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
+2 -3
View File
@@ -5,12 +5,11 @@ import { requireSignedAction } from '@/lib/auth/verify-signature';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { createSignedPayload } from '@/lib/swarm/signature'; import { createSignedPayload } from '@/lib/swarm/signature';
import { federatedHandleSchema } from '@/lib/utils/federation';
const handleRegex = /^[a-zA-Z0-9_]{3,20}$/;
const chatSendSchema = z.object({ const chatSendSchema = z.object({
recipientDid: z.string().min(1).regex(/^did:/, 'Must be a valid DID (did:key:... or did:synapsis:...)'), 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), content: z.string().min(1).max(5000),
}); });
+56 -2
View File
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db, posts, users, media, remotePosts } from '@/db'; 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'; import { z } from 'zod';
// Schema for local post ID (UUID) // Schema for local post ID (UUID)
@@ -77,7 +77,7 @@ export async function GET(
linkPreviewImage: data.post.linkPreviewImage, linkPreviewImage: data.post.linkPreviewImage,
}; };
// Transform replies // Transform replies from the origin node
replyPosts = (data.replies || []).map((r: any) => ({ replyPosts = (data.replies || []).map((r: any) => ({
id: `swarm:${originDomain}:${r.id}`, id: `swarm:${originDomain}:${r.id}`,
originalPostId: 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 // Check if current user has liked this post
try { try {
const { requireAuth } = await import('@/lib/auth'); 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 // 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 => ({ 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 originalPostId: sp.id, // Keep the original ID for replies
@@ -541,7 +560,7 @@ export async function GET(request: Request) {
createdAt: new Date(sp.createdAt), createdAt: new Date(sp.createdAt),
likesCount: sp.likeCount, likesCount: sp.likeCount,
repostsCount: sp.repostCount, repostsCount: sp.repostCount,
repliesCount: sp.replyCount, repliesCount: sp.replyCount + (localReplyCountMap.get(`swarm:${sp.nodeDomain}:${sp.id}`) || 0),
isSwarm: true, isSwarm: true,
nodeDomain: sp.nodeDomain, nodeDomain: sp.nodeDomain,
author: { author: {
+7 -1
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { db, users, posts, likes } from '@/db'; import { db, users, posts, likes } from '@/db';
import { ilike, or, desc, and, notInArray, eq, inArray } from 'drizzle-orm'; import { ilike, or, desc, and, notInArray, eq, inArray } from 'drizzle-orm';
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions'; import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
import { discoverNode } from '@/lib/swarm/discovery';
type SearchUser = { type SearchUser = {
id: string; id: string;
@@ -127,7 +128,12 @@ export async function GET(request: Request) {
const parsedRemote = parseRemoteHandleQuery(query); const parsedRemote = parseRemoteHandleQuery(query);
if (parsedRemote) { if (parsedRemote) {
// Only lookup on swarm nodes // 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) { if (isSwarm) {
try { try {
const profileData = await fetchSwarmUserProfile(parsedRemote.handle, parsedRemote.domain, 0); 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 { eq, and, sql } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature'; import { verifyUserInteraction } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
const swarmFollowSchema = z.object({ 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({ 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), followerDisplayName: z.string().min(1).max(50),
followerAvatarUrl: z.string().url().optional(), followerAvatarUrl: z.string().url().optional(),
followerBio: z.string().max(500).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(), interactionId: z.string().uuid(),
timestamp: z.string().datetime(), 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 { eq, and } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature'; import { verifyUserInteraction } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
const swarmLikeSchema = z.object({ const swarmLikeSchema = z.object({
postId: z.string().uuid(), postId: z.string().uuid(),
like: z.object({ 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), actorDisplayName: z.string().min(1).max(50),
actorAvatarUrl: z.string().url().optional(), 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(), interactionId: z.string().uuid(),
timestamp: z.string().datetime(), timestamp: z.string().datetime(),
}), }),
@@ -11,18 +11,19 @@ import { db, users, notifications } from '@/db';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature'; import { verifyUserInteraction } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
const swarmMentionSchema = z.object({ const swarmMentionSchema = z.object({
mentionedHandle: z.string(), mentionedHandle: localHandleSchema,
mention: z.object({ mention: z.object({
actorHandle: z.string(), actorHandle: localHandleSchema,
actorDisplayName: z.string(), actorDisplayName: z.string().min(1).max(50),
actorAvatarUrl: z.string().optional(), actorAvatarUrl: z.string().url().optional(),
actorNodeDomain: z.string(), actorNodeDomain: nodeDomainSchema,
postId: z.string(), postId: z.string().uuid(),
postContent: z.string(), postContent: z.string().max(10000),
interactionId: z.string(), interactionId: z.string().uuid(),
timestamp: z.string(), timestamp: z.string().datetime(),
}), }),
signature: z.string(), signature: z.string(),
}); });
@@ -11,14 +11,15 @@ import { db, posts, users, notifications } from '@/db';
import { eq, sql } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature'; import { verifyUserInteraction } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
const swarmRepostSchema = z.object({ const swarmRepostSchema = z.object({
postId: z.string().uuid(), postId: z.string().uuid(),
repost: z.object({ 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), actorDisplayName: z.string().min(1).max(50),
actorAvatarUrl: z.string().url().optional(), 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(), repostId: z.string().uuid(),
interactionId: z.string().uuid(), interactionId: z.string().uuid(),
timestamp: z.string().datetime(), timestamp: z.string().datetime(),
@@ -11,14 +11,15 @@ import { db, users, remoteFollowers } from '@/db';
import { eq, and, sql } from 'drizzle-orm'; import { eq, and, sql } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature'; import { verifyUserInteraction } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
const swarmUnfollowSchema = z.object({ const swarmUnfollowSchema = z.object({
targetHandle: z.string(), targetHandle: localHandleSchema,
unfollow: z.object({ unfollow: z.object({
followerHandle: z.string(), followerHandle: localHandleSchema,
followerNodeDomain: z.string(), followerNodeDomain: nodeDomainSchema,
interactionId: z.string(), interactionId: z.string().uuid(),
timestamp: z.string(), timestamp: z.string().datetime(),
}), }),
signature: z.string(), signature: z.string(),
}); });
@@ -11,14 +11,15 @@ import { db, posts, remoteLikes } from '@/db';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature'; import { verifyUserInteraction } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
const swarmUnlikeSchema = z.object({ const swarmUnlikeSchema = z.object({
postId: z.string().uuid(), postId: z.string().uuid(),
unlike: z.object({ unlike: z.object({
actorHandle: z.string(), actorHandle: localHandleSchema,
actorNodeDomain: z.string(), actorNodeDomain: nodeDomainSchema,
interactionId: z.string(), interactionId: z.string().uuid(),
timestamp: z.string(), timestamp: z.string().datetime(),
}), }),
signature: z.string(), signature: z.string(),
}); });
@@ -11,14 +11,15 @@ import { db, posts } from '@/db';
import { eq, sql } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature'; import { verifyUserInteraction } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
const swarmUnrepostSchema = z.object({ const swarmUnrepostSchema = z.object({
postId: z.string().uuid(), postId: z.string().uuid(),
unrepost: z.object({ unrepost: z.object({
actorHandle: z.string(), actorHandle: localHandleSchema,
actorNodeDomain: z.string(), actorNodeDomain: nodeDomainSchema,
interactionId: z.string(), interactionId: z.string().uuid(),
timestamp: z.string(), timestamp: z.string().datetime(),
}), }),
signature: z.string(), signature: z.string(),
}); });
+7 -1
View File
@@ -5,6 +5,7 @@ import { eq, and, sql } from 'drizzle-orm';
import { requireAuth } from '@/lib/auth'; import { requireAuth } from '@/lib/auth';
import { requireSignedAction } from '@/lib/auth/verify-signature'; import { requireSignedAction } from '@/lib/auth/verify-signature';
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions'; import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions';
import { discoverNode } from '@/lib/swarm/discovery';
type RouteContext = { params: Promise<{ handle: string }> }; type RouteContext = { params: Promise<{ handle: string }> };
@@ -115,7 +116,12 @@ export async function POST(request: Request, context: RouteContext) {
} }
// Only allow following swarm nodes // 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) { if (!isSwarm) {
return NextResponse.json({ error: 'Can only follow users on Synapsis swarm nodes' }, { status: 400 }); 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 { db, posts, users, likes } from '@/db';
import { eq, desc, and, inArray, lt } from 'drizzle-orm'; import { eq, desc, and, inArray, lt } from 'drizzle-orm';
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions'; import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
import { discoverNode } from '@/lib/swarm/discovery';
type RouteContext = { params: Promise<{ handle: string }> }; type RouteContext = { params: Promise<{ handle: string }> };
@@ -30,7 +31,12 @@ export async function GET(request: Request, context: RouteContext) {
} }
// Only fetch from swarm nodes // 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) { if (!isSwarm) {
return NextResponse.json({ posts: [], message: 'Only Synapsis swarm nodes are supported' }); 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 // 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) { if (!isSwarm) {
return NextResponse.json({ posts: [], message: 'Only Synapsis swarm nodes are supported' }); 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 { getSession } from '@/lib/auth';
import { db, users, follows } from '@/db'; import { db, users, follows } from '@/db';
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions'; import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
import { discoverNode } from '@/lib/swarm/discovery';
type RouteContext = { params: Promise<{ handle: string }> }; type RouteContext = { params: Promise<{ handle: string }> };
@@ -40,7 +41,12 @@ export async function GET(request: Request, context: RouteContext) {
if (!user || isRemotePlaceholder) { if (!user || isRemotePlaceholder) {
if (remoteHandle && remoteDomain) { if (remoteHandle && remoteDomain) {
// Only fetch from swarm nodes // 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) { if (isSwarm) {
const profileData = await fetchSwarmUserProfile(remoteHandle, remoteDomain, 0); const profileData = await fetchSwarmUserProfile(remoteHandle, remoteDomain, 0);
if (profileData?.profile) { if (profileData?.profile) {
+6 -1
View File
@@ -269,7 +269,12 @@ export default function ExplorePage() {
const handleLike = async (postId: string, currentLiked: boolean) => { const handleLike = async (postId: string, currentLiked: boolean) => {
const method = currentLiked ? 'DELETE' : 'POST'; 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) => { 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) => { const handleLike = async (postId: string, currentLiked: boolean) => {
if (!did || !handle) return; if (!did || !handle) return;
if (currentLiked) { const res = currentLiked
await signedAPI.unlikePost(postId, did, handle); ? await signedAPI.unlikePost(postId, did, handle)
} else { : await signedAPI.likePost(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 handleLike = async (postId: string, currentLiked: boolean) => {
const method = currentLiked ? 'DELETE' : 'POST'; 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) => { 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 handleLike = async (postId: string, currentLiked: boolean) => {
const method = currentLiked ? 'DELETE' : 'POST'; 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) => { 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) => { const handleLike = async (postId: string, currentLiked: boolean) => {
if (!did || !userHandle) return; if (!did || !userHandle) return;
if (currentLiked) { const res = currentLiked
await signedAPI.unlikePost(postId, did, userHandle); ? await signedAPI.unlikePost(postId, did, userHandle)
} else { : await signedAPI.likePost(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');
} }
}; };
+28 -7
View File
@@ -49,6 +49,8 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
const { showToast } = useToast(); const { showToast } = useToast();
const router = useRouter(); const router = useRouter();
const [liked, setLiked] = useState(post.isLiked || false); 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 [reposted, setReposted] = useState(post.isReposted || false);
const [repostsCount, setRepostsCount] = useState(post.repostsCount || 0); const [repostsCount, setRepostsCount] = useState(post.repostsCount || 0);
const [repostPending, setRepostPending] = useState(false); const [repostPending, setRepostPending] = useState(false);
@@ -57,14 +59,13 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const domain = useDomain(); const domain = useDomain();
const authorHandle = useFormattedHandle(post.author.handle, post.nodeDomain); 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) // Sync state if post changes (e.g. after a re-render from parent)
useEffect(() => { useEffect(() => {
setLiked(post.isLiked || false); setLiked(post.isLiked || false);
setLikesCount(post.likesCount || 0);
setReposted(post.isReposted || false); setReposted(post.isReposted || false);
setRepostsCount(post.repostsCount || 0); 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 formatTime = (dateStr: string | Date) => {
const date = new Date(dateStr); const date = new Date(dateStr);
@@ -93,18 +94,37 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
return date.toLocaleDateString(); return date.toLocaleDateString();
}; };
const handleLike = (e: React.MouseEvent) => { const handleLike = async (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (likePending) {
return;
}
if (!isIdentityUnlocked) { if (!isIdentityUnlocked) {
showToast('Please log in to like posts', 'error'); showToast('Please log in to like posts', 'error');
return; return;
} }
const currentLiked = liked; const currentLiked = liked;
setLiked(!currentLiked); const currentLikesCount = likesCount;
onLike?.(post.id, currentLiked); // Pass current state before toggle 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) => { const handleRepost = async (e: React.MouseEvent) => {
@@ -410,6 +430,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
? JSON.parse(post.swarmReplyToAuthor) ? JSON.parse(post.swarmReplyToAuthor)
: post.swarmReplyToAuthor)?.nodeDomain, : post.swarmReplyToAuthor)?.nodeDomain,
} as Post : null); } 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 this is a thread parent being rendered, just render the article
if (isThreadParent) { if (isThreadParent) {
@@ -685,7 +706,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
</button> </button>
<button className={`post-action ${liked ? 'liked' : ''}`} onClick={handleLike}> <button className={`post-action ${liked ? 'liked' : ''}`} onClick={handleLike}>
<HeartIcon filled={liked} /> <HeartIcon filled={liked} />
<span>{(post.likesCount - (post.isLiked ? 1 : 0)) + (liked ? 1 : 0) || ''}</span> <span>{likesCount || ''}</span>
</button> </button>
<button className="post-action" onClick={handleReport} disabled={reporting}> <button className="post-action" onClick={handleReport} disabled={reporting}>
<FlagIcon /> <FlagIcon />
+32
View File
@@ -0,0 +1,32 @@
import { z } from 'zod';
const localHandlePattern = /^[a-zA-Z0-9_]{3,20}$/;
const hostnameLabel = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?';
const nodeDomainPattern = `(?:localhost|127\\.0\\.0\\.1|${hostnameLabel}(?:\\.${hostnameLabel})+)(?::\\d{1,5})?`;
const federatedHandlePattern = new RegExp(`^[a-zA-Z0-9_]{3,20}(?:@${nodeDomainPattern})?$`);
const nodeDomainRegex = new RegExp(`^${nodeDomainPattern}$`);
export const localHandleSchema = z
.string()
.min(3)
.max(20)
.regex(localHandlePattern, 'Handle must be 3-20 characters, alphanumeric and underscores only');
export const federatedHandleSchema = z
.string()
.min(3)
.max(255)
.regex(
federatedHandlePattern,
'Handle must be a local handle or a federated handle like user@example.com'
);
export const nodeDomainSchema = z
.string()
.min(1)
.max(255)
.regex(nodeDomainRegex, 'Invalid node domain format');
export function isValidNodeDomain(value: string): boolean {
return nodeDomainRegex.test(value);
}