**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.
194 lines
5.5 KiB
TypeScript
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" },
|
|
});
|
|
}
|
|
});
|