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;