Fix remote likes, notification reads, and profile tabs

This commit is contained in:
cyph3rasi
2026-03-08 00:13:14 -08:00
parent f46ac1cafa
commit 98359886df
9 changed files with 288 additions and 25 deletions
+3 -5
View File
@@ -1,7 +1,6 @@
import { NextResponse } from 'next/server';
import { db, notifications } from '@/db';
import { requireAuth } from '@/lib/auth';
import { requireSignedAction } from '@/lib/auth/verify-signature';
import { and, desc, eq, inArray, isNull } from 'drizzle-orm';
import { z } from 'zod';
@@ -120,15 +119,14 @@ export async function GET(request: Request) {
export async function PATCH(request: Request) {
try {
const signedAction = await request.json();
const user = await requireSignedAction(signedAction);
const user = await requireAuth();
if (!db) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
// We trust the signed action 'data' for the IDs
const body = signedAction.data;
const rawBody = await request.json();
const body = rawBody?.data && typeof rawBody.data === 'object' ? rawBody.data : rawBody;
const data = markSchema.parse(body);
if (!data.all && (!data.ids || data.ids.length === 0)) {
+63 -3
View File
@@ -1,6 +1,5 @@
import { NextResponse } from 'next/server';
import { db, posts, likes, users, notifications } from '@/db';
import { requireAuth } from '@/lib/auth';
import { db, posts, likes, users, notifications, userSwarmLikes } from '@/db';
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
import { eq, and, sql } from 'drizzle-orm';
import { z } from 'zod';
@@ -41,6 +40,44 @@ function extractSwarmPostId(apId: string): string | null {
return apId.substring(lastColonIndex + 1);
}
async function fetchSwarmPostSnapshot(domain: string, originalPostId: string) {
try {
const protocol = domain.includes('localhost') ? 'http' : 'https';
const res = await fetch(`${protocol}://${domain}/api/swarm/posts/${originalPostId}`, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
return null;
}
const data = await res.json();
const post = data.post;
if (!post) {
return null;
}
return {
authorHandle: post.author?.handle || 'unknown',
authorDisplayName: post.author?.displayName || post.author?.handle || 'Unknown',
authorAvatarUrl: post.author?.avatarUrl || null,
content: post.content || '',
postCreatedAt: new Date(post.createdAt || new Date().toISOString()),
likesCount: post.likesCount || 0,
repostsCount: post.repostsCount || 0,
repliesCount: post.repliesCount || 0,
linkPreviewUrl: post.linkPreviewUrl || null,
linkPreviewTitle: post.linkPreviewTitle || null,
linkPreviewDescription: post.linkPreviewDescription || null,
linkPreviewImage: post.linkPreviewImage || null,
mediaJson: post.media ? JSON.stringify(post.media) : null,
};
} catch {
return null;
}
}
// Like a post
export async function POST(request: Request, context: RouteContext) {
try {
@@ -89,6 +126,23 @@ export async function POST(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'Failed to deliver like to remote node' }, { status: 502 });
}
const snapshot = await fetchSwarmPostSnapshot(targetDomain, originalPostId);
if (snapshot) {
await db.insert(userSwarmLikes).values({
userId: user.id,
nodeDomain: targetDomain,
originalPostId,
...snapshot,
likedAt: new Date(),
}).onConflictDoUpdate({
target: [userSwarmLikes.userId, userSwarmLikes.nodeDomain, userSwarmLikes.originalPostId],
set: {
...snapshot,
likedAt: new Date(),
},
});
}
console.log(`[Swarm] Like delivered to ${targetDomain} for post ${originalPostId}`);
return NextResponse.json({ success: true, liked: true });
}
@@ -242,7 +296,7 @@ export async function DELETE(request: Request, context: RouteContext) {
// Handle swarm posts (format: swarm:domain:uuid)
if (postId.startsWith('swarm:')) {
const targetDomain = extractSwarmDomain(postId);
const originalPostId = postId.split(':')[2];
const originalPostId = extractSwarmPostId(postId);
if (!targetDomain || !originalPostId) {
return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 });
@@ -266,6 +320,12 @@ export async function DELETE(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'Failed to deliver unlike to remote node' }, { status: 502 });
}
await db.delete(userSwarmLikes).where(and(
eq(userSwarmLikes.userId, user.id),
eq(userSwarmLikes.nodeDomain, targetDomain),
eq(userSwarmLikes.originalPostId, originalPostId)
));
console.log(`[Swarm] Unlike delivered to ${targetDomain} for post ${originalPostId}`);
return NextResponse.json({ success: true, liked: false });
}
+17
View File
@@ -9,6 +9,7 @@ import { db, posts } from '@/db';
import { fetchSwarmTimeline } from '@/lib/swarm/timeline';
import { getSession } from '@/lib/auth';
import { and, eq, inArray, sql } from 'drizzle-orm';
import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
/**
* GET /api/posts/swarm
@@ -52,10 +53,26 @@ export async function GET(request: NextRequest) {
.map(row => [row.swarmReplyToId as string, row.count])
);
const session = await getSession().catch(() => null);
const viewer = session?.user;
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const likedPostIds = viewer
? await getViewerSwarmLikedPostIds(
timeline.posts.map(post => ({
id: `swarm:${post.nodeDomain}:${post.id}`,
nodeDomain: post.nodeDomain,
originalPostId: post.id,
})),
viewer.handle,
nodeDomain
)
: new Set<string>();
return NextResponse.json({
posts: timeline.posts.map(post => ({
...post,
replyCount: post.replyCount + (localReplyCountMap.get(`swarm:${post.nodeDomain}:${post.id}`) || 0),
isLiked: likedPostIds.has(`swarm:${post.nodeDomain}:${post.id}`),
})),
sources: timeline.sources,
cached: false,
+69 -8
View File
@@ -1,9 +1,21 @@
import { NextResponse } from 'next/server';
import { db, likes, posts, users } from '@/db';
import { db, likes, posts, users, userSwarmLikes } from '@/db';
import { eq, desc, and, inArray } from 'drizzle-orm';
type RouteContext = { params: Promise<{ handle: string }> };
const parseMediaJson = (mediaJson: string | null) => {
if (!mediaJson) {
return [];
}
try {
return JSON.parse(mediaJson);
} catch {
return [];
}
};
export async function GET(request: Request, context: RouteContext) {
try {
const { handle } = await context.params;
@@ -41,11 +53,51 @@ export async function GET(request: Request, context: RouteContext) {
limit,
});
// Filter out any likes where the post was removed and format response
let likedPosts = userLikes
const localLikedPosts = userLikes
.filter(like => like.post && !like.post.isRemoved)
.map(like => like.post);
const swarmLikedRows = await db.query.userSwarmLikes.findMany({
where: eq(userSwarmLikes.userId, user.id),
orderBy: [desc(userSwarmLikes.likedAt)],
limit,
});
const swarmLikedPosts = swarmLikedRows.map((like) => ({
id: `swarm:${like.nodeDomain}:${like.originalPostId}`,
originalPostId: like.originalPostId,
content: like.content,
createdAt: like.postCreatedAt.toISOString(),
likesCount: like.likesCount,
repostsCount: like.repostsCount,
repliesCount: like.repliesCount,
author: {
id: `swarm:${like.nodeDomain}:${like.authorHandle}`,
handle: `${like.authorHandle}@${like.nodeDomain}`,
displayName: like.authorDisplayName || like.authorHandle,
avatarUrl: like.authorAvatarUrl,
},
media: parseMediaJson(like.mediaJson),
linkPreviewUrl: like.linkPreviewUrl,
linkPreviewTitle: like.linkPreviewTitle,
linkPreviewDescription: like.linkPreviewDescription,
linkPreviewImage: like.linkPreviewImage,
isSwarm: true,
nodeDomain: like.nodeDomain,
likedAt: like.likedAt.toISOString(),
isLiked: false,
}));
let likedPosts: any[] = [
...localLikedPosts.map((post) => ({
...post,
likedAt: userLikes.find((like) => like.post?.id === post.id)?.createdAt?.toISOString() || post.createdAt.toISOString(),
})),
...swarmLikedPosts,
]
.sort((a, b) => new Date(b.likedAt).getTime() - new Date(a.likedAt).getTime())
.slice(0, limit);
// Populate isLiked and isReposted for authenticated users
try {
const { getSession } = await import('@/lib/auth');
@@ -53,13 +105,17 @@ export async function GET(request: Request, context: RouteContext) {
if (session?.user && likedPosts.length > 0) {
const viewer = session.user;
const postIds = likedPosts.map(p => p!.id).filter(Boolean);
const isOwnLikesView = viewer.id === user.id;
const localPostIds = likedPosts
.filter((post: any) => !post.isSwarm)
.map((post: any) => post.id)
.filter(Boolean);
if (postIds.length > 0) {
if (localPostIds.length > 0) {
const viewerLikes = await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
inArray(likes.postId, postIds)
inArray(likes.postId, localPostIds)
),
});
const likedPostIds = new Set(viewerLikes.map(l => l.postId));
@@ -67,7 +123,7 @@ export async function GET(request: Request, context: RouteContext) {
const viewerReposts = await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, postIds),
inArray(posts.repostOfId, localPostIds),
eq(posts.isRemoved, false)
),
});
@@ -75,9 +131,14 @@ export async function GET(request: Request, context: RouteContext) {
likedPosts = likedPosts.map(p => ({
...p!,
isLiked: likedPostIds.has(p!.id),
isLiked: p!.isSwarm ? isOwnLikesView : likedPostIds.has(p!.id),
isReposted: repostedPostIds.has(p!.id),
})) as any;
} else {
likedPosts = likedPosts.map(p => ({
...p!,
isLiked: p!.isSwarm ? isOwnLikesView : p!.isLiked,
})) as any;
}
}
} catch (error) {
+51 -6
View File
@@ -1,8 +1,9 @@
import { NextResponse } from 'next/server';
import { db, posts, users, likes } from '@/db';
import { eq, desc, and, inArray, lt, sql } from 'drizzle-orm';
import { eq, desc, and, inArray, lt, sql, isNull } from 'drizzle-orm';
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
import { discoverNode } from '@/lib/swarm/discovery';
import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
type RouteContext = { params: Promise<{ handle: string }> };
@@ -15,6 +16,43 @@ const parseRemoteHandle = (handle: string) => {
return null;
};
async function populateViewerLikeState(
remotePosts: any[],
domain: string
) {
if (!remotePosts.length) {
return remotePosts;
}
try {
const { getSession } = await import('@/lib/auth');
const session = await getSession();
const viewer = session?.user;
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
if (!viewer) {
return remotePosts;
}
const likedIds = await getViewerSwarmLikedPostIds(
remotePosts.map((post) => ({
id: `swarm:${domain}:${post.originalPostId}`,
nodeDomain: domain,
originalPostId: post.originalPostId,
})),
viewer.handle,
nodeDomain
);
return remotePosts.map((post) => ({
...post,
isLiked: likedIds.has(`swarm:${domain}:${post.originalPostId}`),
}));
} catch {
return remotePosts;
}
}
export async function GET(request: Request, context: RouteContext) {
try {
const { handle } = await context.params;
@@ -54,6 +92,7 @@ export async function GET(request: Request, context: RouteContext) {
const remotePosts = profileData.posts.map((post: any) => ({
id: post.id,
originalPostId: post.id,
content: post.content,
createdAt: post.createdAt,
likesCount: post.likesCount || 0,
@@ -67,10 +106,9 @@ export async function GET(request: Request, context: RouteContext) {
linkPreviewImage: post.linkPreviewImage || null,
isSwarm: true,
nodeDomain: remote.domain,
originalPostId: post.id,
}));
return NextResponse.json({ posts: remotePosts, nextCursor: null });
return NextResponse.json({ posts: await populateViewerLikeState(remotePosts, remote.domain), nextCursor: null });
}
return NextResponse.json({ posts: [] });
@@ -132,6 +170,7 @@ export async function GET(request: Request, context: RouteContext) {
const remotePosts = profileData.posts.map((post: any) => ({
id: post.id,
originalPostId: post.id,
content: post.content,
createdAt: post.createdAt,
likesCount: post.likesCount || 0,
@@ -145,10 +184,9 @@ export async function GET(request: Request, context: RouteContext) {
linkPreviewImage: post.linkPreviewImage || null,
isSwarm: true,
nodeDomain: remote.domain,
originalPostId: post.id,
}));
return NextResponse.json({ posts: remotePosts, nextCursor: null });
return NextResponse.json({ posts: await populateViewerLikeState(remotePosts, remote.domain), nextCursor: null });
}
return NextResponse.json({ posts: [] });
@@ -159,7 +197,12 @@ export async function GET(request: Request, context: RouteContext) {
}
// Get user's posts with cursor-based pagination
let whereConditions = and(eq(posts.userId, user.id), eq(posts.isRemoved, false));
let whereConditions = and(
eq(posts.userId, user.id),
eq(posts.isRemoved, false),
isNull(posts.replyToId),
isNull(posts.swarmReplyToId)
);
// If cursor provided, get posts older than the cursor
if (cursor) {
@@ -170,6 +213,8 @@ export async function GET(request: Request, context: RouteContext) {
whereConditions = and(
eq(posts.userId, user.id),
eq(posts.isRemoved, false),
isNull(posts.replyToId),
isNull(posts.swarmReplyToId),
lt(posts.createdAt, cursorPost.createdAt)
);
}