From 5d524a03546ea60eb4e311f868472d284d73f2e0 Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Sat, 7 Mar 2026 21:27:22 -0800 Subject: [PATCH] Fix federated interactions and reply threads --- src/app/api/chat/send/route.ts | 7 +-- src/app/api/posts/[id]/route.ts | 58 ++++++++++++++++++- src/app/api/posts/route.ts | 21 ++++++- src/app/api/search/route.ts | 8 ++- .../api/swarm/interactions/follow/route.ts | 7 ++- src/app/api/swarm/interactions/like/route.ts | 5 +- .../api/swarm/interactions/mention/route.ts | 19 +++--- .../api/swarm/interactions/repost/route.ts | 5 +- .../api/swarm/interactions/unfollow/route.ts | 11 ++-- .../api/swarm/interactions/unlike/route.ts | 9 +-- .../api/swarm/interactions/unrepost/route.ts | 9 +-- src/app/api/users/[handle]/follow/route.ts | 8 ++- src/app/api/users/[handle]/posts/route.ts | 15 ++++- src/app/api/users/[handle]/route.ts | 8 ++- src/app/explore/page.tsx | 7 ++- src/app/page.tsx | 11 ++-- src/app/search/page.tsx | 7 ++- src/app/u/[handle]/page.tsx | 7 ++- src/app/u/[handle]/posts/[id]/page.tsx | 11 ++-- src/components/PostCard.tsx | 35 ++++++++--- src/lib/utils/federation.ts | 32 ++++++++++ 21 files changed, 241 insertions(+), 59 deletions(-) create mode 100644 src/lib/utils/federation.ts diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index be5f907..1b2bbb4 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -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 }); } -} \ No newline at end of file +} diff --git a/src/app/api/posts/[id]/route.ts b/src/app/api/posts/[id]/route.ts index c7541b4..5557487 100644 --- a/src/app/api/posts/[id]/route.ts +++ b/src/app/api/posts/[id]/route.ts @@ -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(); + let repostedReplyIds = new Set(); + + 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'); diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 01fefbb..bc70554 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -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`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: { diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index c3cd440..bde85f8 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -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); diff --git a/src/app/api/swarm/interactions/follow/route.ts b/src/app/api/swarm/interactions/follow/route.ts index 1afe8a1..3dd1cbe 100644 --- a/src/app/api/swarm/interactions/follow/route.ts +++ b/src/app/api/swarm/interactions/follow/route.ts @@ -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(), }), diff --git a/src/app/api/swarm/interactions/like/route.ts b/src/app/api/swarm/interactions/like/route.ts index 85268c4..fb18152 100644 --- a/src/app/api/swarm/interactions/like/route.ts +++ b/src/app/api/swarm/interactions/like/route.ts @@ -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(), }), diff --git a/src/app/api/swarm/interactions/mention/route.ts b/src/app/api/swarm/interactions/mention/route.ts index 914f7da..a718f04 100644 --- a/src/app/api/swarm/interactions/mention/route.ts +++ b/src/app/api/swarm/interactions/mention/route.ts @@ -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(), }); diff --git a/src/app/api/swarm/interactions/repost/route.ts b/src/app/api/swarm/interactions/repost/route.ts index 078aeb2..8729cc0 100644 --- a/src/app/api/swarm/interactions/repost/route.ts +++ b/src/app/api/swarm/interactions/repost/route.ts @@ -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(), diff --git a/src/app/api/swarm/interactions/unfollow/route.ts b/src/app/api/swarm/interactions/unfollow/route.ts index f539e96..f439517 100644 --- a/src/app/api/swarm/interactions/unfollow/route.ts +++ b/src/app/api/swarm/interactions/unfollow/route.ts @@ -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(), }); diff --git a/src/app/api/swarm/interactions/unlike/route.ts b/src/app/api/swarm/interactions/unlike/route.ts index 7a3973a..36032dc 100644 --- a/src/app/api/swarm/interactions/unlike/route.ts +++ b/src/app/api/swarm/interactions/unlike/route.ts @@ -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(), }); diff --git a/src/app/api/swarm/interactions/unrepost/route.ts b/src/app/api/swarm/interactions/unrepost/route.ts index 77d94aa..2e61b96 100644 --- a/src/app/api/swarm/interactions/unrepost/route.ts +++ b/src/app/api/swarm/interactions/unrepost/route.ts @@ -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(), }); diff --git a/src/app/api/users/[handle]/follow/route.ts b/src/app/api/users/[handle]/follow/route.ts index c72286d..a7d15d0 100644 --- a/src/app/api/users/[handle]/follow/route.ts +++ b/src/app/api/users/[handle]/follow/route.ts @@ -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 }); } diff --git a/src/app/api/users/[handle]/posts/route.ts b/src/app/api/users/[handle]/posts/route.ts index f0c5f7f..5d52a9c 100644 --- a/src/app/api/users/[handle]/posts/route.ts +++ b/src/app/api/users/[handle]/posts/route.ts @@ -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' }); } diff --git a/src/app/api/users/[handle]/route.ts b/src/app/api/users/[handle]/route.ts index 4ec5ef8..5221ce0 100644 --- a/src/app/api/users/[handle]/route.ts +++ b/src/app/api/users/[handle]/route.ts @@ -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) { diff --git a/src/app/explore/page.tsx b/src/app/explore/page.tsx index 2ca9bfc..f8dd545 100644 --- a/src/app/explore/page.tsx +++ b/src/app/explore/page.tsx @@ -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) => { diff --git a/src/app/page.tsx b/src/app/page.tsx index b710054..2ed2276 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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'); } }; diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 3c15edb..e6d0deb 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -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) => { diff --git a/src/app/u/[handle]/page.tsx b/src/app/u/[handle]/page.tsx index c8f24d4..cfc48af 100644 --- a/src/app/u/[handle]/page.tsx +++ b/src/app/u/[handle]/page.tsx @@ -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) => { diff --git a/src/app/u/[handle]/posts/[id]/page.tsx b/src/app/u/[handle]/posts/[id]/page.tsx index cdeacc5..e71a1ad 100644 --- a/src/app/u/[handle]/posts/[id]/page.tsx +++ b/src/app/u/[handle]/posts/[id]/page.tsx @@ -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'); } }; diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index a894067..0d68697 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -49,6 +49,8 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, const { showToast } = useToast(); const router = useRouter(); const [liked, setLiked] = useState(post.isLiked || false); + const [likesCount, setLikesCount] = useState(post.likesCount || 0); + const [likePending, setLikePending] = useState(false); const [reposted, setReposted] = useState(post.isReposted || false); const [repostsCount, setRepostsCount] = useState(post.repostsCount || 0); const [repostPending, setRepostPending] = useState(false); @@ -57,14 +59,13 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, const [showMenu, setShowMenu] = useState(false); const domain = useDomain(); const authorHandle = useFormattedHandle(post.author.handle, post.nodeDomain); - const replyToHandle = post.replyTo?.author?.handle ? useFormattedHandle(post.replyTo.author.handle) : ''; - // Sync state if post changes (e.g. after a re-render from parent) useEffect(() => { setLiked(post.isLiked || false); + setLikesCount(post.likesCount || 0); setReposted(post.isReposted || false); setRepostsCount(post.repostsCount || 0); - }, [post.isLiked, post.isReposted, post.repostsCount, post.id]); + }, [post.isLiked, post.likesCount, post.isReposted, post.repostsCount, post.id]); const formatTime = (dateStr: string | Date) => { const date = new Date(dateStr); @@ -93,18 +94,37 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, return date.toLocaleDateString(); }; - const handleLike = (e: React.MouseEvent) => { + const handleLike = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); + if (likePending) { + return; + } + if (!isIdentityUnlocked) { showToast('Please log in to like posts', 'error'); return; } const currentLiked = liked; - setLiked(!currentLiked); - onLike?.(post.id, currentLiked); // Pass current state before toggle + const currentLikesCount = likesCount; + const nextLiked = !currentLiked; + const nextLikesCount = Math.max(0, currentLikesCount + (currentLiked ? -1 : 1)); + + setLiked(nextLiked); + setLikesCount(nextLikesCount); + setLikePending(true); + + try { + await onLike?.(post.id, currentLiked); + } catch (error) { + setLiked(currentLiked); + setLikesCount(currentLikesCount); + showToast(error instanceof Error ? error.message : 'Failed to update like', 'error'); + } finally { + setLikePending(false); + } }; const handleRepost = async (e: React.MouseEvent) => { @@ -410,6 +430,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, ? JSON.parse(post.swarmReplyToAuthor) : post.swarmReplyToAuthor)?.nodeDomain, } as Post : null); + const replyToHandle = effectiveReplyTo?.author?.handle ? useFormattedHandle(effectiveReplyTo.author.handle, effectiveReplyTo.nodeDomain) : ''; // If this is a thread parent being rendered, just render the article if (isThreadParent) { @@ -685,7 +706,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,