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
+25 -23
View File
@@ -15,6 +15,7 @@ interface TimelineResult {
interface TimelineOptions {
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 protocol = nodeDomain === 'localhost' ? 'http' : 'https';
const previewUrl = `${protocol}://${nodeDomain}/api/media/preview?url=${encodeURIComponent(url)}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000); // 3s timeout for previews
const response = await fetch(previewUrl, {
headers: { 'Accept': 'application/json' },
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) return null;
const data = await response.json();
return {
url: data.url || url,
@@ -72,18 +73,18 @@ async function enrichPostsWithPreviews(posts: SwarmPost[]): Promise<SwarmPost[]>
const enrichmentPromises = posts.map(async (post) => {
// Skip if already has link preview data
if (post.linkPreviewUrl) return post;
// Extract URL from content
const url = extractFirstUrl(post.content);
if (!url) return post;
// Skip video URLs (handled by VideoEmbed component)
if (url.match(/(youtube\.com|youtu\.be|vimeo\.com)/)) return post;
// Fetch preview
const preview = await fetchLinkPreview(url);
if (!preview) return post;
return {
...post,
linkPreviewUrl: preview.url,
@@ -92,7 +93,7 @@ async function enrichPostsWithPreviews(posts: SwarmPost[]): Promise<SwarmPost[]>
linkPreviewImage: preview.image || undefined,
};
});
return Promise.all(enrichmentPromises);
}
@@ -101,7 +102,8 @@ async function enrichPostsWithPreviews(posts: SwarmPost[]): Promise<SwarmPost[]>
*/
async function fetchNodeTimeline(
domain: string,
limit: number = 20
limit: number = 20,
cursor?: string
): Promise<{ posts: SwarmPost[]; nodeIsNsfw?: boolean; error?: string }> {
try {
// Determine protocol - use http for localhost, https for everything else
@@ -113,7 +115,7 @@ async function fetchNodeTimeline(
} else {
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 timeout = setTimeout(() => controller.abort(), 5000); // 5s timeout
@@ -148,14 +150,14 @@ export async function fetchSwarmTimeline(
postsPerNode: number = 10,
options: TimelineOptions = {}
): Promise<TimelineResult> {
const { includeNsfw = false } = options;
const { includeNsfw = false, cursor } = options;
// Get active nodes to query
const nodes = await getActiveSwarmNodes(maxNodes);
// Always include our own posts
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
// Always query all nodes - we filter posts, not nodes
const nodesToQuery = [
ourDomain,
@@ -163,12 +165,12 @@ export async function fetchSwarmTimeline(
].slice(0, maxNodes);
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
const results = await Promise.all(
nodesToQuery.map(async (domain) => {
const result = await fetchNodeTimeline(domain, postsPerNode);
const result = await fetchNodeTimeline(domain, postsPerNode, cursor);
return {
domain,
...result,
@@ -183,17 +185,17 @@ export async function fetchSwarmTimeline(
for (const result of results) {
// 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
const filteredPosts = includeNsfw
? result.posts
const filteredPosts = includeNsfw
? result.posts
: result.posts.filter(p => !p.isNsfw && !p.nodeIsNsfw);
// Log filtering details for debugging
if (!includeNsfw && result.posts.length > 0) {
const nsfwPosts = result.posts.filter(p => p.isNsfw);
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`);
}
sources.push({
domain: result.domain,
postCount: result.posts.length,
@@ -201,7 +203,7 @@ export async function fetchSwarmTimeline(
isNsfw: result.nodeIsNsfw,
error: result.error,
});
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],
};
});
}