Fix remote likes, notification reads, and profile tabs
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { db, notifications } from '@/db';
|
import { db, notifications } from '@/db';
|
||||||
import { requireAuth } from '@/lib/auth';
|
import { requireAuth } from '@/lib/auth';
|
||||||
import { requireSignedAction } from '@/lib/auth/verify-signature';
|
|
||||||
import { and, desc, eq, inArray, isNull } from 'drizzle-orm';
|
import { and, desc, eq, inArray, isNull } from 'drizzle-orm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -120,15 +119,14 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
export async function PATCH(request: Request) {
|
export async function PATCH(request: Request) {
|
||||||
try {
|
try {
|
||||||
const signedAction = await request.json();
|
const user = await requireAuth();
|
||||||
const user = await requireSignedAction(signedAction);
|
|
||||||
|
|
||||||
if (!db) {
|
if (!db) {
|
||||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// We trust the signed action 'data' for the IDs
|
const rawBody = await request.json();
|
||||||
const body = signedAction.data;
|
const body = rawBody?.data && typeof rawBody.data === 'object' ? rawBody.data : rawBody;
|
||||||
const data = markSchema.parse(body);
|
const data = markSchema.parse(body);
|
||||||
|
|
||||||
if (!data.all && (!data.ids || data.ids.length === 0)) {
|
if (!data.all && (!data.ids || data.ids.length === 0)) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { db, posts, likes, users, notifications } from '@/db';
|
import { db, posts, likes, users, notifications, userSwarmLikes } from '@/db';
|
||||||
import { requireAuth } from '@/lib/auth';
|
|
||||||
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
|
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
|
||||||
import { eq, and, sql } from 'drizzle-orm';
|
import { eq, and, sql } from 'drizzle-orm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -41,6 +40,44 @@ function extractSwarmPostId(apId: string): string | null {
|
|||||||
return apId.substring(lastColonIndex + 1);
|
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
|
// Like a post
|
||||||
export async function POST(request: Request, context: RouteContext) {
|
export async function POST(request: Request, context: RouteContext) {
|
||||||
try {
|
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 });
|
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}`);
|
console.log(`[Swarm] Like delivered to ${targetDomain} for post ${originalPostId}`);
|
||||||
return NextResponse.json({ success: true, liked: true });
|
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)
|
// Handle swarm posts (format: swarm:domain:uuid)
|
||||||
if (postId.startsWith('swarm:')) {
|
if (postId.startsWith('swarm:')) {
|
||||||
const targetDomain = extractSwarmDomain(postId);
|
const targetDomain = extractSwarmDomain(postId);
|
||||||
const originalPostId = postId.split(':')[2];
|
const originalPostId = extractSwarmPostId(postId);
|
||||||
|
|
||||||
if (!targetDomain || !originalPostId) {
|
if (!targetDomain || !originalPostId) {
|
||||||
return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 });
|
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 });
|
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}`);
|
console.log(`[Swarm] Unlike delivered to ${targetDomain} for post ${originalPostId}`);
|
||||||
return NextResponse.json({ success: true, liked: false });
|
return NextResponse.json({ success: true, liked: false });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { db, posts } from '@/db';
|
|||||||
import { fetchSwarmTimeline } from '@/lib/swarm/timeline';
|
import { fetchSwarmTimeline } from '@/lib/swarm/timeline';
|
||||||
import { getSession } from '@/lib/auth';
|
import { getSession } from '@/lib/auth';
|
||||||
import { and, eq, inArray, sql } from 'drizzle-orm';
|
import { and, eq, inArray, sql } from 'drizzle-orm';
|
||||||
|
import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/posts/swarm
|
* GET /api/posts/swarm
|
||||||
@@ -52,10 +53,26 @@ export async function GET(request: NextRequest) {
|
|||||||
.map(row => [row.swarmReplyToId as string, row.count])
|
.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({
|
return NextResponse.json({
|
||||||
posts: timeline.posts.map(post => ({
|
posts: timeline.posts.map(post => ({
|
||||||
...post,
|
...post,
|
||||||
replyCount: post.replyCount + (localReplyCountMap.get(`swarm:${post.nodeDomain}:${post.id}`) || 0),
|
replyCount: post.replyCount + (localReplyCountMap.get(`swarm:${post.nodeDomain}:${post.id}`) || 0),
|
||||||
|
isLiked: likedPostIds.has(`swarm:${post.nodeDomain}:${post.id}`),
|
||||||
})),
|
})),
|
||||||
sources: timeline.sources,
|
sources: timeline.sources,
|
||||||
cached: false,
|
cached: false,
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
import { NextResponse } from 'next/server';
|
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';
|
import { eq, desc, and, inArray } from 'drizzle-orm';
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ handle: string }> };
|
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) {
|
export async function GET(request: Request, context: RouteContext) {
|
||||||
try {
|
try {
|
||||||
const { handle } = await context.params;
|
const { handle } = await context.params;
|
||||||
@@ -41,11 +53,51 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter out any likes where the post was removed and format response
|
const localLikedPosts = userLikes
|
||||||
let likedPosts = userLikes
|
|
||||||
.filter(like => like.post && !like.post.isRemoved)
|
.filter(like => like.post && !like.post.isRemoved)
|
||||||
.map(like => like.post);
|
.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
|
// Populate isLiked and isReposted for authenticated users
|
||||||
try {
|
try {
|
||||||
const { getSession } = await import('@/lib/auth');
|
const { getSession } = await import('@/lib/auth');
|
||||||
@@ -53,13 +105,17 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
|
|
||||||
if (session?.user && likedPosts.length > 0) {
|
if (session?.user && likedPosts.length > 0) {
|
||||||
const viewer = session.user;
|
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({
|
const viewerLikes = await db.query.likes.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(likes.userId, viewer.id),
|
eq(likes.userId, viewer.id),
|
||||||
inArray(likes.postId, postIds)
|
inArray(likes.postId, localPostIds)
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
const likedPostIds = new Set(viewerLikes.map(l => l.postId));
|
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({
|
const viewerReposts = await db.query.posts.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(posts.userId, viewer.id),
|
eq(posts.userId, viewer.id),
|
||||||
inArray(posts.repostOfId, postIds),
|
inArray(posts.repostOfId, localPostIds),
|
||||||
eq(posts.isRemoved, false)
|
eq(posts.isRemoved, false)
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -75,9 +131,14 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
|
|
||||||
likedPosts = likedPosts.map(p => ({
|
likedPosts = likedPosts.map(p => ({
|
||||||
...p!,
|
...p!,
|
||||||
isLiked: likedPostIds.has(p!.id),
|
isLiked: p!.isSwarm ? isOwnLikesView : likedPostIds.has(p!.id),
|
||||||
isReposted: repostedPostIds.has(p!.id),
|
isReposted: repostedPostIds.has(p!.id),
|
||||||
})) as any;
|
})) as any;
|
||||||
|
} else {
|
||||||
|
likedPosts = likedPosts.map(p => ({
|
||||||
|
...p!,
|
||||||
|
isLiked: p!.isSwarm ? isOwnLikesView : p!.isLiked,
|
||||||
|
})) as any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { NextResponse } from 'next/server';
|
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, sql } from 'drizzle-orm';
|
import { eq, desc, and, inArray, lt, sql, isNull } from 'drizzle-orm';
|
||||||
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
|
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
|
||||||
import { discoverNode } from '@/lib/swarm/discovery';
|
import { discoverNode } from '@/lib/swarm/discovery';
|
||||||
|
import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ handle: string }> };
|
type RouteContext = { params: Promise<{ handle: string }> };
|
||||||
|
|
||||||
@@ -15,6 +16,43 @@ const parseRemoteHandle = (handle: string) => {
|
|||||||
return null;
|
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) {
|
export async function GET(request: Request, context: RouteContext) {
|
||||||
try {
|
try {
|
||||||
const { handle } = await context.params;
|
const { handle } = await context.params;
|
||||||
@@ -54,6 +92,7 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
|
|
||||||
const remotePosts = profileData.posts.map((post: any) => ({
|
const remotePosts = profileData.posts.map((post: any) => ({
|
||||||
id: post.id,
|
id: post.id,
|
||||||
|
originalPostId: post.id,
|
||||||
content: post.content,
|
content: post.content,
|
||||||
createdAt: post.createdAt,
|
createdAt: post.createdAt,
|
||||||
likesCount: post.likesCount || 0,
|
likesCount: post.likesCount || 0,
|
||||||
@@ -67,10 +106,9 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
linkPreviewImage: post.linkPreviewImage || null,
|
linkPreviewImage: post.linkPreviewImage || null,
|
||||||
isSwarm: true,
|
isSwarm: true,
|
||||||
nodeDomain: remote.domain,
|
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: [] });
|
return NextResponse.json({ posts: [] });
|
||||||
@@ -132,6 +170,7 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
|
|
||||||
const remotePosts = profileData.posts.map((post: any) => ({
|
const remotePosts = profileData.posts.map((post: any) => ({
|
||||||
id: post.id,
|
id: post.id,
|
||||||
|
originalPostId: post.id,
|
||||||
content: post.content,
|
content: post.content,
|
||||||
createdAt: post.createdAt,
|
createdAt: post.createdAt,
|
||||||
likesCount: post.likesCount || 0,
|
likesCount: post.likesCount || 0,
|
||||||
@@ -145,10 +184,9 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
linkPreviewImage: post.linkPreviewImage || null,
|
linkPreviewImage: post.linkPreviewImage || null,
|
||||||
isSwarm: true,
|
isSwarm: true,
|
||||||
nodeDomain: remote.domain,
|
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: [] });
|
return NextResponse.json({ posts: [] });
|
||||||
@@ -159,7 +197,12 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get user's posts with cursor-based pagination
|
// 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 provided, get posts older than the cursor
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
@@ -170,6 +213,8 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
whereConditions = and(
|
whereConditions = and(
|
||||||
eq(posts.userId, user.id),
|
eq(posts.userId, user.id),
|
||||||
eq(posts.isRemoved, false),
|
eq(posts.isRemoved, false),
|
||||||
|
isNull(posts.replyToId),
|
||||||
|
isNull(posts.swarmReplyToId),
|
||||||
lt(posts.createdAt, cursorPost.createdAt)
|
lt(posts.createdAt, cursorPost.createdAt)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ interface SwarmPost {
|
|||||||
linkPreviewTitle?: string;
|
linkPreviewTitle?: string;
|
||||||
linkPreviewDescription?: string;
|
linkPreviewDescription?: string;
|
||||||
linkPreviewImage?: string;
|
linkPreviewImage?: string;
|
||||||
|
isLiked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExplorePage() {
|
export default function ExplorePage() {
|
||||||
@@ -466,6 +467,7 @@ export default function ExplorePage() {
|
|||||||
linkPreviewTitle: post.linkPreviewTitle || null,
|
linkPreviewTitle: post.linkPreviewTitle || null,
|
||||||
linkPreviewDescription: post.linkPreviewDescription || null,
|
linkPreviewDescription: post.linkPreviewDescription || null,
|
||||||
linkPreviewImage: post.linkPreviewImage || null,
|
linkPreviewImage: post.linkPreviewImage || null,
|
||||||
|
isLiked: post.isLiked || false,
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<PostCard
|
<PostCard
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { BellIcon } from '@/components/Icons';
|
import { BellIcon } from '@/components/Icons';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
|
|
||||||
interface NotificationActor {
|
interface NotificationActor {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,13 +27,17 @@ interface Notification {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function NotificationsPage() {
|
export default function NotificationsPage() {
|
||||||
|
const { loading: authLoading } = useAuth();
|
||||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (authLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
fetchNotifications();
|
fetchNotifications();
|
||||||
}, []);
|
}, [authLoading]);
|
||||||
|
|
||||||
const fetchNotifications = async () => {
|
const fetchNotifications = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -56,11 +60,14 @@ export default function NotificationsPage() {
|
|||||||
|
|
||||||
const markAllRead = async () => {
|
const markAllRead = async () => {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/notifications', {
|
const res = await fetch('/api/notifications', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ all: true }),
|
body: JSON.stringify({ all: true }),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Failed to mark notifications as read');
|
||||||
|
}
|
||||||
setNotifications(prev => prev.map(n => ({ ...n, readAt: new Date().toISOString() })));
|
setNotifications(prev => prev.map(n => ({ ...n, readAt: new Date().toISOString() })));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to mark notifications as read:', err);
|
console.error('Failed to mark notifications as read:', err);
|
||||||
|
|||||||
@@ -353,6 +353,35 @@ export const remoteLikes = pgTable('remote_likes', {
|
|||||||
uniqueIndex('remote_likes_unique').on(table.postId, table.actorHandle, table.actorNodeDomain),
|
uniqueIndex('remote_likes_unique').on(table.postId, table.actorHandle, table.actorNodeDomain),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// USER SWARM LIKES (local users liking remote swarm posts)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const userSwarmLikes = pgTable('user_swarm_likes', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
nodeDomain: text('node_domain').notNull(),
|
||||||
|
originalPostId: text('original_post_id').notNull(),
|
||||||
|
authorHandle: text('author_handle').notNull(),
|
||||||
|
authorDisplayName: text('author_display_name'),
|
||||||
|
authorAvatarUrl: text('author_avatar_url'),
|
||||||
|
content: text('content').notNull(),
|
||||||
|
postCreatedAt: timestamp('post_created_at').notNull(),
|
||||||
|
likesCount: integer('likes_count').default(0).notNull(),
|
||||||
|
repostsCount: integer('reposts_count').default(0).notNull(),
|
||||||
|
repliesCount: integer('replies_count').default(0).notNull(),
|
||||||
|
linkPreviewUrl: text('link_preview_url'),
|
||||||
|
linkPreviewTitle: text('link_preview_title'),
|
||||||
|
linkPreviewDescription: text('link_preview_description'),
|
||||||
|
linkPreviewImage: text('link_preview_image'),
|
||||||
|
mediaJson: text('media_json'),
|
||||||
|
likedAt: timestamp('liked_at').defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index('user_swarm_likes_user_idx').on(table.userId, table.likedAt),
|
||||||
|
index('user_swarm_likes_post_idx').on(table.nodeDomain, table.originalPostId),
|
||||||
|
uniqueIndex('user_swarm_likes_unique').on(table.userId, table.nodeDomain, table.originalPostId),
|
||||||
|
]);
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// REMOTE REPOSTS (reposts from federated users on local posts)
|
// REMOTE REPOSTS (reposts from federated users on local posts)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export interface SwarmLikeTarget {
|
||||||
|
id: string;
|
||||||
|
nodeDomain: string;
|
||||||
|
originalPostId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getViewerSwarmLikedPostIds(
|
||||||
|
targets: SwarmLikeTarget[],
|
||||||
|
viewerHandle: string,
|
||||||
|
viewerDomain: string
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
const likedIds = new Set<string>();
|
||||||
|
|
||||||
|
if (!targets.length || !viewerHandle || !viewerDomain) {
|
||||||
|
return likedIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
targets.map(async (target) => {
|
||||||
|
try {
|
||||||
|
const protocol = target.nodeDomain.includes('localhost') ? 'http' : 'https';
|
||||||
|
const res = await fetch(
|
||||||
|
`${protocol}://${target.nodeDomain}/api/swarm/posts/${target.originalPostId}/likes?checkHandle=${encodeURIComponent(viewerHandle)}&checkDomain=${encodeURIComponent(viewerDomain)}`,
|
||||||
|
{
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(3000),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.isLiked) {
|
||||||
|
likedIds.add(target.id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return likedIds;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user