sojorn/_legacy/supabase/functions/profile-posts/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

194 lines
5.5 KiB
TypeScript

import { serve } from "https://deno.land/std@0.177.0/http/server.ts";
import { createSupabaseClient, createServiceClient } from "../_shared/supabase-client.ts";
import { trySignR2Url } from "../_shared/r2_signer.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
const postSelect = `
id,
body,
author_id,
category_id,
tags,
body_format,
background_id,
tone_label,
cis_score,
status,
created_at,
edited_at,
expires_at,
is_edited,
allow_chain,
chain_parent_id,
image_url,
video_url,
thumbnail_url,
duration_ms,
type,
visibility,
pinned_at,
chain_parent:posts (
id,
body,
created_at,
author:profiles!posts_author_id_fkey (
id,
handle,
display_name
)
),
metrics:post_metrics!post_metrics_post_id_fkey (
like_count,
save_count,
view_count
),
author:profiles!posts_author_id_fkey (
id,
handle,
display_name,
trust_state (
user_id,
harmony_score,
tier,
posts_today
)
)
`;
serve(async (req: Request) => {
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 authorId = url.searchParams.get("author_id");
if (!authorId) {
return new Response(JSON.stringify({ error: "Missing author_id" }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const limit = Math.min(parseInt(url.searchParams.get("limit") || "20"), 100);
const offset = parseInt(url.searchParams.get("offset") || "0");
// Use service client to bypass RLS issues
const serviceClient = createServiceClient();
// Check visibility rules manually:
// - User can always see their own posts
// - Public posts are visible to everyone
// - Followers-only posts require accepted follow
// - Private posts only visible to author
const isOwnProfile = authorId === user.id;
// Get author's profile to check privacy settings
const { data: authorProfile } = await serviceClient
.from("profiles")
.select("is_private, is_official")
.eq("id", authorId)
.single();
// Check if viewer follows the author (for followers-only posts)
let hasAcceptedFollow = false;
if (!isOwnProfile) {
const { data: followRow } = await serviceClient
.from("follows")
.select("status")
.eq("follower_id", user.id)
.eq("following_id", authorId)
.eq("status", "accepted")
.maybeSingle();
hasAcceptedFollow = !!followRow;
}
// Build query with appropriate visibility filter
// Note: posts use 'active' status for published posts
let query = serviceClient
.from("posts")
.select(postSelect)
.eq("author_id", authorId)
.eq("status", "active");
// Apply visibility filters based on relationship
if (isOwnProfile) {
// User can see all their own posts (no visibility filter)
} else if (hasAcceptedFollow) {
// Follower can see public and followers-only posts
query = query.in("visibility", ["public", "followers"]);
} else if (authorProfile?.is_official || authorProfile?.is_private === false) {
// Public/official profiles - only public posts
query = query.eq("visibility", "public");
} else {
// Private profile without follow - only public posts
query = query.eq("visibility", "public");
}
const { data: posts, error } = await query
.order("pinned_at", { ascending: false, nullsFirst: false })
.order("created_at", { ascending: false })
.range(offset, offset + limit - 1);
if (error) {
console.error("Profile posts fetch error:", error);
return new Response(JSON.stringify({ error: "Failed to fetch posts" }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const signedPosts = await Promise.all(
(posts || []).map(async (post: any) => {
const imageUrl = post.image_url ? await trySignR2Url(post.image_url) : null;
const thumbUrl = post.thumbnail_url ? await trySignR2Url(post.thumbnail_url) : null;
return {
...post,
image_url: imageUrl,
thumbnail_url: thumbUrl,
};
})
);
return new Response(
JSON.stringify({
posts: signedPosts,
pagination: { limit, offset, returned: signedPosts.length },
}),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Unexpected profile-posts error:", error);
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});