Fix federated interactions and reply threads

This commit is contained in:
cyph3rasi
2026-03-07 21:27:22 -08:00
parent 1e2da4f2da
commit 5d524a0354
21 changed files with 241 additions and 59 deletions
+3 -4
View File
@@ -5,12 +5,11 @@ import { requireSignedAction } from '@/lib/auth/verify-signature';
import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
import { createSignedPayload } from '@/lib/swarm/signature';
const handleRegex = /^[a-zA-Z0-9_]{3,20}$/;
import { federatedHandleSchema } from '@/lib/utils/federation';
const chatSendSchema = z.object({
recipientDid: z.string().min(1).regex(/^did:/, 'Must be a valid DID (did:key:... or did:synapsis:...)'),
recipientHandle: z.string().min(3).max(30).regex(handleRegex, 'Handle must be 3-20 characters, alphanumeric and underscores only'),
recipientHandle: federatedHandleSchema,
content: z.string().min(1).max(5000),
});
@@ -267,4 +266,4 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: error.message || 'Failed to send message' }, { status: 500 });
}
}
}
+56 -2
View File
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { db, posts, users, media, remotePosts } from '@/db';
import { eq, desc, and, sql } from 'drizzle-orm';
import { eq, desc, and, sql, inArray } from 'drizzle-orm';
import { z } from 'zod';
// Schema for local post ID (UUID)
@@ -77,7 +77,7 @@ export async function GET(
linkPreviewImage: data.post.linkPreviewImage,
};
// Transform replies
// Transform replies from the origin node
replyPosts = (data.replies || []).map((r: any) => ({
id: `swarm:${originDomain}:${r.id}`,
originalPostId: r.id,
@@ -103,6 +103,60 @@ export async function GET(
})) || [],
}));
const localSwarmReplyId = `swarm:${originDomain}:${originalPostId}`;
const localReplies = await db.query.posts.findMany({
where: and(
eq(posts.swarmReplyToId, localSwarmReplyId),
eq(posts.isRemoved, false)
),
with: {
author: true,
media: true,
},
orderBy: [desc(posts.createdAt)],
});
if (localReplies.length > 0) {
let likedReplyIds = new Set<string>();
let repostedReplyIds = new Set<string | null>();
try {
const { requireAuth } = await import('@/lib/auth');
const { likes } = await import('@/db');
const viewer = await requireAuth();
const localReplyIds = localReplies.map(reply => reply.id);
const viewerLikes = await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
inArray(likes.postId, localReplyIds)
),
});
likedReplyIds = new Set(viewerLikes.map(like => like.postId));
const viewerReposts = await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, localReplyIds),
eq(posts.isRemoved, false)
),
});
repostedReplyIds = new Set(viewerReposts.map(reply => reply.repostOfId));
} catch {
}
const formattedLocalReplies = localReplies.map((reply: any) => ({
...reply,
isLiked: likedReplyIds.has(reply.id),
isReposted: repostedReplyIds.has(reply.id),
}));
replyPosts = [...formattedLocalReplies, ...replyPosts]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
mainPost.repliesCount = (mainPost.repliesCount || 0) + localReplies.length;
}
// Check if current user has liked this post
try {
const { requireAuth } = await import('@/lib/auth');
+20 -1
View File
@@ -534,6 +534,25 @@ export async function GET(request: Request) {
});
// Transform swarm posts to match local post format
const localSwarmReplyIds = swarmResult.posts.map(sp => `swarm:${sp.nodeDomain}:${sp.id}`);
const localReplyCounts = localSwarmReplyIds.length > 0
? await db.select({
swarmReplyToId: posts.swarmReplyToId,
count: sql<number>`count(*)`,
})
.from(posts)
.where(and(
inArray(posts.swarmReplyToId, localSwarmReplyIds),
eq(posts.isRemoved, false)
))
.groupBy(posts.swarmReplyToId)
: [];
const localReplyCountMap = new Map(
localReplyCounts
.filter(row => row.swarmReplyToId)
.map(row => [row.swarmReplyToId as string, Number(row.count || 0)])
);
const swarmPosts = swarmResult.posts.map(sp => ({
id: `swarm:${sp.nodeDomain}:${sp.id}`,
originalPostId: sp.id, // Keep the original ID for replies
@@ -541,7 +560,7 @@ export async function GET(request: Request) {
createdAt: new Date(sp.createdAt),
likesCount: sp.likeCount,
repostsCount: sp.repostCount,
repliesCount: sp.replyCount,
repliesCount: sp.replyCount + (localReplyCountMap.get(`swarm:${sp.nodeDomain}:${sp.id}`) || 0),
isSwarm: true,
nodeDomain: sp.nodeDomain,
author: {
+7 -1
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { db, users, posts, likes } from '@/db';
import { ilike, or, desc, and, notInArray, eq, inArray } from 'drizzle-orm';
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
import { discoverNode } from '@/lib/swarm/discovery';
type SearchUser = {
id: string;
@@ -127,7 +128,12 @@ export async function GET(request: Request) {
const parsedRemote = parseRemoteHandleQuery(query);
if (parsedRemote) {
// Only lookup on swarm nodes
const isSwarm = await isSwarmNode(parsedRemote.domain);
let isSwarm = await isSwarmNode(parsedRemote.domain);
if (!isSwarm) {
const discovery = await discoverNode(parsedRemote.domain);
isSwarm = discovery.success;
}
if (isSwarm) {
try {
const profileData = await fetchSwarmUserProfile(parsedRemote.handle, parsedRemote.domain, 0);
@@ -14,15 +14,16 @@ import { db, users, notifications, remoteFollowers } from '@/db';
import { eq, and, sql } from 'drizzle-orm';
import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
const swarmFollowSchema = z.object({
targetHandle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/, 'Handle must be alphanumeric with underscores'),
targetHandle: localHandleSchema,
follow: z.object({
followerHandle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/, 'Handle must be alphanumeric with underscores'),
followerHandle: localHandleSchema,
followerDisplayName: z.string().min(1).max(50),
followerAvatarUrl: z.string().url().optional(),
followerBio: z.string().max(500).optional(),
followerNodeDomain: z.string().min(1).regex(/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/, 'Invalid domain format'),
followerNodeDomain: nodeDomainSchema,
interactionId: z.string().uuid(),
timestamp: z.string().datetime(),
}),
+3 -2
View File
@@ -11,14 +11,15 @@ import { db, posts, users, notifications, remoteLikes } from '@/db';
import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
const swarmLikeSchema = z.object({
postId: z.string().uuid(),
like: z.object({
actorHandle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/, 'Handle must be alphanumeric with underscores'),
actorHandle: localHandleSchema,
actorDisplayName: z.string().min(1).max(50),
actorAvatarUrl: z.string().url().optional(),
actorNodeDomain: z.string().min(1).regex(/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/, 'Invalid domain format'),
actorNodeDomain: nodeDomainSchema,
interactionId: z.string().uuid(),
timestamp: z.string().datetime(),
}),
@@ -11,18 +11,19 @@ import { db, users, notifications } from '@/db';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
const swarmMentionSchema = z.object({
mentionedHandle: z.string(),
mentionedHandle: localHandleSchema,
mention: z.object({
actorHandle: z.string(),
actorDisplayName: z.string(),
actorAvatarUrl: z.string().optional(),
actorNodeDomain: z.string(),
postId: z.string(),
postContent: z.string(),
interactionId: z.string(),
timestamp: z.string(),
actorHandle: localHandleSchema,
actorDisplayName: z.string().min(1).max(50),
actorAvatarUrl: z.string().url().optional(),
actorNodeDomain: nodeDomainSchema,
postId: z.string().uuid(),
postContent: z.string().max(10000),
interactionId: z.string().uuid(),
timestamp: z.string().datetime(),
}),
signature: z.string(),
});
@@ -11,14 +11,15 @@ import { db, posts, users, notifications } from '@/db';
import { eq, sql } from 'drizzle-orm';
import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
const swarmRepostSchema = z.object({
postId: z.string().uuid(),
repost: z.object({
actorHandle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/, 'Handle must be alphanumeric with underscores'),
actorHandle: localHandleSchema,
actorDisplayName: z.string().min(1).max(50),
actorAvatarUrl: z.string().url().optional(),
actorNodeDomain: z.string().min(1).regex(/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/, 'Invalid domain format'),
actorNodeDomain: nodeDomainSchema,
repostId: z.string().uuid(),
interactionId: z.string().uuid(),
timestamp: z.string().datetime(),
@@ -11,14 +11,15 @@ import { db, users, remoteFollowers } from '@/db';
import { eq, and, sql } from 'drizzle-orm';
import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
const swarmUnfollowSchema = z.object({
targetHandle: z.string(),
targetHandle: localHandleSchema,
unfollow: z.object({
followerHandle: z.string(),
followerNodeDomain: z.string(),
interactionId: z.string(),
timestamp: z.string(),
followerHandle: localHandleSchema,
followerNodeDomain: nodeDomainSchema,
interactionId: z.string().uuid(),
timestamp: z.string().datetime(),
}),
signature: z.string(),
});
@@ -11,14 +11,15 @@ import { db, posts, remoteLikes } from '@/db';
import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
const swarmUnlikeSchema = z.object({
postId: z.string().uuid(),
unlike: z.object({
actorHandle: z.string(),
actorNodeDomain: z.string(),
interactionId: z.string(),
timestamp: z.string(),
actorHandle: localHandleSchema,
actorNodeDomain: nodeDomainSchema,
interactionId: z.string().uuid(),
timestamp: z.string().datetime(),
}),
signature: z.string(),
});
@@ -11,14 +11,15 @@ import { db, posts } from '@/db';
import { eq, sql } from 'drizzle-orm';
import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
const swarmUnrepostSchema = z.object({
postId: z.string().uuid(),
unrepost: z.object({
actorHandle: z.string(),
actorNodeDomain: z.string(),
interactionId: z.string(),
timestamp: z.string(),
actorHandle: localHandleSchema,
actorNodeDomain: nodeDomainSchema,
interactionId: z.string().uuid(),
timestamp: z.string().datetime(),
}),
signature: z.string(),
});
+7 -1
View File
@@ -5,6 +5,7 @@ import { eq, and, sql } from 'drizzle-orm';
import { requireAuth } from '@/lib/auth';
import { requireSignedAction } from '@/lib/auth/verify-signature';
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions';
import { discoverNode } from '@/lib/swarm/discovery';
type RouteContext = { params: Promise<{ handle: string }> };
@@ -115,7 +116,12 @@ export async function POST(request: Request, context: RouteContext) {
}
// Only allow following swarm nodes
const isSwarm = await isSwarmNode(remote.domain);
let isSwarm = await isSwarmNode(remote.domain);
if (!isSwarm) {
const discovery = await discoverNode(remote.domain, nodeDomain);
isSwarm = discovery.success;
}
if (!isSwarm) {
return NextResponse.json({ error: 'Can only follow users on Synapsis swarm nodes' }, { status: 400 });
}
+13 -2
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { db, posts, users, likes } from '@/db';
import { eq, desc, and, inArray, lt } from 'drizzle-orm';
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
import { discoverNode } from '@/lib/swarm/discovery';
type RouteContext = { params: Promise<{ handle: string }> };
@@ -30,7 +31,12 @@ export async function GET(request: Request, context: RouteContext) {
}
// Only fetch from swarm nodes
const isSwarm = await isSwarmNode(remote.domain);
let isSwarm = await isSwarmNode(remote.domain);
if (!isSwarm) {
const discovery = await discoverNode(remote.domain);
isSwarm = discovery.success;
}
if (!isSwarm) {
return NextResponse.json({ posts: [], message: 'Only Synapsis swarm nodes are supported' });
}
@@ -81,7 +87,12 @@ export async function GET(request: Request, context: RouteContext) {
}
// Only fetch from swarm nodes
const isSwarm = await isSwarmNode(remote.domain);
let isSwarm = await isSwarmNode(remote.domain);
if (!isSwarm) {
const discovery = await discoverNode(remote.domain);
isSwarm = discovery.success;
}
if (!isSwarm) {
return NextResponse.json({ posts: [], message: 'Only Synapsis swarm nodes are supported' });
}
+7 -1
View File
@@ -3,6 +3,7 @@ import { eq, and } from 'drizzle-orm';
import { getSession } from '@/lib/auth';
import { db, users, follows } from '@/db';
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
import { discoverNode } from '@/lib/swarm/discovery';
type RouteContext = { params: Promise<{ handle: string }> };
@@ -40,7 +41,12 @@ export async function GET(request: Request, context: RouteContext) {
if (!user || isRemotePlaceholder) {
if (remoteHandle && remoteDomain) {
// Only fetch from swarm nodes
const isSwarm = await isSwarmNode(remoteDomain);
let isSwarm = await isSwarmNode(remoteDomain);
if (!isSwarm) {
const discovery = await discoverNode(remoteDomain);
isSwarm = discovery.success;
}
if (isSwarm) {
const profileData = await fetchSwarmUserProfile(remoteHandle, remoteDomain, 0);
if (profileData?.profile) {
+6 -1
View File
@@ -269,7 +269,12 @@ export default function ExplorePage() {
const handleLike = async (postId: string, currentLiked: boolean) => {
const method = currentLiked ? 'DELETE' : 'POST';
await fetch(`/api/posts/${postId}/like`, { method });
const res = await fetch(`/api/posts/${postId}/like`, { method });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Failed to update like');
}
};
const handleRepost = async (postId: string, currentReposted: boolean) => {
+7 -4
View File
@@ -164,10 +164,13 @@ export default function Home() {
const handleLike = async (postId: string, currentLiked: boolean) => {
if (!did || !handle) return;
if (currentLiked) {
await signedAPI.unlikePost(postId, did, handle);
} else {
await signedAPI.likePost(postId, did, handle);
const res = currentLiked
? await signedAPI.unlikePost(postId, did, handle)
: await signedAPI.likePost(postId, did, handle);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Failed to update like');
}
};
+6 -1
View File
@@ -182,7 +182,12 @@ export default function SearchPage() {
const handleLike = async (postId: string, currentLiked: boolean) => {
const method = currentLiked ? 'DELETE' : 'POST';
await fetch(`/api/posts/${postId}/like`, { method });
const res = await fetch(`/api/posts/${postId}/like`, { method });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Failed to update like');
}
};
const handleRepost = async (postId: string, currentReposted: boolean) => {
+6 -1
View File
@@ -208,7 +208,12 @@ export default function ProfilePage() {
const handleLike = async (postId: string, currentLiked: boolean) => {
const method = currentLiked ? 'DELETE' : 'POST';
await fetch(`/api/posts/${postId}/like`, { method });
const res = await fetch(`/api/posts/${postId}/like`, { method });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Failed to update like');
}
};
const handleRepost = async (postId: string, currentReposted: boolean) => {
+7 -4
View File
@@ -83,10 +83,13 @@ export default function PostDetailPage() {
const handleLike = async (postId: string, currentLiked: boolean) => {
if (!did || !userHandle) return;
if (currentLiked) {
await signedAPI.unlikePost(postId, did, userHandle);
} else {
await signedAPI.likePost(postId, did, userHandle);
const res = currentLiked
? await signedAPI.unlikePost(postId, did, userHandle)
: await signedAPI.likePost(postId, did, userHandle);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Failed to update like');
}
};