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');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
</button>
|
||||
<button className={`post-action ${liked ? 'liked' : ''}`} onClick={handleLike}>
|
||||
<HeartIcon filled={liked} />
|
||||
<span>{(post.likesCount - (post.isLiked ? 1 : 0)) + (liked ? 1 : 0) || ''}</span>
|
||||
<span>{likesCount || ''}</span>
|
||||
</button>
|
||||
<button className="post-action" onClick={handleReport} disabled={reporting}>
|
||||
<FlagIcon />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user