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
This commit is contained in:
Christomatt
2026-01-26 13:50:15 +01:00
parent 5d2c05dcbf
commit 141b99747a
6 changed files with 116 additions and 17 deletions
+4 -2
View File
@@ -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', {
+25 -5
View File
@@ -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)],
+31
View File
@@ -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;
+41 -4
View File
@@ -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,8 +358,42 @@ 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 (
<article className={`post ${isDetail ? 'detail' : ''}`}>
<>
{/* Show parent post as part of thread */}
{showThread && effectiveReplyTo && !isDetail && !isThreadParent && (
<div className="thread-container">
<PostCard
post={effectiveReplyTo}
onLike={onLike}
onRepost={onRepost}
onComment={onComment}
onDelete={onDelete}
onHide={onHide}
showThread={false}
isThreadParent={true}
/>
<div className="thread-line" />
</div>
)}
<article className={`post ${isDetail ? 'detail' : ''} ${isThreadParent ? 'thread-parent' : ''}`}>
{!isDetail && <Link href={postUrl} className="post-link-overlay" aria-label="View post" />}
<div className="post-header">
@@ -517,9 +553,9 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
)}
</div>
{post.replyTo && (
{effectiveReplyTo && !showThread && (
<div className="post-reply-to">
Replied to <Link href={`/${post.replyTo.author.handle}`} onClick={(e) => e.stopPropagation()}>{formatFullHandle(post.replyTo.author.handle)}</Link>
Replying to <Link href={`/${effectiveReplyTo.author.handle}`} onClick={(e) => e.stopPropagation()}>{formatFullHandle(effectiveReplyTo.author.handle)}</Link>
</div>
)}
@@ -601,5 +637,6 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
)}
</div>
</article>
</>
);
}
+4
View File
@@ -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(),
+9 -4
View File
@@ -50,11 +50,16 @@ export interface Post {
linkPreviewTitle?: string | null;
linkPreviewDescription?: string | null;
linkPreviewImage?: string | null;
replyTo?: {
author: {
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;
};
displayName?: string | null;
avatarUrl?: string | null;
nodeDomain?: string | null;
} | null;
isLiked?: boolean;
isReposted?: boolean;