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 { 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),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -267,4 +266,4 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({ error: error.message || 'Failed to send message' }, { status: 500 });
|
return NextResponse.json({ error: error.message || 'Failed to send message' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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