feat(swarm,posts): Add federated reply support for swarm posts

- Add new /api/swarm/replies endpoint to receive and store replies from remote nodes
- Implement swarm reply detection in post detail page to route replies to origin node
- Update POST /api/posts to handle swarmReplyTo payload and deliver replies to remote nodes
- Enhance swarm post transformation to include originalPostId for reply tracking
- Improve media and link preview handling in swarm post responses
- Add swarmReplyTo schema validation with postId and nodeDomain fields
- Store remote replies with swarm identifiers (swarm:domain:id format) to prevent duplicates
- Enable cross-node reply federation with async delivery to origin nodes
- Update PostCard and Compose components to support federated reply workflows
This commit is contained in:
AskIt
2026-01-26 03:18:49 +01:00
parent 302ee41cd8
commit dc9ed98091
11 changed files with 371 additions and 68 deletions
+14 -1
View File
@@ -42,10 +42,23 @@ export default function PostDetailPage() {
}, [id]); }, [id]);
const handlePost = async (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => { 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 localReplyToId: string | undefined = replyToId;
if (post?.isSwarm && post.nodeDomain && post.originalPostId) {
// This is a reply to a swarm post - send to the origin node
swarmReplyTo = {
postId: post.originalPostId,
nodeDomain: post.nodeDomain,
};
localReplyToId = undefined; // Don't set local replyToId for swarm posts
}
const res = await fetch('/api/posts', { const res = await fetch('/api/posts', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, mediaIds, linkPreview, replyToId, isNsfw }), body: JSON.stringify({ content, mediaIds, linkPreview, replyToId: localReplyToId, swarmReplyTo, isNsfw }),
}); });
if (res.ok) { if (res.ok) {
+53 -4
View File
@@ -18,7 +18,11 @@ const buildWhere = (...conditions: Array<SQL | undefined>) => {
const createPostSchema = z.object({ const createPostSchema = z.object({
content: z.string().min(1).max(POST_MAX_LENGTH), content: z.string().min(1).max(POST_MAX_LENGTH),
replyToId: z.string().uuid().optional(), replyToId: z.string().optional(), // Can be UUID or swarm:domain:uuid
swarmReplyTo: z.object({
postId: z.string(),
nodeDomain: z.string(),
}).optional(),
mediaIds: z.array(z.string().uuid()).max(4).optional(), mediaIds: z.array(z.string().uuid()).max(4).optional(),
isNsfw: z.boolean().optional(), isNsfw: z.boolean().optional(),
linkPreview: z.object({ linkPreview: z.object({
@@ -91,6 +95,45 @@ export async function POST(request: Request) {
} }
} }
// If this is a reply to a swarm post, deliver it to the origin node
if (data.swarmReplyTo) {
(async () => {
try {
const targetUrl = `https://${data.swarmReplyTo!.nodeDomain}/api/swarm/replies`;
const replyPayload = {
postId: data.swarmReplyTo!.postId,
reply: {
id: post.id,
content: post.content,
createdAt: post.createdAt.toISOString(),
author: {
handle: user.handle,
displayName: user.displayName || user.handle,
avatarUrl: user.avatarUrl || undefined,
},
nodeDomain,
mediaUrls: attachedMedia.map(m => m.url),
},
};
const response = await fetch(targetUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(replyPayload),
});
if (response.ok) {
console.log(`[Swarm] Reply delivered to ${data.swarmReplyTo!.nodeDomain}`);
} else {
console.error(`[Swarm] Failed to deliver reply: ${response.status}`);
}
} catch (err) {
console.error('[Swarm] Error delivering reply:', err);
}
})();
}
// Federate the post to remote followers (non-blocking) // Federate the post to remote followers (non-blocking)
(async () => { (async () => {
try { try {
@@ -301,6 +344,7 @@ export async function GET(request: Request) {
// Transform swarm posts to match local post format // Transform swarm posts to match local post format
const swarmPosts = swarmResult.posts.map(sp => ({ const swarmPosts = swarmResult.posts.map(sp => ({
id: `swarm:${sp.nodeDomain}:${sp.id}`, id: `swarm:${sp.nodeDomain}:${sp.id}`,
originalPostId: sp.id, // Keep the original ID for replies
content: sp.content, content: sp.content,
createdAt: new Date(sp.createdAt), createdAt: new Date(sp.createdAt),
likesCount: sp.likeCount, likesCount: sp.likeCount,
@@ -316,11 +360,16 @@ export async function GET(request: Request) {
isSwarm: true, isSwarm: true,
nodeDomain: sp.nodeDomain, nodeDomain: sp.nodeDomain,
}, },
media: sp.mediaUrls?.map((url, idx) => ({ media: sp.media?.map((m, idx) => ({
id: `swarm:${sp.nodeDomain}:${sp.id}:media:${idx}`, id: `swarm:${sp.nodeDomain}:${sp.id}:media:${idx}`,
url, url: m.url,
altText: null, altText: m.altText || null,
mimeType: m.mimeType || null,
})) || [], })) || [],
linkPreviewUrl: sp.linkPreviewUrl || null,
linkPreviewTitle: sp.linkPreviewTitle || null,
linkPreviewDescription: sp.linkPreviewDescription || null,
linkPreviewImage: sp.linkPreviewImage || null,
replyTo: null, replyTo: null,
})); }));
+189
View File
@@ -0,0 +1,189 @@
/**
* Swarm Replies Endpoint
*
* POST: Receive a reply from another node
* GET: Fetch replies to a post on this node
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, media } from '@/db';
import { eq, desc, and } from 'drizzle-orm';
import { z } from 'zod';
// Schema for incoming swarm reply
const swarmReplySchema = z.object({
postId: z.string().uuid(), // The local post being replied to
reply: z.object({
id: z.string(), // Original reply ID on the sender's node
content: z.string(),
createdAt: z.string(),
author: z.object({
handle: z.string(),
displayName: z.string(),
avatarUrl: z.string().optional(),
}),
nodeDomain: z.string(),
mediaUrls: z.array(z.string()).optional(),
}),
});
/**
* POST /api/swarm/replies
*
* Receives a reply from another node in the swarm.
* The reply is stored as a remote reply linked to the local post.
*/
export async function POST(request: NextRequest) {
try {
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const body = await request.json();
const data = swarmReplySchema.parse(body);
// Verify the target post exists on this node
const targetPost = await db.query.posts.findFirst({
where: eq(posts.id, data.postId),
});
if (!targetPost) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
// Check if we already have this reply (by swarm ID)
const swarmReplyId = `swarm:${data.reply.nodeDomain}:${data.reply.id}`;
const existingReply = await db.query.posts.findFirst({
where: eq(posts.apId, swarmReplyId),
});
if (existingReply) {
return NextResponse.json({ success: true, message: 'Reply already exists' });
}
// We need a system user to attribute swarm replies to
// For now, we'll store them with metadata in the apId/apUrl fields
// and create a virtual representation
// Get or create a placeholder user for this remote author
let remoteUser = await db.query.users.findFirst({
where: eq(users.handle, `${data.reply.author.handle}@${data.reply.nodeDomain}`),
});
if (!remoteUser) {
// Create a placeholder user for the remote author
const [newUser] = await db.insert(users).values({
did: `did:swarm:${data.reply.nodeDomain}:${data.reply.author.handle}`,
handle: `${data.reply.author.handle}@${data.reply.nodeDomain}`,
displayName: data.reply.author.displayName,
avatarUrl: data.reply.author.avatarUrl || null,
publicKey: 'swarm-remote-user', // Placeholder
}).returning();
remoteUser = newUser;
}
// Create the reply post
const [replyPost] = await db.insert(posts).values({
userId: remoteUser.id,
content: data.reply.content,
replyToId: data.postId,
apId: swarmReplyId,
apUrl: `https://${data.reply.nodeDomain}/${data.reply.author.handle}/posts/${data.reply.id}`,
createdAt: new Date(data.reply.createdAt),
}).returning();
// Update the parent post's reply count
await db.update(posts)
.set({ repliesCount: targetPost.repliesCount + 1 })
.where(eq(posts.id, data.postId));
console.log(`[Swarm] Received reply from ${data.reply.nodeDomain} to post ${data.postId}`);
return NextResponse.json({
success: true,
replyId: replyPost.id,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid request', details: error.issues }, { status: 400 });
}
console.error('[Swarm] Reply error:', error);
return NextResponse.json({ error: 'Failed to process reply' }, { status: 500 });
}
}
/**
* GET /api/swarm/replies?postId=xxx
*
* Returns replies to a specific post on this node.
* Used by other nodes to fetch reply threads.
*/
export async function GET(request: NextRequest) {
try {
if (!db) {
return NextResponse.json({ replies: [] });
}
const { searchParams } = new URL(request.url);
const postId = searchParams.get('postId');
if (!postId) {
return NextResponse.json({ error: 'postId required' }, { status: 400 });
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
// Get replies to this post
const replies = await db
.select({
id: posts.id,
content: posts.content,
createdAt: posts.createdAt,
likesCount: posts.likesCount,
repostsCount: posts.repostsCount,
repliesCount: posts.repliesCount,
authorHandle: users.handle,
authorDisplayName: users.displayName,
authorAvatarUrl: users.avatarUrl,
})
.from(posts)
.innerJoin(users, eq(posts.userId, users.id))
.where(
and(
eq(posts.replyToId, postId),
eq(posts.isRemoved, false)
)
)
.orderBy(desc(posts.createdAt))
.limit(50);
// Format replies for swarm consumption
const formattedReplies = replies.map(reply => ({
id: reply.id,
content: reply.content,
createdAt: reply.createdAt.toISOString(),
author: {
handle: reply.authorHandle.includes('@')
? reply.authorHandle.split('@')[0]
: reply.authorHandle,
displayName: reply.authorDisplayName || reply.authorHandle,
avatarUrl: reply.authorAvatarUrl || undefined,
},
nodeDomain: reply.authorHandle.includes('@')
? reply.authorHandle.split('@')[1]
: nodeDomain,
likeCount: reply.likesCount,
repostCount: reply.repostsCount,
replyCount: reply.repliesCount,
}));
return NextResponse.json({
postId,
replies: formattedReplies,
nodeDomain,
});
} catch (error) {
console.error('[Swarm] Fetch replies error:', error);
return NextResponse.json({ error: 'Failed to fetch replies' }, { status: 500 });
}
}
+20 -3
View File
@@ -24,7 +24,12 @@ export interface SwarmPost {
likeCount: number; likeCount: number;
repostCount: number; repostCount: number;
replyCount: number; replyCount: number;
mediaUrls?: string[]; media?: { url: string; mimeType?: string; altText?: string }[];
// Link preview
linkPreviewUrl?: string;
linkPreviewTitle?: string;
linkPreviewDescription?: string;
linkPreviewImage?: string;
} }
/** /**
@@ -60,6 +65,10 @@ export async function GET(request: NextRequest) {
likesCount: posts.likesCount, likesCount: posts.likesCount,
repostsCount: posts.repostsCount, repostsCount: posts.repostsCount,
repliesCount: posts.repliesCount, repliesCount: posts.repliesCount,
linkPreviewUrl: posts.linkPreviewUrl,
linkPreviewTitle: posts.linkPreviewTitle,
linkPreviewDescription: posts.linkPreviewDescription,
linkPreviewImage: posts.linkPreviewImage,
authorHandle: users.handle, authorHandle: users.handle,
authorDisplayName: users.displayName, authorDisplayName: users.displayName,
authorAvatarUrl: users.avatarUrl, authorAvatarUrl: users.avatarUrl,
@@ -82,7 +91,7 @@ export async function GET(request: NextRequest) {
for (const post of recentPosts) { for (const post of recentPosts) {
const postMedia = await db const postMedia = await db
.select({ url: media.url }) .select({ url: media.url, mimeType: media.mimeType, altText: media.altText })
.from(media) .from(media)
.where(eq(media.postId, post.id)); .where(eq(media.postId, post.id));
@@ -102,7 +111,15 @@ export async function GET(request: NextRequest) {
likeCount: post.likesCount, likeCount: post.likesCount,
repostCount: post.repostsCount, repostCount: post.repostsCount,
replyCount: post.repliesCount, replyCount: post.repliesCount,
mediaUrls: postMedia.length > 0 ? postMedia.map(m => m.url) : undefined, media: postMedia.length > 0 ? postMedia.map(m => ({
url: m.url,
mimeType: m.mimeType || undefined,
altText: m.altText || undefined,
})) : undefined,
linkPreviewUrl: post.linkPreviewUrl || undefined,
linkPreviewTitle: post.linkPreviewTitle || undefined,
linkPreviewDescription: post.linkPreviewDescription || undefined,
linkPreviewImage: post.linkPreviewImage || undefined,
}); });
} }
+44 -38
View File
@@ -72,7 +72,11 @@ interface SwarmPost {
likeCount: number; likeCount: number;
repostCount: number; repostCount: number;
replyCount: number; replyCount: number;
mediaUrls?: string[]; media?: { url: string; mimeType?: string; altText?: string }[];
linkPreviewUrl?: string;
linkPreviewTitle?: string;
linkPreviewDescription?: string;
linkPreviewImage?: string;
} }
export default function ExplorePage() { export default function ExplorePage() {
@@ -299,43 +303,45 @@ export default function ExplorePage() {
</div> </div>
</div> </div>
<div className="explore-posts"> <div className="explore-posts">
{swarmPosts.map((post) => ( {swarmPosts.map((post) => {
<div key={`${post.nodeDomain}:${post.id}`} className="swarm-post-wrapper"> // Transform swarm post to Post format for PostCard
<div className="swarm-post-card card"> const transformedPost: Post = {
<div className="swarm-post-header"> id: `swarm:${post.nodeDomain}:${post.id}`,
<div className="avatar"> originalPostId: post.id,
{post.author.avatarUrl ? ( content: post.content,
<img src={post.author.avatarUrl} alt={post.author.displayName} /> createdAt: post.createdAt,
) : ( likesCount: post.likeCount,
post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase() repostsCount: post.repostCount,
)} repliesCount: post.replyCount,
</div> isSwarm: true,
<div className="swarm-post-meta"> nodeDomain: post.nodeDomain,
<span className="swarm-post-author">{post.author.displayName}</span> author: {
<span className="swarm-post-handle">@{post.author.handle}@{post.nodeDomain}</span> id: `swarm:${post.nodeDomain}:${post.author.handle}`,
</div> handle: post.author.handle,
</div> displayName: post.author.displayName,
<div className="swarm-post-content">{post.content}</div> avatarUrl: post.author.avatarUrl,
{post.mediaUrls && post.mediaUrls.length > 0 && ( },
<div className="swarm-post-media"> media: post.media?.map((m, idx) => ({
{post.mediaUrls.map((url, i) => ( id: `swarm:${post.nodeDomain}:${post.id}:media:${idx}`,
<img key={i} src={url} alt="" /> url: m.url,
))} altText: m.altText || null,
</div> mimeType: m.mimeType || null,
)} })) || [],
<div className="swarm-post-footer"> linkPreviewUrl: post.linkPreviewUrl || null,
<span className="swarm-post-time"> linkPreviewTitle: post.linkPreviewTitle || null,
{new Date(post.createdAt).toLocaleString()} linkPreviewDescription: post.linkPreviewDescription || null,
</span> linkPreviewImage: post.linkPreviewImage || null,
<span className="swarm-post-stats"> };
{post.likeCount > 0 && `${post.likeCount} likes`} return (
{post.likeCount > 0 && post.repostCount > 0 && ' · '} <PostCard
{post.repostCount > 0 && `${post.repostCount} reposts`} key={`${post.nodeDomain}:${post.id}`}
</span> post={transformedPost}
</div> onLike={handleLike}
</div> onRepost={handleRepost}
</div> onDelete={handleDelete}
))} />
);
})}
</div> </div>
</> </>
) )
+14 -14
View File
@@ -558,10 +558,11 @@ a.btn-primary:visited {
} }
.compose-media-grid { .compose-media-grid {
display: grid; display: flex;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); flex-wrap: wrap;
gap: 8px; gap: 8px;
margin-top: 12px; margin-top: 12px;
align-items: flex-start;
} }
.compose-media-item { .compose-media-item {
@@ -570,34 +571,33 @@ a.btn-primary:visited {
overflow: hidden; overflow: hidden;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--background-tertiary); background: var(--background-tertiary);
max-height: 80px;
} }
.compose-media-item img { .compose-media-item img {
width: 100%; height: 80px;
height: 120px; width: auto;
object-fit: cover;
display: block; display: block;
} }
.compose-media-item video { .compose-media-item video {
width: 100%; height: 80px;
height: 120px; width: auto;
object-fit: cover;
display: block; display: block;
} }
.compose-media-remove { .compose-media-remove {
position: absolute; position: absolute;
top: 6px; top: 4px;
right: 6px; right: 4px;
width: 24px; width: 20px;
height: 24px; height: 20px;
border-radius: var(--radius-full); border-radius: var(--radius-full);
border: none; border: none;
background: rgba(0, 0, 0, 0.6); background: rgba(0, 0, 0, 0.7);
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
font-size: 16px; font-size: 12px;
line-height: 1; line-height: 1;
} }
+14 -1
View File
@@ -68,10 +68,23 @@ export default function Home() {
}, [feedType]); }, [feedType]);
const handlePost = async (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => { 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 localReplyToId: string | undefined = replyToId;
if (replyingTo?.isSwarm && replyingTo.nodeDomain && replyingTo.originalPostId) {
// This is a reply to a swarm post - send to the origin node
swarmReplyTo = {
postId: replyingTo.originalPostId,
nodeDomain: replyingTo.nodeDomain,
};
localReplyToId = undefined; // Don't set local replyToId for swarm posts
}
const res = await fetch('/api/posts', { const res = await fetch('/api/posts', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, mediaIds, linkPreview, replyToId, isNsfw }), body: JSON.stringify({ content, mediaIds, linkPreview, replyToId: localReplyToId, swarmReplyTo, isNsfw }),
}); });
if (res.ok) { if (res.ok) {
+12 -2
View File
@@ -30,10 +30,11 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
const [lastDetectedUrl, setLastDetectedUrl] = useState<string | null>(null); const [lastDetectedUrl, setLastDetectedUrl] = useState<string | null>(null);
const [isNsfw, setIsNsfw] = useState(false); const [isNsfw, setIsNsfw] = useState(false);
const [canPostNsfw, setCanPostNsfw] = useState(false); const [canPostNsfw, setCanPostNsfw] = useState(false);
const [isNsfwNode, setIsNsfwNode] = useState(false);
const maxLength = 400; const maxLength = 400;
const remaining = maxLength - content.length; const remaining = maxLength - content.length;
// Check if user can post NSFW content // Check if user can post NSFW content and if node is NSFW
useEffect(() => { useEffect(() => {
fetch('/api/settings/nsfw') fetch('/api/settings/nsfw')
.then(res => res.ok ? res.json() : null) .then(res => res.ok ? res.json() : null)
@@ -43,6 +44,15 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
} }
}) })
.catch(() => {}); .catch(() => {});
fetch('/api/node')
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data?.isNsfw) {
setIsNsfwNode(true);
}
})
.catch(() => {});
}, []); }, []);
// Detect URLs in content // Detect URLs in content
@@ -214,7 +224,7 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
<span className={`compose-counter ${remaining < 50 ? (remaining < 0 ? 'error' : 'warning') : ''}`}> <span className={`compose-counter ${remaining < 50 ? (remaining < 0 ? 'error' : 'warning') : ''}`}>
{remaining} {remaining}
</span> </span>
{canPostNsfw && ( {canPostNsfw && !isNsfwNode && (
<label className="compose-nsfw-toggle" title="Mark as sensitive content"> <label className="compose-nsfw-toggle" title="Mark as sensitive content">
<input <input
type="checkbox" type="checkbox"
+1 -1
View File
@@ -380,7 +380,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
</span> </span>
)} )}
</div> </div>
<span className="post-time">{formatFullHandle(post.author.handle)} · {formatTime(post.createdAt)}</span> <span className="post-time">{formatFullHandle(post.author.handle, post.nodeDomain)} · {formatTime(post.createdAt)}</span>
</div> </div>
{currentUser && currentUser.id !== post.author.id && ( {currentUser && currentUser.id !== post.author.id && (
<div style={{ position: 'relative', marginLeft: 'auto' }}> <div style={{ position: 'relative', marginLeft: 'auto' }}>
+2
View File
@@ -63,4 +63,6 @@ export interface Post {
ownerId: string; ownerId: string;
} | null; } | null;
nodeDomain?: string | null; // Domain of the node this post came from (for swarm posts) nodeDomain?: string | null; // Domain of the node this post came from (for swarm posts)
isSwarm?: boolean; // Whether this is a swarm post from another node
originalPostId?: string; // Original post ID on the source node (for swarm posts)
} }
+8 -4
View File
@@ -3,9 +3,12 @@ export const NODE_DOMAIN = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:300
/** /**
* Formats a handle into its full federated form: @user@domain * Formats a handle into its full federated form: @user@domain
* If the handle already contains a domain (e.g. user@other.com), it returns it as @user@other.com * If the handle already contains a domain (e.g. user@other.com), it returns it as @user@other.com
* If it's a local handle (e.g. user), it appends the local node domain: @user@localnode.com * If it's a local handle (e.g. user), it appends the provided domain or local node domain: @user@domain
*
* @param handle - The user handle (with or without domain)
* @param nodeDomain - Optional domain override for swarm posts
*/ */
export function formatFullHandle(handle: string): string { export function formatFullHandle(handle: string, nodeDomain?: string | null): string {
if (!handle) return ''; if (!handle) return '';
// Remove leading @ if present for processing // Remove leading @ if present for processing
@@ -16,6 +19,7 @@ export function formatFullHandle(handle: string): string {
return `@${cleanHandle}`; return `@${cleanHandle}`;
} }
// Append local node domain // Use provided domain or fall back to local node domain
return `@${cleanHandle}@${NODE_DOMAIN}`; const domain = nodeDomain || NODE_DOMAIN;
return `@${cleanHandle}@${domain}`;
} }