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 () => { (async () => {
try { try {
const targetUrl = `https://${data.swarmReplyTo!.nodeDomain}/api/swarm/replies`; const targetUrl = `https://${data.swarmReplyTo!.nodeDomain}/api/swarm/replies`;
const replyPayload = { const replyPayload = {
postId: data.swarmReplyTo!.postId, postId: data.swarmReplyTo!.postId,
reply: { reply: {
@@ -159,18 +159,18 @@ export async function POST(request: Request) {
try { try {
const { extractMentions } = await import('@/lib/swarm/interactions'); const { extractMentions } = await import('@/lib/swarm/interactions');
const { notifications } = await import('@/db'); const { notifications } = await import('@/db');
const mentions = extractMentions(data.content); const mentions = extractMentions(data.content);
for (const mention of mentions) { for (const mention of mentions) {
// Only handle local mentions (no domain) // Only handle local mentions (no domain)
if (mention.domain) continue; if (mention.domain) continue;
// Find the mentioned user // Find the mentioned user
const mentionedUser = await db.query.users.findFirst({ const mentionedUser = await db.query.users.findFirst({
where: eq(users.handle, mention.handle.toLowerCase()), where: eq(users.handle, mention.handle.toLowerCase()),
}); });
if (mentionedUser && mentionedUser.id !== user.id && !mentionedUser.isSuspended) { if (mentionedUser && mentionedUser.id !== user.id && !mentionedUser.isSuspended) {
// Create notification for the mentioned user with actor info stored directly // Create notification for the mentioned user with actor info stored directly
await db.insert(notifications).values({ await db.insert(notifications).values({
@@ -184,7 +184,7 @@ export async function POST(request: Request) {
postContent: post.content?.slice(0, 200) || null, postContent: post.content?.slice(0, 200) || null,
type: 'mention', type: 'mention',
}); });
// Also notify bot owner if this is a bot being mentioned // Also notify bot owner if this is a bot being mentioned
if (mentionedUser.isBot && mentionedUser.botOwnerId) { if (mentionedUser.isBot && mentionedUser.botOwnerId) {
await db.insert(notifications).values({ await db.insert(notifications).values({
@@ -210,7 +210,7 @@ export async function POST(request: Request) {
(async () => { (async () => {
try { try {
const { deliverSwarmMentions } = await import('@/lib/swarm/interactions'); const { deliverSwarmMentions } = await import('@/lib/swarm/interactions');
const result = await deliverSwarmMentions( const result = await deliverSwarmMentions(
data.content, data.content,
post.id, post.id,
@@ -221,7 +221,7 @@ export async function POST(request: Request) {
nodeDomain, nodeDomain,
} }
); );
if (result.delivered > 0) { if (result.delivered > 0) {
console.log(`[Swarm] Delivered ${result.delivered} mentions (${result.failed} failed)`); console.log(`[Swarm] Delivered ${result.delivered} mentions (${result.failed} failed)`);
} }
@@ -235,7 +235,7 @@ export async function POST(request: Request) {
try { try {
// SWARM-FIRST: Deliver to swarm followers directly // SWARM-FIRST: Deliver to swarm followers directly
const { deliverPostToSwarmFollowers } = await import('@/lib/swarm/interactions'); const { deliverPostToSwarmFollowers } = await import('@/lib/swarm/interactions');
const swarmResult = await deliverPostToSwarmFollowers( const swarmResult = await deliverPostToSwarmFollowers(
user.id, user.id,
post, post,
@@ -248,7 +248,7 @@ export async function POST(request: Request) {
attachedMedia, attachedMedia,
nodeDomain nodeDomain
); );
if (swarmResult.delivered > 0) { if (swarmResult.delivered > 0) {
console.log(`[Swarm] Post ${post.id} delivered to ${swarmResult.delivered} swarm nodes (${swarmResult.failed} failed)`); 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') { if (type === 'local') {
// Local node posts only - no fediverse content // Local node posts only - no fediverse content
let whereCondition = baseFilter; let whereCondition = baseFilter;
// Apply cursor-based pagination // Apply cursor-based pagination
if (cursor) { if (cursor) {
const cursorPost = await db.query.posts.findFirst({ 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)); whereCondition = buildWhere(baseFilter, lt(posts.createdAt, cursorPost.createdAt));
} }
} }
feedPosts = await db.query.posts.findMany({ feedPosts = await db.query.posts.findMany({
where: whereCondition, where: whereCondition,
with: { with: {
@@ -454,7 +454,7 @@ export async function GET(request: Request) {
} else if (type === 'user' && userId) { } else if (type === 'user' && userId) {
// User's posts (excluding replies) // User's posts (excluding replies)
let whereCondition = buildWhere(baseFilter, eq(posts.userId, userId)); let whereCondition = buildWhere(baseFilter, eq(posts.userId, userId));
// Apply cursor-based pagination // Apply cursor-based pagination
if (cursor) { if (cursor) {
const cursorPost = await db.query.posts.findFirst({ 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)); whereCondition = buildWhere(baseFilter, eq(posts.userId, userId), lt(posts.createdAt, cursorPost.createdAt));
} }
} }
feedPosts = await db.query.posts.findMany({ feedPosts = await db.query.posts.findMany({
where: whereCondition, where: whereCondition,
with: { with: {
@@ -481,7 +481,7 @@ export async function GET(request: Request) {
} else if (type === 'replies' && userId) { } else if (type === 'replies' && userId) {
// User's replies only // User's replies only
let whereCondition = buildWhere(repliesFilter, eq(posts.userId, userId)); let whereCondition = buildWhere(repliesFilter, eq(posts.userId, userId));
// Apply cursor-based pagination // Apply cursor-based pagination
if (cursor) { if (cursor) {
const cursorPost = await db.query.posts.findFirst({ 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)); whereCondition = buildWhere(repliesFilter, eq(posts.userId, userId), lt(posts.createdAt, cursorPost.createdAt));
} }
} }
feedPosts = await db.query.posts.findMany({ feedPosts = await db.query.posts.findMany({
where: whereCondition, where: whereCondition,
with: { with: {
@@ -522,7 +522,7 @@ export async function GET(request: Request) {
// Fetch swarm posts with user's NSFW preference // Fetch swarm posts with user's NSFW preference
const { fetchSwarmTimeline } = await import('@/lib/swarm/timeline'); const { fetchSwarmTimeline } = await import('@/lib/swarm/timeline');
const swarmResult = await fetchSwarmTimeline(10, 30, { includeNsfw }); const swarmResult = await fetchSwarmTimeline(10, 30, { includeNsfw });
// Transform swarm posts to match local post format // Transform swarm posts to match local post format
const swarmPosts = swarmResult.posts.map(sp => ({ const swarmPosts = swarmResult.posts.map(sp => ({
id: `swarm:${sp.nodeDomain}:${sp.id}`, id: `swarm:${sp.nodeDomain}:${sp.id}`,
@@ -540,6 +540,7 @@ export async function GET(request: Request) {
displayName: sp.author.displayName, displayName: sp.author.displayName,
avatarUrl: sp.author.avatarUrl, avatarUrl: sp.author.avatarUrl,
isSwarm: true, isSwarm: true,
isBot: sp.author.isBot,
nodeDomain: sp.nodeDomain, nodeDomain: sp.nodeDomain,
}, },
media: sp.media?.map((m, idx) => ({ media: sp.media?.map((m, idx) => ({
@@ -630,13 +631,13 @@ export async function GET(request: Request) {
.from(follows) .from(follows)
.where(eq(follows.followerId, user.id)); .where(eq(follows.followerId, user.id));
const followingIds = followRows.map(row => row.followingId); const followingIds = followRows.map(row => row.followingId);
// Include own posts + posts from followed users // Include own posts + posts from followed users
const allowedUserIds = [user.id, ...followingIds]; const allowedUserIds = [user.id, ...followingIds];
// Build where condition with cursor support // Build where condition with cursor support
let whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds)); let whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds));
if (cursor) { if (cursor) {
const cursorPost = await db.query.posts.findFirst({ const cursorPost = await db.query.posts.findFirst({
where: eq(posts.id, cursor), where: eq(posts.id, cursor),
@@ -671,7 +672,7 @@ export async function GET(request: Request) {
if (followedRemoteUsers.length > 0) { if (followedRemoteUsers.length > 0) {
const { fetchSwarmUserProfile, isSwarmNode } = await import('@/lib/swarm/interactions'); const { fetchSwarmUserProfile, isSwarmNode } = await import('@/lib/swarm/interactions');
const { resolveRemoteUser } = await import('@/lib/activitypub/fetch'); const { resolveRemoteUser } = await import('@/lib/activitypub/fetch');
// Wrap each fetch with a timeout to prevent slow nodes from blocking // Wrap each fetch with a timeout to prevent slow nodes from blocking
const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T | null> => { const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T | null> => {
return Promise.race([ return Promise.race([
@@ -679,25 +680,25 @@ export async function GET(request: Request) {
new Promise<null>((resolve) => setTimeout(() => resolve(null), ms)) new Promise<null>((resolve) => setTimeout(() => resolve(null), ms))
]); ]);
}; };
const fetchPromises = followedRemoteUsers.map(async (follow) => { const fetchPromises = followedRemoteUsers.map(async (follow) => {
try { try {
const atIndex = follow.targetHandle.lastIndexOf('@'); const atIndex = follow.targetHandle.lastIndexOf('@');
if (atIndex === -1) return []; if (atIndex === -1) return [];
const handle = follow.targetHandle.slice(0, atIndex); const handle = follow.targetHandle.slice(0, atIndex);
const domain = follow.targetHandle.slice(atIndex + 1); const domain = follow.targetHandle.slice(atIndex + 1);
// Check if swarm node - use swarm API (faster) // Check if swarm node - use swarm API (faster)
const isSwarm = await isSwarmNode(domain); const isSwarm = await isSwarmNode(domain);
if (isSwarm) { if (isSwarm) {
const profileData = await withTimeout( const profileData = await withTimeout(
fetchSwarmUserProfile(handle, domain, limit), fetchSwarmUserProfile(handle, domain, limit),
5000 // 5s timeout per node 5000 // 5s timeout per node
); );
if (!profileData?.posts) return []; if (!profileData?.posts) return [];
return profileData.posts.map(post => ({ return profileData.posts.map(post => ({
id: `swarm:${domain}:${post.id}`, id: `swarm:${domain}:${post.id}`,
content: post.content, content: post.content,
@@ -717,6 +718,7 @@ export async function GET(request: Request) {
displayName: follow.displayName || profileData.profile?.displayName || handle, displayName: follow.displayName || profileData.profile?.displayName || handle,
avatarUrl: follow.avatarUrl || profileData.profile?.avatarUrl, avatarUrl: follow.avatarUrl || profileData.profile?.avatarUrl,
isRemote: true, isRemote: true,
isBot: profileData.profile?.isBot,
}, },
media: post.media?.map((m: any, idx: number) => ({ media: post.media?.map((m: any, idx: number) => ({
id: `swarm:${domain}:${post.id}:media:${idx}`, id: `swarm:${domain}:${post.id}:media:${idx}`,
@@ -729,14 +731,14 @@ export async function GET(request: Request) {
// ActivityPub - fetch from outbox // ActivityPub - fetch from outbox
const remoteProfile = await resolveRemoteUser(handle, domain); const remoteProfile = await resolveRemoteUser(handle, domain);
if (!remoteProfile?.outbox) return []; if (!remoteProfile?.outbox) return [];
// For AP, fall back to cached posts (live outbox fetch is slower) // For AP, fall back to cached posts (live outbox fetch is slower)
const cachedPosts = await db.query.remotePosts.findMany({ const cachedPosts = await db.query.remotePosts.findMany({
where: eq(remotePosts.authorHandle, follow.targetHandle), where: eq(remotePosts.authorHandle, follow.targetHandle),
orderBy: [desc(remotePosts.publishedAt)], orderBy: [desc(remotePosts.publishedAt)],
limit: limit, limit: limit,
}); });
return transformRemotePosts(cachedPosts); return transformRemotePosts(cachedPosts);
} }
} catch (error) { } catch (error) {
@@ -744,7 +746,7 @@ export async function GET(request: Request) {
return []; return [];
} }
}); });
const results = await Promise.all(fetchPromises); const results = await Promise.all(fetchPromises);
liveRemotePosts = results.flat(); liveRemotePosts = results.flat();
} }
@@ -781,11 +783,11 @@ export async function GET(request: Request) {
if (session?.user && feedPosts && feedPosts.length > 0) { if (session?.user && feedPosts && feedPosts.length > 0) {
const viewer = session.user; const viewer = session.user;
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
// Separate local and swarm posts // Separate local and swarm posts
const localPostIds: string[] = []; const localPostIds: string[] = [];
const swarmPosts: Array<{ id: string; domain: string; originalId: string }> = []; const swarmPosts: Array<{ id: string; domain: string; originalId: string }> = [];
for (const p of feedPosts as Array<{ id: string }>) { for (const p of feedPosts as Array<{ id: string }>) {
if (p.id.startsWith('swarm:')) { if (p.id.startsWith('swarm:')) {
const parts = p.id.split(':'); const parts = p.id.split(':');
@@ -804,7 +806,7 @@ export async function GET(request: Request) {
// Check local likes // Check local likes
const likedPostIds = new Set<string>(); const likedPostIds = new Set<string>();
const repostedPostIds = new Set<string>(); const repostedPostIds = new Set<string>();
if (localPostIds.length > 0) { if (localPostIds.length > 0) {
const viewerLikes = await db.query.likes.findMany({ const viewerLikes = await db.query.likes.findMany({
where: and( where: and(
@@ -829,12 +831,12 @@ export async function GET(request: Request) {
try { try {
const protocol = sp.domain.includes('localhost') ? 'http' : 'https'; 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 url = `${protocol}://${sp.domain}/api/swarm/posts/${sp.originalId}/likes?checkHandle=${viewer.handle}&checkDomain=${nodeDomain}`;
const res = await fetch(url, { const res = await fetch(url, {
headers: { 'Accept': 'application/json' }, headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(3000), signal: AbortSignal.timeout(3000),
}); });
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
if (data.isLiked) { if (data.isLiked) {
@@ -845,7 +847,7 @@ export async function GET(request: Request) {
// Timeout or error - just skip // Timeout or error - just skip
} }
}); });
await Promise.all(checkPromises); await Promise.all(checkPromises);
} }
+3 -2
View File
@@ -18,7 +18,8 @@ export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const refresh = searchParams.get('refresh') === 'true'; const refresh = searchParams.get('refresh') === 'true';
const cursor = searchParams.get('cursor') || undefined;
// Check user's NSFW preference // Check user's NSFW preference
let includeNsfw = false; let includeNsfw = false;
try { try {
@@ -29,7 +30,7 @@ export async function GET(request: NextRequest) {
} }
// Fetch swarm timeline (no caching - user preferences vary) // 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({ return NextResponse.json({
posts: timeline.posts, posts: timeline.posts,
+25 -9
View File
@@ -6,7 +6,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, media, nodes } from '@/db'; 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 { export interface SwarmPost {
id: string; id: string;
@@ -17,6 +17,7 @@ export interface SwarmPost {
displayName: string; displayName: string;
avatarUrl?: string; avatarUrl?: string;
isNsfw: boolean; isNsfw: boolean;
isBot?: boolean;
}; };
nodeDomain: string; nodeDomain: string;
nodeIsNsfw: boolean; nodeIsNsfw: boolean;
@@ -43,18 +44,36 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50); const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
const cursor = searchParams.get('cursor');
if (!db) { if (!db) {
return NextResponse.json({ posts: [], nodeDomain: '', nodeIsNsfw: false }); return NextResponse.json({ posts: [], nodeDomain: '', nodeIsNsfw: false });
} }
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost'; const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
// Get node NSFW status // Get node NSFW status
const node = await db.query.nodes.findFirst({ const node = await db.query.nodes.findFirst({
where: eq(nodes.domain, nodeDomain), where: eq(nodes.domain, nodeDomain),
}); });
const nodeIsNsfw = node?.isNsfw ?? false; 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) // Get recent public posts (not replies, local users only, not removed)
const recentPosts = await db const recentPosts = await db
.select({ .select({
@@ -73,22 +92,18 @@ export async function GET(request: NextRequest) {
authorDisplayName: users.displayName, authorDisplayName: users.displayName,
authorAvatarUrl: users.avatarUrl, authorAvatarUrl: users.avatarUrl,
authorIsNsfw: users.isNsfw, authorIsNsfw: users.isNsfw,
authorIsBot: users.isBot,
authorNodeId: users.nodeId, authorNodeId: users.nodeId,
}) })
.from(posts) .from(posts)
.innerJoin(users, eq(posts.userId, users.id)) .innerJoin(users, eq(posts.userId, users.id))
.where( .where(whereCondition)
and(
isNull(posts.replyToId), // Not a reply
eq(posts.isRemoved, false) // Not removed
)
)
.orderBy(desc(posts.createdAt)) .orderBy(desc(posts.createdAt))
.limit(limit); .limit(limit);
// Fetch media for each post // Fetch media for each post
const swarmPosts: SwarmPost[] = []; const swarmPosts: SwarmPost[] = [];
for (const post of recentPosts) { for (const post of recentPosts) {
const postMedia = await db const postMedia = await db
.select({ url: media.url, mimeType: media.mimeType, altText: media.altText }) .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, displayName: post.authorDisplayName || post.authorHandle,
avatarUrl: post.authorAvatarUrl || undefined, avatarUrl: post.authorAvatarUrl || undefined,
isNsfw: post.authorIsNsfw, isNsfw: post.authorIsNsfw,
isBot: post.authorIsBot,
}, },
nodeDomain, nodeDomain,
nodeIsNsfw, nodeIsNsfw,
@@ -7,6 +7,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db, follows, users, remoteFollowers } from '@/db'; import { db, follows, users, remoteFollowers } from '@/db';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { hydrateSwarmUsers } from '@/lib/swarm/user-hydration';
export interface SwarmFollowerUser { export interface SwarmFollowerUser {
handle: string; handle: string;
@@ -88,8 +89,33 @@ export async function GET(request: NextRequest, context: RouteContext) {
// Merge all followers // Merge all followers
const allFollowers = [...localFollowers, ...remoteFollowersList].slice(0, limit); 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({ return NextResponse.json({
followers: allFollowers, followers: finalFollowers,
nodeDomain, nodeDomain,
timestamp: new Date().toISOString(), 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 // Check if this is a remote user
const [remoteHandle, remoteDomain] = cleanHandle.split('@'); const [remoteHandle, remoteDomain] = cleanHandle.split('@');
if (remoteDomain) { if (remoteDomain) {
// Fetch from remote swarm node // Fetch from remote swarm node
const swarmData = await fetchSwarmFollowers(remoteHandle, remoteDomain, limit); 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)) .where(eq(follows.followingId, user.id))
.limit(limit); .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({ return NextResponse.json({
followers: userFollowers.map(f => ({ followers: allFollowers,
id: f.follower.id,
handle: f.follower.handle,
displayName: f.follower.displayName,
avatarUrl: f.follower.avatarUrl,
bio: f.follower.bio,
isBot: f.follower.isBot,
})),
nextCursor: userFollowers.length === limit ? userFollowers[userFollowers.length - 1]?.id : null, nextCursor: userFollowers.length === limit ? userFollowers[userFollowers.length - 1]?.id : null,
}); });
} catch (error) { } catch (error) {
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db, follows, users, remoteFollows } from '@/db'; import { db, follows, users, remoteFollows } from '@/db';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { hydrateSwarmUsers } from '@/lib/swarm/user-hydration';
type RouteContext = { params: Promise<{ handle: string }> }; 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 // Check if this is a remote user
const [remoteHandle, remoteDomain] = cleanHandle.split('@'); const [remoteHandle, remoteDomain] = cleanHandle.split('@');
if (remoteDomain) { if (remoteDomain) {
// Fetch from remote swarm node // Fetch from remote swarm node
const swarmData = await fetchSwarmFollowing(remoteHandle, remoteDomain, limit); const swarmData = await fetchSwarmFollowing(remoteHandle, remoteDomain, limit);
@@ -108,8 +109,11 @@ export async function GET(request: Request, context: RouteContext) {
// Merge and return // Merge and return
const allFollowing = [...localFollowing, ...remoteFollowing].slice(0, limit); const allFollowing = [...localFollowing, ...remoteFollowing].slice(0, limit);
// Hydrate remote users with fresh data from swarm
const hydratedFollowing = await hydrateSwarmUsers(allFollowing);
return NextResponse.json({ return NextResponse.json({
following: allFollowing, following: hydratedFollowing,
nextCursor: allFollowing.length === limit ? allFollowing[allFollowing.length - 1]?.id : null, nextCursor: allFollowing.length === limit ? allFollowing[allFollowing.length - 1]?.id : null,
}); });
} catch (error) { } catch (error) {
+107 -9
View File
@@ -34,15 +34,15 @@ function UserCard({ user }: { user: User }) {
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span className="user-card-name">{user.displayName || user.handle}</span> <span className="user-card-name">{user.displayName || user.handle}</span>
{user.isBot && ( {user.isBot && (
<span <span
style={{ style={{
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
gap: '3px', gap: '3px',
fontSize: '10px', fontSize: '10px',
padding: '2px 6px', padding: '2px 6px',
borderRadius: '4px', borderRadius: '4px',
background: 'var(--accent-muted)', background: 'var(--accent-muted)',
color: 'var(--accent)', color: 'var(--accent)',
fontWeight: 500, fontWeight: 500,
}} }}
@@ -90,6 +90,11 @@ export default function ExplorePage() {
const [searchResults, setSearchResults] = useState<{ posts: Post[]; users: User[] }>({ posts: [], users: [] }); const [searchResults, setSearchResults] = useState<{ posts: Post[]; users: User[] }>({ posts: [], users: [] });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [nodeCursor, setNodeCursor] = useState<string | null>(null);
const [swarmCursor, setSwarmCursor] = useState<string | null>(null);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMoreNode, setHasMoreNode] = useState(true);
const [hasMoreSwarm, setHasMoreSwarm] = useState(true);
const [isNsfwNode, setIsNsfwNode] = useState(false); const [isNsfwNode, setIsNsfwNode] = useState(false);
// Fetch node info to check if NSFW // Fetch node info to check if NSFW
@@ -99,7 +104,7 @@ export default function ExplorePage() {
.then(data => { .then(data => {
setIsNsfwNode(data.isNsfw || false); setIsNsfwNode(data.isNsfw || false);
}) })
.catch(() => {}); .catch(() => { });
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -110,6 +115,8 @@ export default function ExplorePage() {
const res = await fetch('/api/posts?type=local&limit=20'); const res = await fetch('/api/posts?type=local&limit=20');
const data = await res.json(); const data = await res.json();
setNodePosts(data.posts || []); setNodePosts(data.posts || []);
setNodeCursor(data.nextCursor || null);
setHasMoreNode(!!data.nextCursor);
} catch { } catch {
setNodePosts([]); setNodePosts([]);
} finally { } finally {
@@ -117,8 +124,10 @@ export default function ExplorePage() {
} }
}; };
loadNodePosts(); if (activeTab === 'node' && nodePosts.length === 0) {
}, []); loadNodePosts();
}
}, [activeTab, nodePosts.length]);
useEffect(() => { useEffect(() => {
// Load swarm posts when tab changes // Load swarm posts when tab changes
@@ -130,6 +139,15 @@ export default function ExplorePage() {
const data = await res.json(); const data = await res.json();
setSwarmPosts(data.posts || []); setSwarmPosts(data.posts || []);
setSwarmSources(data.sources || []); setSwarmSources(data.sources || []);
// Set cursor from the last post if available
if (data.posts && data.posts.length > 0) {
const lastPost = data.posts[data.posts.length - 1];
setSwarmCursor(lastPost.createdAt); // Use timestamp as cursor
setHasMoreSwarm(true);
} else {
setHasMoreSwarm(false);
}
} catch { } catch {
setSwarmPosts([]); setSwarmPosts([]);
} finally { } finally {
@@ -140,6 +158,74 @@ export default function ExplorePage() {
} }
}, [activeTab, swarmPosts.length]); }, [activeTab, swarmPosts.length]);
// Load more node posts
const loadMoreNode = async () => {
if (!nodeCursor || loadingMore || !hasMoreNode) return;
setLoadingMore(true);
try {
const res = await fetch(`/api/posts?type=local&limit=20&cursor=${nodeCursor}`);
const data = await res.json();
if (data.posts && data.posts.length > 0) {
setNodePosts(prev => [...prev, ...data.posts]);
setNodeCursor(data.nextCursor || null);
setHasMoreNode(!!data.nextCursor);
} else {
setHasMoreNode(false);
}
} catch {
// Error loading more
} finally {
setLoadingMore(false);
}
};
// Load more swarm posts
const loadMoreSwarm = async () => {
if (!swarmCursor || loadingMore || !hasMoreSwarm) return;
setLoadingMore(true);
try {
// Use timestamp of last post as cursor
const res = await fetch(`/api/posts/swarm?limit=20&cursor=${encodeURIComponent(swarmCursor)}`);
const data = await res.json();
if (data.posts && data.posts.length > 0) {
setSwarmPosts(prev => [...prev, ...data.posts]);
const lastPost = data.posts[data.posts.length - 1];
setSwarmCursor(lastPost.createdAt);
setHasMoreSwarm(true);
} else {
setHasMoreSwarm(false);
}
} catch {
// Error loading more
} finally {
setLoadingMore(false);
}
};
// Intersection Observer for Infinite Scroll
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
if (activeTab === 'node') {
loadMoreNode();
} else if (activeTab === 'swarm') {
loadMoreSwarm();
}
}
},
{ threshold: 0.5 }
);
const sentinel = document.getElementById('scroll-sentinel');
if (sentinel) {
observer.observe(sentinel);
}
return () => observer.disconnect();
}, [activeTab, nodeCursor, swarmCursor, loadingMore, hasMoreNode, hasMoreSwarm]);
useEffect(() => { useEffect(() => {
// Load users when tab changes to users // Load users when tab changes to users
if (activeTab === 'users' && users.length === 0) { if (activeTab === 'users' && users.length === 0) {
@@ -278,6 +364,12 @@ export default function ExplorePage() {
{nodePosts.map((post) => ( {nodePosts.map((post) => (
<PostCard key={post.id} post={post} onLike={handleLike} onRepost={handleRepost} onDelete={handleDelete} /> <PostCard key={post.id} post={post} onLike={handleLike} onRepost={handleRepost} onDelete={handleDelete} />
))} ))}
{/* Sentinel for Infinite Scroll */}
{hasMoreNode && (
<div id="scroll-sentinel" style={{ height: '20px', margin: '20px 0', textAlign: 'center', opacity: 0.5 }}>
{loadingMore ? 'Loading more...' : ''}
</div>
)}
</div> </div>
</> </>
) )
@@ -342,6 +434,12 @@ export default function ExplorePage() {
/> />
); );
})} })}
{/* Sentinel for Infinite Scroll */}
{hasMoreSwarm && (
<div id="scroll-sentinel" style={{ height: '20px', margin: '20px 0', textAlign: 'center', opacity: 0.5 }}>
{loadingMore ? 'Loading more...' : ''}
</div>
)}
</div> </div>
</> </>
) )
+28 -10
View File
@@ -13,16 +13,34 @@ const sairaCondensed = Saira_Condensed({
variable: "--font-saira", variable: "--font-saira",
}); });
export const metadata: Metadata = { import { db } from "@/db";
title: "Synapsis",
description: "Synapsis is designed to function like a global signal layer rather than a culture-bound platform. Anyone can run their own node and still participate in a shared, interconnected network, with global identity, clean terminology, and a modern interface that feels current rather than experimental.", export async function generateMetadata(): Promise<Metadata> {
manifest: "/manifest.json", let title = "Synapsis";
icons: {
icon: "/api/favicon", try {
}, const node = await db.query.nodes.findFirst();
themeColor: "#0a0a0a", if (node?.name) {
viewport: "width=device-width, initial-scale=1, maximum-scale=1", title = node.name;
}; }
} catch (e) {
console.error("Failed to fetch node info for metadata", e);
}
return {
title: {
default: title,
template: `%s | ${title}`,
},
description: "Synapsis is designed to function like a global signal layer rather than a culture-bound platform. Anyone can run their own node and still participate in a shared, interconnected network, with global identity, clean terminology, and a modern interface that feels current rather than experimental.",
manifest: "/manifest.json",
icons: {
icon: "/api/favicon",
},
themeColor: "#0a0a0a",
viewport: "width=device-width, initial-scale=1, maximum-scale=1",
};
}
// Force all routes to be dynamic (no static generation at build time) // Force all routes to be dynamic (no static generation at build time)
// This is appropriate for a social network where all content is user-generated // This is appropriate for a social network where all content is user-generated
+2 -2
View File
@@ -187,7 +187,7 @@ function NotificationItem({
> >
<Link href={actor ? `/@${actor.handle}` : '#'} style={{ flexShrink: 0 }}> <Link href={actor ? `/@${actor.handle}` : '#'} style={{ flexShrink: 0 }}>
{actor?.avatarUrl ? ( {actor?.avatarUrl ? (
<Image <img
src={actor.avatarUrl} src={actor.avatarUrl}
alt={actor.displayName || actor.handle} alt={actor.displayName || actor.handle}
width={40} width={40}
@@ -220,7 +220,7 @@ function NotificationItem({
href={actor ? `/@${actor.handle}` : '#'} href={actor ? `/@${actor.handle}` : '#'}
style={{ fontWeight: 600, color: 'var(--foreground)', textDecoration: 'none' }} style={{ fontWeight: 600, color: 'var(--foreground)', textDecoration: 'none' }}
> >
{actor?.displayName || actor?.handle || 'Someone'} {actor?.displayName || actor?.handle || 'Someone'} <span style={{ fontWeight: 400, color: 'var(--foreground-tertiary)' }}>@{actor?.handle}</span>
</Link> </Link>
<span style={{ color: 'var(--foreground-secondary)' }}> <span style={{ color: 'var(--foreground-secondary)' }}>
{getNotificationText(notification)} {getNotificationText(notification)}
+7 -4
View File
@@ -441,7 +441,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
<Link href={`/${profileHandle}`} className="post-handle" onClick={(e) => e.stopPropagation()}> <Link href={`/${profileHandle}`} className="post-handle" onClick={(e) => e.stopPropagation()}>
{post.author.displayName || post.author.handle} {post.author.displayName || post.author.handle}
</Link> </Link>
{post.bot && ( {(post.bot || post.author.isBot) && (
<span <span
style={{ style={{
display: 'inline-flex', display: 'inline-flex',
@@ -454,7 +454,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
color: 'var(--accent)', color: 'var(--accent)',
fontWeight: 500, fontWeight: 500,
}} }}
title={`AI Account: ${post.bot.name}`} title={post.bot ? `AI Account: ${post.bot.name}` : `AI Account: ${post.author.displayName || post.author.handle}`}
> >
<Bot size={12} /> <Bot size={12} />
AI Account AI Account
@@ -663,8 +663,11 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
currentUser.id === post.author.id || currentUser.id === post.author.id ||
(post.bot && currentUser.id === post.bot.ownerId) || (post.bot && currentUser.id === post.bot.ownerId) ||
(parentPostAuthorId && currentUser.id === parentPostAuthorId) || (parentPostAuthorId && currentUser.id === parentPostAuthorId) ||
// Allow deleting own remote posts (where ID format differs but handle matches) // Allow deleting own remote posts where handle might be username@node_domain
(post.author.id.startsWith('swarm:') && post.author.handle === currentUser.handle) (post.author.id.startsWith('swarm:') && (
post.author.handle === currentUser.handle ||
post.author.handle === `${currentUser.handle}@${NODE_DOMAIN}`
))
)) && ( )) && (
<button className="post-action delete-action" onClick={handleDelete} disabled={deleting} title="Delete post"> <button className="post-action delete-action" onClick={handleDelete} disabled={deleting} title="Delete post">
<TrashIcon /> <TrashIcon />
+25 -23
View File
@@ -15,6 +15,7 @@ interface TimelineResult {
interface TimelineOptions { interface TimelineOptions {
includeNsfw?: boolean; // Whether to include NSFW content includeNsfw?: boolean; // Whether to include NSFW content
cursor?: string; // Timestamp cursor for pagination
} }
/** /**
@@ -40,19 +41,19 @@ async function fetchLinkPreview(url: string): Promise<{
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost'; const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
const protocol = nodeDomain === 'localhost' ? 'http' : 'https'; const protocol = nodeDomain === 'localhost' ? 'http' : 'https';
const previewUrl = `${protocol}://${nodeDomain}/api/media/preview?url=${encodeURIComponent(url)}`; const previewUrl = `${protocol}://${nodeDomain}/api/media/preview?url=${encodeURIComponent(url)}`;
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000); // 3s timeout for previews const timeout = setTimeout(() => controller.abort(), 3000); // 3s timeout for previews
const response = await fetch(previewUrl, { const response = await fetch(previewUrl, {
headers: { 'Accept': 'application/json' }, headers: { 'Accept': 'application/json' },
signal: controller.signal, signal: controller.signal,
}); });
clearTimeout(timeout); clearTimeout(timeout);
if (!response.ok) return null; if (!response.ok) return null;
const data = await response.json(); const data = await response.json();
return { return {
url: data.url || url, url: data.url || url,
@@ -72,18 +73,18 @@ async function enrichPostsWithPreviews(posts: SwarmPost[]): Promise<SwarmPost[]>
const enrichmentPromises = posts.map(async (post) => { const enrichmentPromises = posts.map(async (post) => {
// Skip if already has link preview data // Skip if already has link preview data
if (post.linkPreviewUrl) return post; if (post.linkPreviewUrl) return post;
// Extract URL from content // Extract URL from content
const url = extractFirstUrl(post.content); const url = extractFirstUrl(post.content);
if (!url) return post; if (!url) return post;
// Skip video URLs (handled by VideoEmbed component) // Skip video URLs (handled by VideoEmbed component)
if (url.match(/(youtube\.com|youtu\.be|vimeo\.com)/)) return post; if (url.match(/(youtube\.com|youtu\.be|vimeo\.com)/)) return post;
// Fetch preview // Fetch preview
const preview = await fetchLinkPreview(url); const preview = await fetchLinkPreview(url);
if (!preview) return post; if (!preview) return post;
return { return {
...post, ...post,
linkPreviewUrl: preview.url, linkPreviewUrl: preview.url,
@@ -92,7 +93,7 @@ async function enrichPostsWithPreviews(posts: SwarmPost[]): Promise<SwarmPost[]>
linkPreviewImage: preview.image || undefined, linkPreviewImage: preview.image || undefined,
}; };
}); });
return Promise.all(enrichmentPromises); return Promise.all(enrichmentPromises);
} }
@@ -101,7 +102,8 @@ async function enrichPostsWithPreviews(posts: SwarmPost[]): Promise<SwarmPost[]>
*/ */
async function fetchNodeTimeline( async function fetchNodeTimeline(
domain: string, domain: string,
limit: number = 20 limit: number = 20,
cursor?: string
): Promise<{ posts: SwarmPost[]; nodeIsNsfw?: boolean; error?: string }> { ): Promise<{ posts: SwarmPost[]; nodeIsNsfw?: boolean; error?: string }> {
try { try {
// Determine protocol - use http for localhost, https for everything else // Determine protocol - use http for localhost, https for everything else
@@ -113,7 +115,7 @@ async function fetchNodeTimeline(
} else { } else {
baseUrl = `https://${domain}`; baseUrl = `https://${domain}`;
} }
const url = `${baseUrl}/api/swarm/timeline?limit=${limit}`; const url = `${baseUrl}/api/swarm/timeline?limit=${limit}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000); // 5s timeout const timeout = setTimeout(() => controller.abort(), 5000); // 5s timeout
@@ -148,14 +150,14 @@ export async function fetchSwarmTimeline(
postsPerNode: number = 10, postsPerNode: number = 10,
options: TimelineOptions = {} options: TimelineOptions = {}
): Promise<TimelineResult> { ): Promise<TimelineResult> {
const { includeNsfw = false } = options; const { includeNsfw = false, cursor } = options;
// Get active nodes to query // Get active nodes to query
const nodes = await getActiveSwarmNodes(maxNodes); const nodes = await getActiveSwarmNodes(maxNodes);
// Always include our own posts // Always include our own posts
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost'; const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
// Always query all nodes - we filter posts, not nodes // Always query all nodes - we filter posts, not nodes
const nodesToQuery = [ const nodesToQuery = [
ourDomain, ourDomain,
@@ -163,12 +165,12 @@ export async function fetchSwarmTimeline(
].slice(0, maxNodes); ].slice(0, maxNodes);
console.log(`[Swarm Timeline] Querying ${nodesToQuery.length} nodes: ${nodesToQuery.join(', ')}`); console.log(`[Swarm Timeline] Querying ${nodesToQuery.length} nodes: ${nodesToQuery.join(', ')}`);
console.log(`[Swarm Timeline] includeNsfw: ${includeNsfw}`); console.log(`[Swarm Timeline] includeNsfw: ${includeNsfw}, cursor: ${cursor || 'none'}`);
// Fetch from all nodes in parallel // Fetch from all nodes in parallel
const results = await Promise.all( const results = await Promise.all(
nodesToQuery.map(async (domain) => { nodesToQuery.map(async (domain) => {
const result = await fetchNodeTimeline(domain, postsPerNode); const result = await fetchNodeTimeline(domain, postsPerNode, cursor);
return { return {
domain, domain,
...result, ...result,
@@ -183,17 +185,17 @@ export async function fetchSwarmTimeline(
for (const result of results) { for (const result of results) {
// Filter NSFW posts only if user doesn't want NSFW content // Filter NSFW posts only if user doesn't want NSFW content
// A post is NSFW if it's explicitly marked OR comes from an NSFW node // A post is NSFW if it's explicitly marked OR comes from an NSFW node
const filteredPosts = includeNsfw const filteredPosts = includeNsfw
? result.posts ? result.posts
: result.posts.filter(p => !p.isNsfw && !p.nodeIsNsfw); : result.posts.filter(p => !p.isNsfw && !p.nodeIsNsfw);
// Log filtering details for debugging // Log filtering details for debugging
if (!includeNsfw && result.posts.length > 0) { if (!includeNsfw && result.posts.length > 0) {
const nsfwPosts = result.posts.filter(p => p.isNsfw); const nsfwPosts = result.posts.filter(p => p.isNsfw);
const nodeNsfwPosts = result.posts.filter(p => p.nodeIsNsfw); const nodeNsfwPosts = result.posts.filter(p => p.nodeIsNsfw);
console.log(`[Swarm Timeline] ${result.domain}: ${result.posts.length} posts, ${nsfwPosts.length} marked NSFW, ${nodeNsfwPosts.length} from NSFW node, ${filteredPosts.length} after filter`); console.log(`[Swarm Timeline] ${result.domain}: ${result.posts.length} posts, ${nsfwPosts.length} marked NSFW, ${nodeNsfwPosts.length} from NSFW node, ${filteredPosts.length} after filter`);
} }
sources.push({ sources.push({
domain: result.domain, domain: result.domain,
postCount: result.posts.length, postCount: result.posts.length,
@@ -201,7 +203,7 @@ export async function fetchSwarmTimeline(
isNsfw: result.nodeIsNsfw, isNsfw: result.nodeIsNsfw,
error: result.error, error: result.error,
}); });
allPosts.push(...filteredPosts); allPosts.push(...filteredPosts);
} }
+97
View File
@@ -0,0 +1,97 @@
import { fetchSwarmUserProfile } from './interactions';
export interface HydratedUser {
id: string; // The ID used in the list (usually handle or handle@domain)
handle: string;
displayName: string | null;
avatarUrl?: string | null;
bio?: string | null;
isBot?: boolean;
isRemote: boolean;
nodeDomain?: string; // For remote users
}
/**
* Hydrates a list of users with fresh profile data from Swarm nodes.
* Used for followers/following lists to ensure remote users have up-to-date info.
*
* @param users List of partial user objects
* @returns List of users with potentially updated profile data
*/
export async function hydrateSwarmUsers(
users: {
id: string;
handle: string;
displayName?: string | null;
avatarUrl?: string | null;
bio?: string | null;
isRemote: boolean;
isBot?: boolean;
}[]
): Promise<HydratedUser[]> {
const needsHydration = users.filter(u => u.isRemote);
if (needsHydration.length === 0) {
return users.map(u => ({
...u,
displayName: u.displayName || u.handle.split('@')[0],
}));
}
// Group by domain to potentially batch (though fetchSwarmUserProfile is individual for now)
// We'll just run them concurrently with a limit
const hydratedMap = new Map<string, Partial<HydratedUser>>();
// Create a promise for each remote user
const promises = needsHydration.map(async (user) => {
try {
// Parse handle and domain
// Handle format for remote users in lists is usually "user@domain.com"
const parts = user.handle.split('@');
if (parts.length !== 2) return; // Should be user@domain
const handle = parts[0];
const domain = parts[1];
// Fetch profile
// We set a small timeout in fetchSwarmUserProfile (10s), but we might want shorter for lists?
// standard fetchSwarmUserProfile uses 10s. Let's stick with that for now or rely on the fact
// api routes have their own timeouts.
const response = await fetchSwarmUserProfile(handle, domain, 0); // 0 limit as we only want profile
if (response && response.profile) {
hydratedMap.set(user.id, {
displayName: response.profile.displayName,
avatarUrl: response.profile.avatarUrl,
bio: response.profile.bio,
isBot: response.profile.isBot,
nodeDomain: response.nodeDomain,
});
}
} catch (e) {
// Just ignore failures and keep original data
console.warn(`Failed to hydrate user ${user.handle}:`, e);
}
});
// Run all (or batch if list is huge, but pagination limits to 20-50 usually)
await Promise.allSettled(promises);
// Merge results
return users.map(user => {
const freshdiv = hydratedMap.get(user.id);
if (freshdiv) {
return {
...user,
...freshdiv,
// Ensure display name fallback
displayName: freshdiv.displayName || user.displayName || user.handle.split('@')[0],
};
}
return {
...user,
displayName: user.displayName || user.handle.split('@')[0],
};
});
}