Add docker publish flow, node blocklist & API updates
Replace docker/metadata GH Action with a docker-publish.sh driven workflow (supports auto-versioning, extra tags, multi-arch builds and optional builder). Update docs and READMEs to use the updater and new publish flow. Add server-side swarm/node blocklist support and admin nodes API. Improve auth endpoints (me, logout, switch, session accounts) and support account switching. Extend bots API to support custom LLM provider and optional endpoint. Enforce node blocklist on chat send/receive, refactor link preview handling into dedicated preview modules, and enhance notifications and posts like/repost handlers to accept both signed actions and regular auth flows. Misc: remove admin update UI details, add helper libs (media previews, node-blocklist, reposts, notifications, etc.), and minor housekeeping (pyc, workflow tweaks).
This commit is contained in:
@@ -6,6 +6,7 @@ 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';
|
||||
import { buildNotificationTarget } from '@/lib/notifications';
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
@@ -220,6 +221,7 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
actorDisplayName: currentUser.displayName,
|
||||
actorAvatarUrl: currentUser.avatarUrl,
|
||||
actorNodeDomain: null,
|
||||
...(targetUser.isBot ? buildNotificationTarget(targetUser) : {}),
|
||||
type: 'follow',
|
||||
});
|
||||
|
||||
@@ -232,6 +234,7 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
actorDisplayName: currentUser.displayName,
|
||||
actorAvatarUrl: currentUser.avatarUrl,
|
||||
actorNodeDomain: null,
|
||||
...buildNotificationTarget(targetUser),
|
||||
type: 'follow',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, likes, posts, users, userSwarmLikes } from '@/db';
|
||||
import { eq, desc, and, inArray } from 'drizzle-orm';
|
||||
import { discoverNode } from '@/lib/swarm/discovery';
|
||||
import { isSwarmNode } from '@/lib/swarm/interactions';
|
||||
import { getRemoteBaseUrl, mapRemoteProfilePost, parseRemoteHandle } from '@/lib/swarm/remote-profile-posts';
|
||||
import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts';
|
||||
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
const embeddedPostRelations = {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const likedPostRelations = {
|
||||
...embeddedPostRelations,
|
||||
repostOf: {
|
||||
with: embeddedPostRelations,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const parseMediaJson = (mediaJson: string | null) => {
|
||||
if (!mediaJson) {
|
||||
return [];
|
||||
@@ -22,11 +47,88 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||
const remote = parseRemoteHandle(handle);
|
||||
|
||||
const fetchRemoteLikesRoute = async () => {
|
||||
if (!remote) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
const baseUrl = getRemoteBaseUrl(remote.domain);
|
||||
const res = await fetch(`${baseUrl}/api/users/${remote.handle}/likes?limit=${limit}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const { getSession } = await import('@/lib/auth');
|
||||
const session = await getSession();
|
||||
const viewer = session?.user;
|
||||
const mappedPosts = (data.posts || []).map((post: any) => mapRemoteProfilePost(post, remote.domain));
|
||||
const repostedIds = viewer
|
||||
? await getViewerSwarmRepostedPostIds(
|
||||
mappedPosts.map((post: any) => ({
|
||||
id: post.id,
|
||||
nodeDomain: remote.domain,
|
||||
originalPostId: post.originalPostId || post.id.split(':').pop(),
|
||||
})),
|
||||
viewer.id
|
||||
)
|
||||
: new Set<string>();
|
||||
return NextResponse.json({
|
||||
posts: mappedPosts.map((post: any) => ({
|
||||
...post,
|
||||
isReposted: repostedIds.has(post.id),
|
||||
})),
|
||||
nextCursor: null,
|
||||
});
|
||||
};
|
||||
|
||||
if (!db) {
|
||||
if (!remote) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
let swarm = await isSwarmNode(remote.domain);
|
||||
if (!swarm) {
|
||||
const discovery = await discoverNode(remote.domain);
|
||||
swarm = discovery.success;
|
||||
}
|
||||
|
||||
if (!swarm) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
return await fetchRemoteLikesRoute();
|
||||
}
|
||||
|
||||
// Find the user
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
const isRemotePlaceholder = user && cleanHandle.includes('@');
|
||||
|
||||
if (!user || isRemotePlaceholder) {
|
||||
if (!remote) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
let swarm = await isSwarmNode(remote.domain);
|
||||
if (!swarm) {
|
||||
const discovery = await discoverNode(remote.domain);
|
||||
swarm = discovery.success;
|
||||
}
|
||||
|
||||
if (!swarm) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
return await fetchRemoteLikesRoute();
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
@@ -42,11 +144,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
where: eq(likes.userId, user.id),
|
||||
with: {
|
||||
post: {
|
||||
with: {
|
||||
author: true,
|
||||
media: true,
|
||||
bot: true,
|
||||
},
|
||||
with: likedPostRelations,
|
||||
},
|
||||
},
|
||||
orderBy: [desc(likes.createdAt)],
|
||||
@@ -82,6 +180,9 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
linkPreviewTitle: like.linkPreviewTitle,
|
||||
linkPreviewDescription: like.linkPreviewDescription,
|
||||
linkPreviewImage: like.linkPreviewImage,
|
||||
linkPreviewType: like.linkPreviewType,
|
||||
linkPreviewVideoUrl: like.linkPreviewVideoUrl,
|
||||
linkPreviewMedia: parseLinkPreviewMediaJson(like.linkPreviewMediaJson) || null,
|
||||
isSwarm: true,
|
||||
nodeDomain: like.nodeDomain,
|
||||
likedAt: like.likedAt.toISOString(),
|
||||
@@ -110,6 +211,17 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
.filter((post: any) => !post.isSwarm)
|
||||
.map((post: any) => post.id)
|
||||
.filter(Boolean);
|
||||
const swarmTargets = likedPosts
|
||||
.filter((post: any) => post.isSwarm)
|
||||
.map((post: any) => ({
|
||||
id: post.id,
|
||||
nodeDomain: post.nodeDomain,
|
||||
originalPostId: post.originalPostId,
|
||||
}))
|
||||
.filter((post: any) => post.nodeDomain && post.originalPostId);
|
||||
const swarmRepostedIds = swarmTargets.length > 0
|
||||
? await getViewerSwarmRepostedPostIds(swarmTargets as any, viewer.id)
|
||||
: new Set<string>();
|
||||
|
||||
if (localPostIds.length > 0) {
|
||||
const viewerLikes = await db.query.likes.findMany({
|
||||
@@ -132,12 +244,13 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
likedPosts = likedPosts.map(p => ({
|
||||
...p!,
|
||||
isLiked: p!.isSwarm ? isOwnLikesView : likedPostIds.has(p!.id),
|
||||
isReposted: repostedPostIds.has(p!.id),
|
||||
isReposted: p!.isSwarm ? swarmRepostedIds.has(p!.id) : repostedPostIds.has(p!.id),
|
||||
})) as any;
|
||||
} else {
|
||||
likedPosts = likedPosts.map(p => ({
|
||||
...p!,
|
||||
isLiked: p!.isSwarm ? isOwnLikesView : p!.isLiked,
|
||||
isReposted: p!.isSwarm ? swarmRepostedIds.has(p!.id) : p!.isReposted,
|
||||
})) as any;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,169 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, users, likes } from '@/db';
|
||||
import { db, posts, users, likes, userSwarmReposts } from '@/db';
|
||||
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';
|
||||
import { getRemoteBaseUrl, mapRemoteProfilePost, parseRemoteHandle } from '@/lib/swarm/remote-profile-posts';
|
||||
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
|
||||
|
||||
const embeddedPostRelations = {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const userPostRelations = {
|
||||
...embeddedPostRelations,
|
||||
repostOf: {
|
||||
with: embeddedPostRelations,
|
||||
},
|
||||
} as const;
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
const parseRemoteHandle = (handle: string) => {
|
||||
const clean = handle.toLowerCase().replace(/^@/, '');
|
||||
const parts = clean.split('@').filter(Boolean);
|
||||
if (parts.length === 2) {
|
||||
return { handle: parts[0], domain: parts[1] };
|
||||
}
|
||||
return null;
|
||||
type FeedPostWithChildren = {
|
||||
id: string;
|
||||
createdAt?: string | Date;
|
||||
repostOf?: FeedPostWithChildren | null;
|
||||
replyTo?: FeedPostWithChildren | null;
|
||||
isLiked?: boolean;
|
||||
isReposted?: boolean;
|
||||
nodeDomain?: string | null;
|
||||
originalPostId?: string | null;
|
||||
};
|
||||
|
||||
function parseMediaJson(mediaJson: string | null) {
|
||||
if (!mediaJson) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(mediaJson);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function mapUserSwarmRepostToFeedPost(
|
||||
row: typeof userSwarmReposts.$inferSelect,
|
||||
author: Pick<typeof users.$inferSelect, 'id' | 'handle' | 'displayName' | 'avatarUrl' | 'isBot'>
|
||||
): FeedPostWithChildren {
|
||||
const remoteAuthorHandle = row.authorHandle.includes('@')
|
||||
? row.authorHandle
|
||||
: `${row.authorHandle}@${row.nodeDomain}`;
|
||||
const remoteOriginalId = `swarm:${row.nodeDomain}:${row.originalPostId}`;
|
||||
|
||||
return {
|
||||
id: `swarm-repost:${row.id}`,
|
||||
content: '',
|
||||
createdAt: row.repostedAt.toISOString(),
|
||||
likesCount: 0,
|
||||
repostsCount: 0,
|
||||
repliesCount: 0,
|
||||
author: {
|
||||
id: author.id,
|
||||
handle: author.handle,
|
||||
displayName: author.displayName,
|
||||
avatarUrl: author.avatarUrl,
|
||||
isBot: author.isBot,
|
||||
},
|
||||
repostOfId: remoteOriginalId,
|
||||
repostOf: {
|
||||
id: remoteOriginalId,
|
||||
originalPostId: row.originalPostId,
|
||||
content: row.content,
|
||||
createdAt: row.postCreatedAt.toISOString(),
|
||||
likesCount: row.likesCount,
|
||||
repostsCount: row.repostsCount,
|
||||
repliesCount: row.repliesCount,
|
||||
isSwarm: true,
|
||||
nodeDomain: row.nodeDomain,
|
||||
author: {
|
||||
id: `swarm:${row.nodeDomain}:${row.authorHandle}`,
|
||||
handle: remoteAuthorHandle,
|
||||
displayName: row.authorDisplayName || row.authorHandle,
|
||||
avatarUrl: row.authorAvatarUrl,
|
||||
isRemote: true,
|
||||
nodeDomain: row.nodeDomain,
|
||||
},
|
||||
media: parseMediaJson(row.mediaJson),
|
||||
linkPreviewUrl: row.linkPreviewUrl,
|
||||
linkPreviewTitle: row.linkPreviewTitle,
|
||||
linkPreviewDescription: row.linkPreviewDescription,
|
||||
linkPreviewImage: row.linkPreviewImage,
|
||||
linkPreviewType: row.linkPreviewType,
|
||||
linkPreviewVideoUrl: row.linkPreviewVideoUrl,
|
||||
linkPreviewMedia: parseLinkPreviewMediaJson(row.linkPreviewMediaJson) || null,
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
function collectNestedPosts(posts: FeedPostWithChildren[]): FeedPostWithChildren[] {
|
||||
const collected: FeedPostWithChildren[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const visit = (post: FeedPostWithChildren | null | undefined) => {
|
||||
if (!post || seen.has(post.id)) return;
|
||||
seen.add(post.id);
|
||||
collected.push(post);
|
||||
visit(post.repostOf);
|
||||
visit(post.replyTo);
|
||||
};
|
||||
|
||||
posts.forEach(visit);
|
||||
return collected;
|
||||
}
|
||||
|
||||
function applyInteractionFlags(
|
||||
posts: FeedPostWithChildren[],
|
||||
likedIds: Set<string>,
|
||||
repostedIds: Set<string>
|
||||
): FeedPostWithChildren[] {
|
||||
return posts.map((post) => ({
|
||||
...post,
|
||||
isLiked: likedIds.has(post.id),
|
||||
isReposted: repostedIds.has(post.id),
|
||||
repostOf: post.repostOf ? applyInteractionFlags([post.repostOf], likedIds, repostedIds)[0] : post.repostOf,
|
||||
replyTo: post.replyTo ? applyInteractionFlags([post.replyTo], likedIds, repostedIds)[0] : post.replyTo,
|
||||
}));
|
||||
}
|
||||
|
||||
function getPostTimestamp(post: { createdAt?: string | Date }) {
|
||||
if (!post.createdAt) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return new Date(post.createdAt).getTime();
|
||||
}
|
||||
|
||||
async function getMixedProfileCursorDate(cursor: string | null) {
|
||||
if (!cursor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cursor.startsWith('swarm-repost:')) {
|
||||
const repostRow = await db.query.userSwarmReposts.findFirst({
|
||||
where: eq(userSwarmReposts.id, cursor.replace('swarm-repost:', '')),
|
||||
});
|
||||
return repostRow?.repostedAt ?? null;
|
||||
}
|
||||
|
||||
const cursorPost = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, cursor),
|
||||
});
|
||||
return cursorPost?.createdAt ?? null;
|
||||
}
|
||||
|
||||
async function populateViewerLikeState(
|
||||
remotePosts: any[],
|
||||
domain: string
|
||||
remotePosts: any[]
|
||||
) {
|
||||
if (!remotePosts.length) {
|
||||
return remotePosts;
|
||||
@@ -34,20 +179,32 @@ async function populateViewerLikeState(
|
||||
return remotePosts;
|
||||
}
|
||||
|
||||
const { getViewerSwarmRepostedPostIds } = await import('@/lib/swarm/reposts');
|
||||
|
||||
const allRemotePosts = collectNestedPosts(remotePosts as FeedPostWithChildren[]);
|
||||
const swarmTargets = allRemotePosts
|
||||
.filter((post) => post.id.startsWith('swarm:') && post.originalPostId && post.nodeDomain)
|
||||
.map((post) => ({
|
||||
id: post.id,
|
||||
nodeDomain: post.nodeDomain!,
|
||||
originalPostId: post.originalPostId!,
|
||||
}));
|
||||
|
||||
const likedIds = await getViewerSwarmLikedPostIds(
|
||||
remotePosts.map((post) => ({
|
||||
id: `swarm:${domain}:${post.originalPostId}`,
|
||||
nodeDomain: domain,
|
||||
originalPostId: post.originalPostId,
|
||||
})),
|
||||
swarmTargets,
|
||||
viewer.handle,
|
||||
nodeDomain
|
||||
);
|
||||
const repostedIds = await getViewerSwarmRepostedPostIds(
|
||||
swarmTargets,
|
||||
viewer.id
|
||||
);
|
||||
|
||||
return remotePosts.map((post) => ({
|
||||
...post,
|
||||
isLiked: likedIds.has(`swarm:${domain}:${post.originalPostId}`),
|
||||
}));
|
||||
return applyInteractionFlags(
|
||||
remotePosts as FeedPostWithChildren[],
|
||||
likedIds,
|
||||
repostedIds
|
||||
);
|
||||
} catch {
|
||||
return remotePosts;
|
||||
}
|
||||
@@ -62,6 +219,31 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
const cursor = searchParams.get('cursor');
|
||||
|
||||
const remote = parseRemoteHandle(handle);
|
||||
const fetchRemotePostsRoute = async () => {
|
||||
if (!remote) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
const baseUrl = getRemoteBaseUrl(remote.domain);
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/users/${remote.handle}/posts?limit=${limit}`,
|
||||
{
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const mappedPosts = (data.posts || []).map((post: any) => mapRemoteProfilePost(post, remote.domain));
|
||||
return NextResponse.json({
|
||||
posts: await populateViewerLikeState(mappedPosts),
|
||||
nextCursor: null,
|
||||
});
|
||||
};
|
||||
|
||||
if (!db) {
|
||||
if (!remote) {
|
||||
@@ -104,11 +286,14 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
linkPreviewTitle: post.linkPreviewTitle || null,
|
||||
linkPreviewDescription: post.linkPreviewDescription || null,
|
||||
linkPreviewImage: post.linkPreviewImage || null,
|
||||
linkPreviewType: post.linkPreviewType || null,
|
||||
linkPreviewVideoUrl: post.linkPreviewVideoUrl || null,
|
||||
linkPreviewMedia: post.linkPreviewMedia || null,
|
||||
isSwarm: true,
|
||||
nodeDomain: remote.domain,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ posts: await populateViewerLikeState(remotePosts, remote.domain), nextCursor: null });
|
||||
return NextResponse.json({ posts: await populateViewerLikeState(remotePosts), nextCursor: null });
|
||||
}
|
||||
|
||||
return NextResponse.json({ posts: [] });
|
||||
@@ -118,8 +303,9 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
const isRemotePlaceholder = user && cleanHandle.includes('@');
|
||||
|
||||
if (!user) {
|
||||
if (!user || isRemotePlaceholder) {
|
||||
if (!remote) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
@@ -135,39 +321,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
return NextResponse.json({ posts: [], message: 'Only Synapsis swarm nodes are supported' });
|
||||
}
|
||||
|
||||
const profileData = await fetchSwarmUserProfile(remote.handle, remote.domain, limit);
|
||||
if (profileData?.posts) {
|
||||
const profile = profileData.profile;
|
||||
const authorHandle = `${profile.handle}@${remote.domain}`;
|
||||
const author = {
|
||||
id: `swarm:${remote.domain}:${profile.handle}`,
|
||||
handle: authorHandle,
|
||||
displayName: profile.displayName || profile.handle,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
};
|
||||
|
||||
const remotePosts = profileData.posts.map((post: any) => ({
|
||||
id: post.id,
|
||||
originalPostId: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt,
|
||||
likesCount: post.likesCount || 0,
|
||||
repostsCount: post.repostsCount || 0,
|
||||
repliesCount: post.repliesCount || 0,
|
||||
author,
|
||||
media: post.media || [],
|
||||
linkPreviewUrl: post.linkPreviewUrl || null,
|
||||
linkPreviewTitle: post.linkPreviewTitle || null,
|
||||
linkPreviewDescription: post.linkPreviewDescription || null,
|
||||
linkPreviewImage: post.linkPreviewImage || null,
|
||||
isSwarm: true,
|
||||
nodeDomain: remote.domain,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ posts: await populateViewerLikeState(remotePosts, remote.domain), nextCursor: null });
|
||||
}
|
||||
|
||||
return NextResponse.json({ posts: [] });
|
||||
return await fetchRemotePostsRoute();
|
||||
}
|
||||
|
||||
if (user.isSuspended) {
|
||||
@@ -175,43 +329,49 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
}
|
||||
|
||||
// Get user's posts with cursor-based pagination
|
||||
const cursorDate = await getMixedProfileCursorDate(cursor);
|
||||
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) {
|
||||
const cursorPost = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, cursor),
|
||||
});
|
||||
if (cursorPost) {
|
||||
whereConditions = and(
|
||||
eq(posts.userId, user.id),
|
||||
eq(posts.isRemoved, false),
|
||||
isNull(posts.replyToId),
|
||||
isNull(posts.swarmReplyToId),
|
||||
lt(posts.createdAt, cursorPost.createdAt)
|
||||
);
|
||||
}
|
||||
|
||||
if (cursorDate) {
|
||||
whereConditions = and(
|
||||
eq(posts.userId, user.id),
|
||||
eq(posts.isRemoved, false),
|
||||
isNull(posts.replyToId),
|
||||
isNull(posts.swarmReplyToId),
|
||||
lt(posts.createdAt, cursorDate)
|
||||
);
|
||||
}
|
||||
|
||||
let userPosts: any[] = await db.query.posts.findMany({
|
||||
|
||||
const localPosts = await db.query.posts.findMany({
|
||||
where: whereConditions,
|
||||
with: {
|
||||
author: true,
|
||||
media: true,
|
||||
bot: true,
|
||||
replyTo: {
|
||||
with: { author: true },
|
||||
},
|
||||
},
|
||||
with: userPostRelations,
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit,
|
||||
limit: cursor ? limit : limit * 2,
|
||||
});
|
||||
|
||||
const swarmRepostWhere = cursorDate
|
||||
? and(
|
||||
eq(userSwarmReposts.userId, user.id),
|
||||
lt(userSwarmReposts.repostedAt, cursorDate)
|
||||
)
|
||||
: eq(userSwarmReposts.userId, user.id);
|
||||
const swarmRepostRows = await db.query.userSwarmReposts.findMany({
|
||||
where: swarmRepostWhere,
|
||||
orderBy: [desc(userSwarmReposts.repostedAt)],
|
||||
limit: cursor ? limit : limit * 2,
|
||||
});
|
||||
let userPosts: any[] = [
|
||||
...localPosts,
|
||||
...swarmRepostRows.map((row) => mapUserSwarmRepostToFeedPost(row, user)),
|
||||
]
|
||||
.sort((a, b) => getPostTimestamp(b) - getPostTimestamp(a))
|
||||
.slice(0, limit);
|
||||
|
||||
// Populate isLiked and isReposted for authenticated users
|
||||
try {
|
||||
const { getSession } = await import('@/lib/auth');
|
||||
@@ -219,32 +379,71 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
|
||||
if (session?.user && userPosts.length > 0) {
|
||||
const viewer = session.user;
|
||||
const postIds = userPosts.map(p => p.id).filter(Boolean);
|
||||
const allProfilePosts = collectNestedPosts(userPosts as FeedPostWithChildren[]);
|
||||
const localPostIds: string[] = [];
|
||||
const swarmTargets: Array<{ id: string; nodeDomain: string; originalPostId: string }> = [];
|
||||
|
||||
if (postIds.length > 0) {
|
||||
for (const post of allProfilePosts) {
|
||||
if (post.id.startsWith('swarm:') && post.nodeDomain && post.originalPostId) {
|
||||
swarmTargets.push({
|
||||
id: post.id,
|
||||
nodeDomain: post.nodeDomain,
|
||||
originalPostId: post.originalPostId,
|
||||
});
|
||||
} else if (!post.id.startsWith('swarm-repost:')) {
|
||||
localPostIds.push(post.id);
|
||||
}
|
||||
}
|
||||
|
||||
const likedPostIds = new Set<string>();
|
||||
const repostedPostIds = new Set<string>();
|
||||
|
||||
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));
|
||||
viewerLikes.forEach((like) => likedPostIds.add(like.postId));
|
||||
|
||||
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)
|
||||
),
|
||||
});
|
||||
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
|
||||
|
||||
userPosts = userPosts.map(p => ({
|
||||
...p,
|
||||
isLiked: likedPostIds.has(p.id),
|
||||
isReposted: repostedPostIds.has(p.id),
|
||||
}));
|
||||
viewerReposts.forEach((repost) => {
|
||||
if (repost.repostOfId) {
|
||||
repostedPostIds.add(repost.repostOfId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (swarmTargets.length > 0) {
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const likedIds = await getViewerSwarmLikedPostIds(
|
||||
swarmTargets.map((post) => ({
|
||||
id: post.id,
|
||||
nodeDomain: post.nodeDomain,
|
||||
originalPostId: post.originalPostId,
|
||||
})),
|
||||
viewer.handle,
|
||||
nodeDomain
|
||||
);
|
||||
likedIds.forEach((id) => likedPostIds.add(id));
|
||||
|
||||
const { getViewerSwarmRepostedPostIds } = await import('@/lib/swarm/reposts');
|
||||
const repostedIds = await getViewerSwarmRepostedPostIds(swarmTargets, viewer.id);
|
||||
repostedIds.forEach((id) => repostedPostIds.add(id));
|
||||
}
|
||||
|
||||
userPosts = applyInteractionFlags(
|
||||
userPosts as FeedPostWithChildren[],
|
||||
likedPostIds,
|
||||
repostedPostIds
|
||||
) as any;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error populating interaction flags:', error);
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, likes, posts, users } from '@/db';
|
||||
import { and, desc, eq, inArray, lt, not, or, isNotNull } from 'drizzle-orm';
|
||||
import { discoverNode } from '@/lib/swarm/discovery';
|
||||
import { getRemoteBaseUrl, mapRemoteProfilePost, parseRemoteHandle } from '@/lib/swarm/remote-profile-posts';
|
||||
import { isSwarmNode } from '@/lib/swarm/interactions';
|
||||
import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts';
|
||||
|
||||
const embeddedPostRelations = {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
replyTo: {
|
||||
with: {
|
||||
author: true,
|
||||
bot: true,
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const replyRelations = {
|
||||
...embeddedPostRelations,
|
||||
repostOf: {
|
||||
with: embeddedPostRelations,
|
||||
},
|
||||
} as const;
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||
const cursor = searchParams.get('cursor');
|
||||
const remote = parseRemoteHandle(handle);
|
||||
|
||||
const fetchRemoteReplies = async () => {
|
||||
if (!remote) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
const baseUrl = getRemoteBaseUrl(remote.domain);
|
||||
const res = await fetch(`${baseUrl}/api/users/${remote.handle}/replies?limit=${limit}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const { getSession } = await import('@/lib/auth');
|
||||
const session = await getSession();
|
||||
const viewer = session?.user;
|
||||
const mappedPosts = (data.posts || []).map((post: any) => mapRemoteProfilePost(post, remote.domain));
|
||||
const repostedIds = viewer
|
||||
? await getViewerSwarmRepostedPostIds(
|
||||
mappedPosts.map((post: any) => ({
|
||||
id: post.id,
|
||||
nodeDomain: remote.domain,
|
||||
originalPostId: post.originalPostId || post.id.split(':').pop(),
|
||||
})),
|
||||
viewer.id
|
||||
)
|
||||
: new Set<string>();
|
||||
return NextResponse.json({
|
||||
posts: mappedPosts.map((post: any) => ({
|
||||
...post,
|
||||
isReposted: repostedIds.has(post.id),
|
||||
})),
|
||||
nextCursor: null,
|
||||
});
|
||||
};
|
||||
|
||||
if (!db) {
|
||||
if (!remote) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
let swarm = await isSwarmNode(remote.domain);
|
||||
if (!swarm) {
|
||||
const discovery = await discoverNode(remote.domain);
|
||||
swarm = discovery.success;
|
||||
}
|
||||
|
||||
if (!swarm) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
return await fetchRemoteReplies();
|
||||
}
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
const isRemotePlaceholder = user && cleanHandle.includes('@');
|
||||
|
||||
if (!user || isRemotePlaceholder) {
|
||||
if (!remote) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
let swarm = await isSwarmNode(remote.domain);
|
||||
if (!swarm) {
|
||||
const discovery = await discoverNode(remote.domain);
|
||||
swarm = discovery.success;
|
||||
}
|
||||
|
||||
if (!swarm) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
return await fetchRemoteReplies();
|
||||
}
|
||||
|
||||
if (user.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
let whereConditions = and(
|
||||
eq(posts.userId, user.id),
|
||||
eq(posts.isRemoved, false),
|
||||
or(isNotNull(posts.replyToId), isNotNull(posts.swarmReplyToId)),
|
||||
);
|
||||
|
||||
if (cursor) {
|
||||
const cursorPost = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, cursor),
|
||||
});
|
||||
if (cursorPost) {
|
||||
whereConditions = and(
|
||||
eq(posts.userId, user.id),
|
||||
eq(posts.isRemoved, false),
|
||||
or(isNotNull(posts.replyToId), isNotNull(posts.swarmReplyToId)),
|
||||
lt(posts.createdAt, cursorPost.createdAt),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let replyPosts: any[] = await db.query.posts.findMany({
|
||||
where: whereConditions,
|
||||
with: replyRelations,
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
limit,
|
||||
});
|
||||
|
||||
try {
|
||||
const { getSession } = await import('@/lib/auth');
|
||||
const session = await getSession();
|
||||
|
||||
if (session?.user && replyPosts.length > 0) {
|
||||
const viewer = session.user;
|
||||
const postIds = replyPosts.map((post) => post.id).filter(Boolean);
|
||||
|
||||
const viewerLikes = postIds.length > 0
|
||||
? await db.query.likes.findMany({
|
||||
where: and(
|
||||
eq(likes.userId, viewer.id),
|
||||
inArray(likes.postId, postIds),
|
||||
),
|
||||
})
|
||||
: [];
|
||||
const likedPostIds = new Set(viewerLikes.map((like) => like.postId));
|
||||
|
||||
const viewerReposts = postIds.length > 0
|
||||
? await db.query.posts.findMany({
|
||||
where: and(
|
||||
eq(posts.userId, viewer.id),
|
||||
inArray(posts.repostOfId, postIds),
|
||||
eq(posts.isRemoved, false),
|
||||
),
|
||||
})
|
||||
: [];
|
||||
const repostedPostIds = new Set(viewerReposts.map((post) => post.repostOfId));
|
||||
|
||||
replyPosts = replyPosts.map((post) => ({
|
||||
...post,
|
||||
isLiked: likedPostIds.has(post.id),
|
||||
isReposted: repostedPostIds.has(post.id),
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
posts: replyPosts,
|
||||
nextCursor: replyPosts.length === limit ? replyPosts[replyPosts.length - 1]?.id : null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get user replies error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get replies' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,15 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
const profileData = await fetchSwarmUserProfile(remoteHandle, remoteDomain, 0);
|
||||
if (profileData?.profile) {
|
||||
const profile = profileData.profile;
|
||||
const rawBotOwnerHandle = profile.botOwnerHandle?.toLowerCase().replace(/^@/, '') || null;
|
||||
const normalizedBotOwnerHandle = rawBotOwnerHandle
|
||||
? rawBotOwnerHandle.includes('@')
|
||||
? rawBotOwnerHandle
|
||||
: `${rawBotOwnerHandle}@${remoteDomain}`
|
||||
: null;
|
||||
const botOwnerLocalHandle = rawBotOwnerHandle
|
||||
? rawBotOwnerHandle.split('@')[0]
|
||||
: null;
|
||||
|
||||
// CACHE: Upsert the remote user into our local database
|
||||
const { upsertRemoteUser } = await import('@/lib/swarm/user-cache');
|
||||
@@ -80,6 +89,12 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
isSwarm: true,
|
||||
nodeDomain: remoteDomain,
|
||||
isBot: profile.isBot || false,
|
||||
botOwner: normalizedBotOwnerHandle && botOwnerLocalHandle ? {
|
||||
id: `swarm:${remoteDomain}:${botOwnerLocalHandle}`,
|
||||
handle: normalizedBotOwnerHandle,
|
||||
displayName: botOwnerLocalHandle,
|
||||
avatarUrl: null,
|
||||
} : null,
|
||||
did: profile.did,
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user