**Major Features Added:** - **Inline Reply System**: Replace compose screen with inline reply boxes - **Thread Navigation**: Parent/child navigation with jump functionality - **Chain Flow UI**: Reply counts, expand/collapse animations, visual hierarchy - **Enhanced Animations**: Smooth transitions, hover effects, micro-interactions **Frontend Changes:** - **ThreadedCommentWidget**: Complete rewrite with animations and navigation - **ThreadNode Model**: Added parent references and descendant counting - **ThreadedConversationScreen**: Integrated navigation handlers - **PostDetailScreen**: Replaced with threaded conversation view - **ComposeScreen**: Added reply indicators and context - **PostActions**: Fixed visibility checks for chain buttons **Backend Changes:** - **API Route**: Added /posts/:id/thread endpoint - **Post Repository**: Include allow_chain and visibility fields in feed - **Thread Handler**: Support for fetching post chains **UI/UX Improvements:** - **Reply Context**: Clear indication when replying to specific posts - **Character Counting**: 500 character limit with live counter - **Visual Hierarchy**: Depth-based indentation and styling - **Smooth Animations**: SizeTransition, FadeTransition, hover states - **Chain Navigation**: Parent/child buttons with visual feedback **Technical Enhancements:** - **Animation Controllers**: Proper lifecycle management - **State Management**: Clean separation of concerns - **Navigation Callbacks**: Reusable navigation system - **Error Handling**: Graceful fallbacks and user feedback This creates a Reddit-style threaded conversation experience with smooth animations, inline replies, and intuitive navigation between posts in a chain.
442 lines
14 KiB
TypeScript
442 lines
14 KiB
TypeScript
/**
|
|
* GET /profile/:handle - Get user profile by handle
|
|
* GET /profile/me - Get own profile
|
|
* PATCH /profile - Update own profile
|
|
*
|
|
* Design intent:
|
|
* - Profiles are public (unless blocked)
|
|
* - Shows harmony tier (not score)
|
|
* - Minimal public metrics
|
|
*/
|
|
|
|
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
|
|
import { createSupabaseClient } from '../_shared/supabase-client.ts';
|
|
import { ValidationError } from '../_shared/validation.ts';
|
|
import { trySignR2Url } from '../_shared/r2_signer.ts';
|
|
|
|
const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || '*';
|
|
const corsHeaders = {
|
|
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
|
|
'Access-Control-Allow-Methods': 'GET, PATCH',
|
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
|
};
|
|
|
|
serve(async (req) => {
|
|
if (req.method === 'OPTIONS') {
|
|
return new Response(null, {
|
|
headers: corsHeaders,
|
|
});
|
|
}
|
|
|
|
try {
|
|
const authHeader = req.headers.get('Authorization');
|
|
if (!authHeader) {
|
|
return new Response(JSON.stringify({ error: 'Missing authorization header' }), {
|
|
status: 401,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
const supabase = createSupabaseClient(authHeader);
|
|
const {
|
|
data: { user },
|
|
error: authError,
|
|
} = await supabase.auth.getUser();
|
|
|
|
if (authError || !user) {
|
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
status: 401,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
const url = new URL(req.url);
|
|
const handle = url.searchParams.get('handle');
|
|
|
|
// GET /profile/me or /profile?handle=username
|
|
if (req.method === 'GET') {
|
|
let profileQuery;
|
|
let isOwnProfile = false;
|
|
|
|
if (handle) {
|
|
// Get profile by handle
|
|
profileQuery = supabase
|
|
.from('profiles')
|
|
.select(
|
|
`
|
|
id,
|
|
handle,
|
|
display_name,
|
|
bio,
|
|
location,
|
|
website,
|
|
interests,
|
|
avatar_url,
|
|
cover_url,
|
|
origin_country,
|
|
is_private,
|
|
is_official,
|
|
created_at,
|
|
trust_state (
|
|
tier
|
|
)
|
|
`
|
|
)
|
|
.eq('handle', handle)
|
|
.single();
|
|
} else {
|
|
// Get own profile
|
|
isOwnProfile = true;
|
|
profileQuery = supabase
|
|
.from('profiles')
|
|
.select(
|
|
`
|
|
id,
|
|
handle,
|
|
display_name,
|
|
bio,
|
|
location,
|
|
website,
|
|
interests,
|
|
avatar_url,
|
|
cover_url,
|
|
origin_country,
|
|
is_private,
|
|
is_official,
|
|
created_at,
|
|
updated_at,
|
|
trust_state (
|
|
harmony_score,
|
|
tier,
|
|
posts_today
|
|
)
|
|
`
|
|
)
|
|
.eq('id', user.id)
|
|
.single();
|
|
}
|
|
|
|
const { data: profile, error: profileError } = await profileQuery;
|
|
|
|
if (profileError || !profile) {
|
|
return new Response(JSON.stringify({ error: 'Profile not found' }), {
|
|
status: 404,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
// Check if viewing own profile
|
|
if (profile.id === user.id) {
|
|
isOwnProfile = true;
|
|
}
|
|
|
|
// Get privacy settings for this profile
|
|
const { data: privacySettings } = await supabase
|
|
.from('profile_privacy_settings')
|
|
.select('*')
|
|
.eq('user_id', profile.id)
|
|
.maybeSingle();
|
|
|
|
const profileVisibility = privacySettings?.profile_visibility || 'public';
|
|
|
|
// Apply privacy filtering based on viewer relationship
|
|
if (!isOwnProfile && profileVisibility !== 'public') {
|
|
// Check if viewer is following the profile
|
|
const { data: followData } = await supabase
|
|
.from('follows')
|
|
.select('status')
|
|
.eq('follower_id', user.id)
|
|
.eq('following_id', profile.id)
|
|
.maybeSingle();
|
|
|
|
const followStatus = followData?.status as string | null;
|
|
const isFollowing = followStatus === 'accepted';
|
|
let isFollowedBy = false;
|
|
if (user.id !== profile.id) {
|
|
const { data: reverseFollow } = await supabase
|
|
.from('follows')
|
|
.select('status')
|
|
.eq('follower_id', profile.id)
|
|
.eq('following_id', user.id)
|
|
.maybeSingle();
|
|
isFollowedBy = reverseFollow?.status === 'accepted';
|
|
}
|
|
const isFriend = isFollowing && isFollowedBy;
|
|
|
|
// Check privacy visibility
|
|
if (profile.is_private || profileVisibility === 'private') {
|
|
// Private profiles show minimal info to non-followers
|
|
if (!isFollowing) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
profile: {
|
|
id: profile.id,
|
|
handle: profile.handle ?? 'unknown',
|
|
display_name: profile.display_name ?? 'Anonymous',
|
|
avatar_url: profile.avatar_url,
|
|
created_at: profile.created_at,
|
|
trust_state: profile.trust_state,
|
|
},
|
|
stats: {
|
|
posts: 0,
|
|
followers: 0,
|
|
following: 0,
|
|
},
|
|
is_following: false,
|
|
is_followed_by: isFollowedBy,
|
|
is_friend: isFriend,
|
|
follow_status: followStatus,
|
|
is_private: true,
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
}
|
|
);
|
|
}
|
|
} else if (profileVisibility === 'followers') {
|
|
// Followers-only profiles hide details from non-followers
|
|
if (!isFollowing) {
|
|
profile.bio = null;
|
|
profile.location = null;
|
|
profile.website = null;
|
|
profile.interests = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Coalesce null values for older clients
|
|
const safeProfile = {
|
|
...profile,
|
|
handle: profile.handle ?? 'unknown',
|
|
display_name: profile.display_name ?? 'Anonymous',
|
|
};
|
|
|
|
if (safeProfile.avatar_url) {
|
|
safeProfile.avatar_url = await trySignR2Url(safeProfile.avatar_url);
|
|
}
|
|
if (safeProfile.cover_url) {
|
|
safeProfile.cover_url = await trySignR2Url(safeProfile.cover_url);
|
|
}
|
|
|
|
// Get post count
|
|
const { count: postCount } = await supabase
|
|
.from('posts')
|
|
.select('*', { count: 'exact', head: true })
|
|
.eq('author_id', safeProfile.id);
|
|
|
|
// Get follower/following counts
|
|
const { count: followerCount } = await supabase
|
|
.from('follows')
|
|
.select('*', { count: 'exact', head: true })
|
|
.eq('following_id', safeProfile.id)
|
|
.eq('status', 'accepted');
|
|
|
|
const { count: followingCount } = await supabase
|
|
.from('follows')
|
|
.select('*', { count: 'exact', head: true })
|
|
.eq('follower_id', safeProfile.id)
|
|
.eq('status', 'accepted');
|
|
|
|
// Check if current user is following this profile
|
|
let isFollowing = false;
|
|
let isFollowedBy = false;
|
|
let isFriend = false;
|
|
let followStatus: string | null = null;
|
|
if (user && user.id !== safeProfile.id) {
|
|
const { data: followData } = await supabase
|
|
.from('follows')
|
|
.select('status')
|
|
.eq('follower_id', user.id)
|
|
.eq('following_id', safeProfile.id)
|
|
.maybeSingle();
|
|
|
|
followStatus = followData?.status as string | null;
|
|
isFollowing = followStatus === 'accepted';
|
|
|
|
const { data: reverseFollow } = await supabase
|
|
.from('follows')
|
|
.select('status')
|
|
.eq('follower_id', safeProfile.id)
|
|
.eq('following_id', user.id)
|
|
.maybeSingle();
|
|
isFollowedBy = reverseFollow?.status === 'accepted';
|
|
isFriend = isFollowing && isFollowedBy;
|
|
}
|
|
|
|
const isPrivateForViewer = (profile.is_private ?? false) && !isFollowing && !isOwnProfile;
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
profile: safeProfile,
|
|
stats: {
|
|
posts: postCount || 0,
|
|
followers: followerCount || 0,
|
|
following: followingCount || 0,
|
|
},
|
|
is_following: isFollowing,
|
|
is_followed_by: isFollowedBy,
|
|
is_friend: isFriend,
|
|
follow_status: followStatus,
|
|
is_private: isPrivateForViewer,
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
}
|
|
);
|
|
}
|
|
|
|
// PATCH /profile - Update own profile
|
|
if (req.method === 'PATCH') {
|
|
const { handle, display_name, bio, location, website, interests, avatar_url, cover_url } = await req.json();
|
|
|
|
const updates: any = {};
|
|
|
|
// Handle username changes with 30-day limit
|
|
if (handle !== undefined) {
|
|
if (handle.trim().length < 3 || handle.length > 20) {
|
|
throw new ValidationError('Username must be 3-20 characters', 'handle');
|
|
}
|
|
if (!/^[a-zA-Z0-9_]+$/.test(handle)) {
|
|
throw new ValidationError('Username can only contain letters, numbers, and underscores', 'handle');
|
|
}
|
|
|
|
// Check if handle is already taken
|
|
const { data: existingProfile } = await supabase
|
|
.from('profiles')
|
|
.select('id')
|
|
.eq('handle', handle)
|
|
.neq('id', user.id)
|
|
.maybeSingle();
|
|
|
|
if (existingProfile) {
|
|
throw new ValidationError('Username is already taken', 'handle');
|
|
}
|
|
|
|
// Check 30-day limit
|
|
const { data: canChange, error: canChangeError } = await supabase
|
|
.rpc('can_change_handle', { p_user_id: user.id });
|
|
|
|
if (canChangeError || !canChange) {
|
|
throw new ValidationError('You can only change your username once every 30 days', 'handle');
|
|
}
|
|
|
|
updates.handle = handle;
|
|
}
|
|
|
|
if (display_name !== undefined) {
|
|
if (display_name.trim().length === 0 || display_name.length > 50) {
|
|
throw new ValidationError('Display name must be 1-50 characters', 'display_name');
|
|
}
|
|
updates.display_name = display_name;
|
|
}
|
|
|
|
if (bio !== undefined) {
|
|
if (bio && bio.length > 300) {
|
|
throw new ValidationError('Bio must be 300 characters or less', 'bio');
|
|
}
|
|
updates.bio = bio || null;
|
|
}
|
|
|
|
if (location !== undefined) {
|
|
if (location && location.length > 100) {
|
|
throw new ValidationError('Location must be 100 characters or less', 'location');
|
|
}
|
|
updates.location = location || null;
|
|
}
|
|
|
|
if (website !== undefined) {
|
|
if (website) {
|
|
if (website.length > 200) {
|
|
throw new ValidationError('Website must be 200 characters or less', 'website');
|
|
}
|
|
// Validate URL format and scheme
|
|
try {
|
|
const url = new URL(website.startsWith('http') ? website : `https://${website}`);
|
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
throw new ValidationError('Website must be a valid HTTP or HTTPS URL', 'website');
|
|
}
|
|
} catch (error) {
|
|
throw new ValidationError('Website must be a valid URL', 'website');
|
|
}
|
|
}
|
|
updates.website = website || null;
|
|
}
|
|
|
|
if (interests !== undefined) {
|
|
if (Array.isArray(interests)) {
|
|
updates.interests = interests;
|
|
} else {
|
|
throw new ValidationError('Interests must be an array', 'interests');
|
|
}
|
|
}
|
|
|
|
if (avatar_url !== undefined) {
|
|
updates.avatar_url = avatar_url || null;
|
|
}
|
|
|
|
if (cover_url !== undefined) {
|
|
updates.cover_url = cover_url || null;
|
|
}
|
|
|
|
if (Object.keys(updates).length === 0) {
|
|
return new Response(JSON.stringify({ error: 'No fields to update' }), {
|
|
status: 400,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
updates.updated_at = new Date().toISOString();
|
|
|
|
const { data: profile, error: updateError } = await supabase
|
|
.from('profiles')
|
|
.update(updates)
|
|
.eq('id', user.id)
|
|
.select()
|
|
.single();
|
|
|
|
if (updateError) {
|
|
console.error('Error updating profile:', updateError);
|
|
return new Response(JSON.stringify({
|
|
error: 'Failed to update profile',
|
|
details: updateError.message,
|
|
code: updateError.code
|
|
}), {
|
|
status: 500,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
profile,
|
|
message: 'Profile updated',
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
}
|
|
);
|
|
}
|
|
|
|
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
|
|
status: 405,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof ValidationError) {
|
|
return new Response(
|
|
JSON.stringify({ error: 'Validation error', message: error.message }),
|
|
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
);
|
|
}
|
|
|
|
console.error('Unexpected error:', error);
|
|
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
|
status: 500,
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
});
|