feat: Implement dynamic node title metadata and enhance swarm user and post hydration.

This commit is contained in:
Christomatt
2026-01-26 17:09:21 +01:00
parent 3ba60cadf5
commit cf0dfa4b66
12 changed files with 380 additions and 105 deletions
+36 -34
View File
@@ -120,7 +120,7 @@ export async function POST(request: Request) {
(async () => {
try {
const targetUrl = `https://${data.swarmReplyTo!.nodeDomain}/api/swarm/replies`;
const replyPayload = {
postId: data.swarmReplyTo!.postId,
reply: {
@@ -159,18 +159,18 @@ export async function POST(request: Request) {
try {
const { extractMentions } = await import('@/lib/swarm/interactions');
const { notifications } = await import('@/db');
const mentions = extractMentions(data.content);
for (const mention of mentions) {
// Only handle local mentions (no domain)
if (mention.domain) continue;
// Find the mentioned user
const mentionedUser = await db.query.users.findFirst({
where: eq(users.handle, mention.handle.toLowerCase()),
});
if (mentionedUser && mentionedUser.id !== user.id && !mentionedUser.isSuspended) {
// Create notification for the mentioned user with actor info stored directly
await db.insert(notifications).values({
@@ -184,7 +184,7 @@ export async function POST(request: Request) {
postContent: post.content?.slice(0, 200) || null,
type: 'mention',
});
// Also notify bot owner if this is a bot being mentioned
if (mentionedUser.isBot && mentionedUser.botOwnerId) {
await db.insert(notifications).values({
@@ -210,7 +210,7 @@ export async function POST(request: Request) {
(async () => {
try {
const { deliverSwarmMentions } = await import('@/lib/swarm/interactions');
const result = await deliverSwarmMentions(
data.content,
post.id,
@@ -221,7 +221,7 @@ export async function POST(request: Request) {
nodeDomain,
}
);
if (result.delivered > 0) {
console.log(`[Swarm] Delivered ${result.delivered} mentions (${result.failed} failed)`);
}
@@ -235,7 +235,7 @@ export async function POST(request: Request) {
try {
// SWARM-FIRST: Deliver to swarm followers directly
const { deliverPostToSwarmFollowers } = await import('@/lib/swarm/interactions');
const swarmResult = await deliverPostToSwarmFollowers(
user.id,
post,
@@ -248,7 +248,7 @@ export async function POST(request: Request) {
attachedMedia,
nodeDomain
);
if (swarmResult.delivered > 0) {
console.log(`[Swarm] Post ${post.id} delivered to ${swarmResult.delivered} swarm nodes (${swarmResult.failed} failed)`);
}
@@ -399,7 +399,7 @@ export async function GET(request: Request) {
if (type === 'local') {
// Local node posts only - no fediverse content
let whereCondition = baseFilter;
// Apply cursor-based pagination
if (cursor) {
const cursorPost = await db.query.posts.findFirst({
@@ -409,7 +409,7 @@ export async function GET(request: Request) {
whereCondition = buildWhere(baseFilter, lt(posts.createdAt, cursorPost.createdAt));
}
}
feedPosts = await db.query.posts.findMany({
where: whereCondition,
with: {
@@ -454,7 +454,7 @@ export async function GET(request: Request) {
} else if (type === 'user' && userId) {
// User's posts (excluding replies)
let whereCondition = buildWhere(baseFilter, eq(posts.userId, userId));
// Apply cursor-based pagination
if (cursor) {
const cursorPost = await db.query.posts.findFirst({
@@ -464,7 +464,7 @@ export async function GET(request: Request) {
whereCondition = buildWhere(baseFilter, eq(posts.userId, userId), lt(posts.createdAt, cursorPost.createdAt));
}
}
feedPosts = await db.query.posts.findMany({
where: whereCondition,
with: {
@@ -481,7 +481,7 @@ export async function GET(request: Request) {
} else if (type === 'replies' && userId) {
// User's replies only
let whereCondition = buildWhere(repliesFilter, eq(posts.userId, userId));
// Apply cursor-based pagination
if (cursor) {
const cursorPost = await db.query.posts.findFirst({
@@ -491,7 +491,7 @@ export async function GET(request: Request) {
whereCondition = buildWhere(repliesFilter, eq(posts.userId, userId), lt(posts.createdAt, cursorPost.createdAt));
}
}
feedPosts = await db.query.posts.findMany({
where: whereCondition,
with: {
@@ -522,7 +522,7 @@ export async function GET(request: Request) {
// Fetch swarm posts with user's NSFW preference
const { fetchSwarmTimeline } = await import('@/lib/swarm/timeline');
const swarmResult = await fetchSwarmTimeline(10, 30, { includeNsfw });
// Transform swarm posts to match local post format
const swarmPosts = swarmResult.posts.map(sp => ({
id: `swarm:${sp.nodeDomain}:${sp.id}`,
@@ -540,6 +540,7 @@ export async function GET(request: Request) {
displayName: sp.author.displayName,
avatarUrl: sp.author.avatarUrl,
isSwarm: true,
isBot: sp.author.isBot,
nodeDomain: sp.nodeDomain,
},
media: sp.media?.map((m, idx) => ({
@@ -630,13 +631,13 @@ export async function GET(request: Request) {
.from(follows)
.where(eq(follows.followerId, user.id));
const followingIds = followRows.map(row => row.followingId);
// Include own posts + posts from followed users
const allowedUserIds = [user.id, ...followingIds];
// Build where condition with cursor support
let whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds));
if (cursor) {
const cursorPost = await db.query.posts.findFirst({
where: eq(posts.id, cursor),
@@ -671,7 +672,7 @@ export async function GET(request: Request) {
if (followedRemoteUsers.length > 0) {
const { fetchSwarmUserProfile, isSwarmNode } = await import('@/lib/swarm/interactions');
const { resolveRemoteUser } = await import('@/lib/activitypub/fetch');
// Wrap each fetch with a timeout to prevent slow nodes from blocking
const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T | null> => {
return Promise.race([
@@ -679,25 +680,25 @@ export async function GET(request: Request) {
new Promise<null>((resolve) => setTimeout(() => resolve(null), ms))
]);
};
const fetchPromises = followedRemoteUsers.map(async (follow) => {
try {
const atIndex = follow.targetHandle.lastIndexOf('@');
if (atIndex === -1) return [];
const handle = follow.targetHandle.slice(0, atIndex);
const domain = follow.targetHandle.slice(atIndex + 1);
// Check if swarm node - use swarm API (faster)
const isSwarm = await isSwarmNode(domain);
if (isSwarm) {
const profileData = await withTimeout(
fetchSwarmUserProfile(handle, domain, limit),
5000 // 5s timeout per node
);
if (!profileData?.posts) return [];
return profileData.posts.map(post => ({
id: `swarm:${domain}:${post.id}`,
content: post.content,
@@ -717,6 +718,7 @@ export async function GET(request: Request) {
displayName: follow.displayName || profileData.profile?.displayName || handle,
avatarUrl: follow.avatarUrl || profileData.profile?.avatarUrl,
isRemote: true,
isBot: profileData.profile?.isBot,
},
media: post.media?.map((m: any, idx: number) => ({
id: `swarm:${domain}:${post.id}:media:${idx}`,
@@ -729,14 +731,14 @@ export async function GET(request: Request) {
// ActivityPub - fetch from outbox
const remoteProfile = await resolveRemoteUser(handle, domain);
if (!remoteProfile?.outbox) return [];
// For AP, fall back to cached posts (live outbox fetch is slower)
const cachedPosts = await db.query.remotePosts.findMany({
where: eq(remotePosts.authorHandle, follow.targetHandle),
orderBy: [desc(remotePosts.publishedAt)],
limit: limit,
});
return transformRemotePosts(cachedPosts);
}
} catch (error) {
@@ -744,7 +746,7 @@ export async function GET(request: Request) {
return [];
}
});
const results = await Promise.all(fetchPromises);
liveRemotePosts = results.flat();
}
@@ -781,11 +783,11 @@ export async function GET(request: Request) {
if (session?.user && feedPosts && feedPosts.length > 0) {
const viewer = session.user;
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
// Separate local and swarm posts
const localPostIds: string[] = [];
const swarmPosts: Array<{ id: string; domain: string; originalId: string }> = [];
for (const p of feedPosts as Array<{ id: string }>) {
if (p.id.startsWith('swarm:')) {
const parts = p.id.split(':');
@@ -804,7 +806,7 @@ export async function GET(request: Request) {
// Check local likes
const likedPostIds = new Set<string>();
const repostedPostIds = new Set<string>();
if (localPostIds.length > 0) {
const viewerLikes = await db.query.likes.findMany({
where: and(
@@ -829,12 +831,12 @@ export async function GET(request: Request) {
try {
const protocol = sp.domain.includes('localhost') ? 'http' : 'https';
const url = `${protocol}://${sp.domain}/api/swarm/posts/${sp.originalId}/likes?checkHandle=${viewer.handle}&checkDomain=${nodeDomain}`;
const res = await fetch(url, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(3000),
});
if (res.ok) {
const data = await res.json();
if (data.isLiked) {
@@ -845,7 +847,7 @@ export async function GET(request: Request) {
// Timeout or error - just skip
}
});
await Promise.all(checkPromises);
}
+3 -2
View File
@@ -18,7 +18,8 @@ export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const refresh = searchParams.get('refresh') === 'true';
const cursor = searchParams.get('cursor') || undefined;
// Check user's NSFW preference
let includeNsfw = false;
try {
@@ -29,7 +30,7 @@ export async function GET(request: NextRequest) {
}
// Fetch swarm timeline (no caching - user preferences vary)
const timeline = await fetchSwarmTimeline(10, 15, { includeNsfw });
const timeline = await fetchSwarmTimeline(10, 15, { includeNsfw, cursor });
return NextResponse.json({
posts: timeline.posts,
+25 -9
View File
@@ -6,7 +6,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, media, nodes } from '@/db';
import { eq, desc, and, isNull } from 'drizzle-orm';
import { eq, desc, and, isNull, lt } from 'drizzle-orm';
export interface SwarmPost {
id: string;
@@ -17,6 +17,7 @@ export interface SwarmPost {
displayName: string;
avatarUrl?: string;
isNsfw: boolean;
isBot?: boolean;
};
nodeDomain: string;
nodeIsNsfw: boolean;
@@ -43,18 +44,36 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
const cursor = searchParams.get('cursor');
if (!db) {
return NextResponse.json({ posts: [], nodeDomain: '', nodeIsNsfw: false });
}
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
// Get node NSFW status
const node = await db.query.nodes.findFirst({
where: eq(nodes.domain, nodeDomain),
});
const nodeIsNsfw = node?.isNsfw ?? false;
// Use query builder for better conditional logic
let whereCondition = and(
isNull(posts.replyToId), // Not a reply
eq(posts.isRemoved, false) // Not removed
);
if (cursor) {
// Find the cursor post or use timestamp directly if passed as ISO string
// Actually, for swarm, passing ISO timestamp is safer than ID because IDs are local UUIDs
// Let's assume cursor is an ISO date string for swarm timeline
const cursorDate = new Date(cursor);
if (!isNaN(cursorDate.getTime())) {
whereCondition = and(whereCondition, lt(posts.createdAt, cursorDate));
}
}
// Get recent public posts (not replies, local users only, not removed)
const recentPosts = await db
.select({
@@ -73,22 +92,18 @@ export async function GET(request: NextRequest) {
authorDisplayName: users.displayName,
authorAvatarUrl: users.avatarUrl,
authorIsNsfw: users.isNsfw,
authorIsBot: users.isBot,
authorNodeId: users.nodeId,
})
.from(posts)
.innerJoin(users, eq(posts.userId, users.id))
.where(
and(
isNull(posts.replyToId), // Not a reply
eq(posts.isRemoved, false) // Not removed
)
)
.where(whereCondition)
.orderBy(desc(posts.createdAt))
.limit(limit);
// Fetch media for each post
const swarmPosts: SwarmPost[] = [];
for (const post of recentPosts) {
const postMedia = await db
.select({ url: media.url, mimeType: media.mimeType, altText: media.altText })
@@ -104,6 +119,7 @@ export async function GET(request: NextRequest) {
displayName: post.authorDisplayName || post.authorHandle,
avatarUrl: post.authorAvatarUrl || undefined,
isNsfw: post.authorIsNsfw,
isBot: post.authorIsBot,
},
nodeDomain,
nodeIsNsfw,
@@ -7,6 +7,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, follows, users, remoteFollowers } from '@/db';
import { eq } from 'drizzle-orm';
import { hydrateSwarmUsers } from '@/lib/swarm/user-hydration';
export interface SwarmFollowerUser {
handle: string;
@@ -88,8 +89,33 @@ export async function GET(request: NextRequest, context: RouteContext) {
// Merge all followers
const allFollowers = [...localFollowers, ...remoteFollowersList].slice(0, limit);
// Hydrate remote users (from 3rd party nodes)
// We need to map to the HydratedUser interface temporarily for the helper
const toHydrate = allFollowers.map(f => ({
id: f.handle,
handle: f.handle,
displayName: f.displayName,
avatarUrl: f.avatarUrl,
bio: f.bio,
isRemote: f.isRemote || false,
isBot: f.isBot,
nodeDomain: undefined,
}));
const hydrated = await hydrateSwarmUsers(toHydrate);
// Map back to SwarmFollowerUser
const finalFollowers: SwarmFollowerUser[] = hydrated.map(u => ({
handle: u.handle,
displayName: u.displayName || u.handle.split('@')[0], // Ensure non-null
avatarUrl: u.avatarUrl || undefined, // Map null to undefined
bio: u.bio || undefined,
isBot: u.isBot,
isRemote: u.isRemote,
}));
return NextResponse.json({
followers: allFollowers,
followers: finalFollowers,
nodeDomain,
timestamp: new Date().toISOString(),
});
+17 -9
View File
@@ -31,7 +31,7 @@ export async function GET(request: Request, context: RouteContext) {
// Check if this is a remote user
const [remoteHandle, remoteDomain] = cleanHandle.split('@');
if (remoteDomain) {
// Fetch from remote swarm node
const swarmData = await fetchSwarmFollowers(remoteHandle, remoteDomain, limit);
@@ -80,15 +80,23 @@ export async function GET(request: Request, context: RouteContext) {
.where(eq(follows.followingId, user.id))
.limit(limit);
const allFollowers = userFollowers.map(f => ({
id: f.follower.id,
handle: f.follower.handle,
displayName: f.follower.displayName,
avatarUrl: f.follower.avatarUrl,
bio: f.follower.bio,
isBot: f.follower.isBot,
isRemote: false,
}));
// Hydrate remote users with fresh data from swarm (if we had local storage for remote followers, we'd merge them here)
// Since we don't store remote followers locally for local users (only incoming follows),
// we mainly need this if we were merging remote lists which we only do in the swarm endpoint.
// However, let's keep it consistent in case we add remote followers storage.
return NextResponse.json({
followers: userFollowers.map(f => ({
id: f.follower.id,
handle: f.follower.handle,
displayName: f.follower.displayName,
avatarUrl: f.follower.avatarUrl,
bio: f.follower.bio,
isBot: f.follower.isBot,
})),
followers: allFollowers,
nextCursor: userFollowers.length === limit ? userFollowers[userFollowers.length - 1]?.id : null,
});
} catch (error) {
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server';
import { db, follows, users, remoteFollows } from '@/db';
import { eq } from 'drizzle-orm';
import { hydrateSwarmUsers } from '@/lib/swarm/user-hydration';
type RouteContext = { params: Promise<{ handle: string }> };
@@ -31,7 +32,7 @@ export async function GET(request: Request, context: RouteContext) {
// Check if this is a remote user
const [remoteHandle, remoteDomain] = cleanHandle.split('@');
if (remoteDomain) {
// Fetch from remote swarm node
const swarmData = await fetchSwarmFollowing(remoteHandle, remoteDomain, limit);
@@ -108,8 +109,11 @@ export async function GET(request: Request, context: RouteContext) {
// Merge and return
const allFollowing = [...localFollowing, ...remoteFollowing].slice(0, limit);
// Hydrate remote users with fresh data from swarm
const hydratedFollowing = await hydrateSwarmUsers(allFollowing);
return NextResponse.json({
following: allFollowing,
following: hydratedFollowing,
nextCursor: allFollowing.length === limit ? allFollowing[allFollowing.length - 1]?.id : null,
});
} catch (error) {