sojorn/_legacy/supabase/functions/profile/index.ts
Patrick Britton 3c4680bdd7 Initial commit: Complete threaded conversation system with inline replies
**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.
2026-01-30 07:40:19 -06:00

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' },
});
}
});