feat: Implement dynamic node title metadata and enhance swarm user and post hydration.
This commit is contained in:
+25
-23
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user