Fix federated interactions and reply threads
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user