From 141b99747ab64744c5a857b553ea1bf2536bf162 Mon Sep 17 00:00:00 2001 From: Christomatt Date: Mon, 26 Jan 2026 13:50:15 +0100 Subject: [PATCH] feat(posts): Add swarm reply tracking and thread visualization - Add swarm reply metadata fields (swarmReplyToId, swarmReplyToContent, swarmReplyToAuthor) to posts table for tracking replies to federated posts - Extend swarmReplyTo schema to include optional content and author information for context preservation - Build synthetic replyTo objects from swarm reply metadata to unify local and federated reply handling - Include media in replyTo relations across all post queries for complete reply context - Add thread container styling with visual line connector between parent and child posts - Implement thread parent display mode with reduced opacity and hidden actions for context - Add showThread and isThreadParent props to PostCard for flexible thread rendering - Enable inline parent post visualization when viewing post details to show conversation context --- src/app/[handle]/posts/[id]/page.tsx | 6 ++-- src/app/api/posts/route.ts | 30 +++++++++++++++--- src/app/globals.css | 31 ++++++++++++++++++ src/components/PostCard.tsx | 47 +++++++++++++++++++++++++--- src/db/schema.ts | 4 +++ src/lib/types.ts | 15 ++++++--- 6 files changed, 116 insertions(+), 17 deletions(-) diff --git a/src/app/[handle]/posts/[id]/page.tsx b/src/app/[handle]/posts/[id]/page.tsx index 8b2ea1f..af72954 100644 --- a/src/app/[handle]/posts/[id]/page.tsx +++ b/src/app/[handle]/posts/[id]/page.tsx @@ -43,7 +43,7 @@ export default function PostDetailPage() { const handlePost = async (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => { // Check if we're replying to a swarm post - let swarmReplyTo: { postId: string; nodeDomain: string } | undefined; + let swarmReplyTo: { postId: string; nodeDomain: string; content?: string; author?: any } | undefined; let localReplyToId: string | undefined = replyToId; if (post?.isSwarm && post.nodeDomain && post.originalPostId) { @@ -51,8 +51,10 @@ export default function PostDetailPage() { swarmReplyTo = { postId: post.originalPostId, nodeDomain: post.nodeDomain, + content: post.content, + author: post.author, }; - localReplyToId = undefined; // Don't set local replyToId for swarm posts + localReplyToId = undefined; // Can't use UUID foreign key for swarm posts } const res = await fetch('/api/posts', { diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 53e74b2..ea2a5f6 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -22,6 +22,13 @@ const createPostSchema = z.object({ swarmReplyTo: z.object({ postId: z.string(), nodeDomain: z.string(), + content: z.string().optional(), + author: z.object({ + handle: z.string(), + displayName: z.string().optional().nullable(), + avatarUrl: z.string().optional().nullable(), + nodeDomain: z.string().optional().nullable(), + }).optional(), }).optional(), mediaIds: z.array(z.string().uuid()).max(4).optional(), isNsfw: z.boolean().optional(), @@ -46,10 +53,23 @@ export async function POST(request: Request) { const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; + // Build swarm reply fields if replying to a swarm post + const swarmReplyFields = data.swarmReplyTo ? { + swarmReplyToId: `swarm:${data.swarmReplyTo.nodeDomain}:${data.swarmReplyTo.postId}`, + swarmReplyToContent: data.swarmReplyTo.content?.slice(0, 300) || null, + swarmReplyToAuthor: data.swarmReplyTo.author ? JSON.stringify({ + handle: data.swarmReplyTo.author.handle, + displayName: data.swarmReplyTo.author.displayName, + avatarUrl: data.swarmReplyTo.author.avatarUrl, + nodeDomain: data.swarmReplyTo.nodeDomain, + }) : null, + } : {}; + const [post] = await db.insert(posts).values({ userId: user.id, content: data.content, replyToId: data.replyToId, + ...swarmReplyFields, isNsfw: data.isNsfw || user.isNsfw || false, // Inherit from account if account is NSFW apId: `https://${nodeDomain}/posts/${crypto.randomUUID()}`, apUrl: `https://${nodeDomain}/posts/${crypto.randomUUID()}`, @@ -374,7 +394,7 @@ export async function GET(request: Request) { bot: true, media: true, replyTo: { - with: { author: true }, + with: { author: true, media: true }, }, }, orderBy: [desc(posts.createdAt)], @@ -389,7 +409,7 @@ export async function GET(request: Request) { bot: true, media: true, replyTo: { - with: { author: true }, + with: { author: true, media: true }, }, }, orderBy: [desc(posts.createdAt)], @@ -417,7 +437,7 @@ export async function GET(request: Request) { bot: true, media: true, replyTo: { - with: { author: true }, + with: { author: true, media: true }, }, }, orderBy: [desc(posts.createdAt)], @@ -560,7 +580,7 @@ export async function GET(request: Request) { bot: true, media: true, replyTo: { - with: { author: true }, + with: { author: true, media: true }, }, }, orderBy: [desc(posts.createdAt)], @@ -670,7 +690,7 @@ export async function GET(request: Request) { bot: true, media: true, replyTo: { - with: { author: true }, + with: { author: true, media: true }, }, }, orderBy: [desc(posts.createdAt)], diff --git a/src/app/globals.css b/src/app/globals.css index 3b69b97..b3a682d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -270,6 +270,37 @@ a.btn-primary:visited { overflow-y: auto; } +/* Thread Container */ +.thread-container { + position: relative; +} + +.thread-container .post { + border-bottom: none; + padding-bottom: 8px; +} + +.thread-line { + position: absolute; + left: 36px; + top: 56px; + bottom: 0; + width: 2px; + background: var(--border); +} + +.post.thread-parent { + opacity: 0.85; +} + +.post.thread-parent .post-actions { + display: none; +} + +.post.thread-parent .post-content { + font-size: 14px; +} + /* Post */ .post { padding: 16px; diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index a06ff23..52a41f8 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -36,9 +36,11 @@ interface PostCardProps { onDelete?: (id: string) => void; onHide?: (id: string) => void; // Called when post should be hidden (block/mute) isDetail?: boolean; + showThread?: boolean; // Show parent post inline as a thread + isThreadParent?: boolean; // This post is being shown as a parent in a thread } -export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, isDetail }: PostCardProps) { +export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, isDetail, showThread = true, isThreadParent }: PostCardProps) { const { user: currentUser } = useAuth(); const { showToast } = useToast(); const [liked, setLiked] = useState(post.isLiked || false); @@ -356,9 +358,43 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, }); }; + // Build a synthetic replyTo for swarm replies + const effectiveReplyTo = post.replyTo || (post.swarmReplyToId && post.swarmReplyToAuthor ? { + id: post.swarmReplyToId, + content: post.swarmReplyToContent || '', + createdAt: post.createdAt, // Use same time as approximation + likesCount: 0, + repostsCount: 0, + repliesCount: 0, + author: typeof post.swarmReplyToAuthor === 'string' + ? JSON.parse(post.swarmReplyToAuthor) + : post.swarmReplyToAuthor, + isSwarm: true, + nodeDomain: (typeof post.swarmReplyToAuthor === 'string' + ? JSON.parse(post.swarmReplyToAuthor) + : post.swarmReplyToAuthor)?.nodeDomain, + } as Post : null); + return ( -
- {!isDetail && } + <> + {/* Show parent post as part of thread */} + {showThread && effectiveReplyTo && !isDetail && !isThreadParent && ( +
+ +
+
+ )} +
+ {!isDetail && }
e.stopPropagation()}> @@ -517,9 +553,9 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, )}
- {post.replyTo && ( + {effectiveReplyTo && !showThread && (
- Replied to e.stopPropagation()}>{formatFullHandle(post.replyTo.author.handle)} + Replying to e.stopPropagation()}>{formatFullHandle(effectiveReplyTo.author.handle)}
)} @@ -601,5 +637,6 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, )}
+ ); } diff --git a/src/db/schema.ts b/src/db/schema.ts index c5a1b01..a72b949 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -106,6 +106,10 @@ export const posts = pgTable('posts', { content: text('content').notNull(), replyToId: uuid('reply_to_id'), repostOfId: uuid('repost_of_id'), + // Swarm reply reference (when replying to a post on another node) + swarmReplyToId: text('swarm_reply_to_id'), // Format: "swarm:domain:postId" + swarmReplyToContent: text('swarm_reply_to_content'), // Cached content for display + swarmReplyToAuthor: text('swarm_reply_to_author'), // JSON: {handle, displayName, avatarUrl, nodeDomain} likesCount: integer('likes_count').default(0).notNull(), repostsCount: integer('reposts_count').default(0).notNull(), repliesCount: integer('replies_count').default(0).notNull(), diff --git a/src/lib/types.ts b/src/lib/types.ts index 330c705..c9447e1 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -50,11 +50,16 @@ export interface Post { linkPreviewTitle?: string | null; linkPreviewDescription?: string | null; linkPreviewImage?: string | null; - replyTo?: { - author: { - handle: string; - displayName: string; - }; + replyTo?: Post | null; + replyToId?: string | null; + // Swarm reply info (when replying to a post on another node) + swarmReplyToId?: string | null; + swarmReplyToContent?: string | null; + swarmReplyToAuthor?: { + handle: string; + displayName?: string | null; + avatarUrl?: string | null; + nodeDomain?: string | null; } | null; isLiked?: boolean; isReposted?: boolean;