diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 31a9cd6..8bb9605 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -329,17 +329,20 @@ export async function GET(request: Request) { } else if (type === 'curated') { // Curated feed - swarm posts only (no fediverse) let viewer = null; + let includeNsfw = false; try { const { getSession } = await import('@/lib/auth'); const session = await getSession(); viewer = session?.user || null; + includeNsfw = session?.user?.nsfwEnabled ?? false; } catch { viewer = null; + includeNsfw = false; } - // Fetch swarm posts + // Fetch swarm posts with user's NSFW preference const { fetchSwarmTimeline } = await import('@/lib/swarm/timeline'); - const swarmResult = await fetchSwarmTimeline(10, 30); + const swarmResult = await fetchSwarmTimeline(10, 30, { includeNsfw }); // Transform swarm posts to match local post format const swarmPosts = swarmResult.posts.map(sp => ({ diff --git a/src/app/api/posts/swarm/route.ts b/src/app/api/posts/swarm/route.ts index 7f2c74c..a87c371 100644 --- a/src/app/api/posts/swarm/route.ts +++ b/src/app/api/posts/swarm/route.ts @@ -6,41 +6,30 @@ import { NextRequest, NextResponse } from 'next/server'; import { fetchSwarmTimeline } from '@/lib/swarm/timeline'; - -// Simple in-memory cache for swarm timeline -let cachedTimeline: Awaited> | null = null; -let cacheTimestamp = 0; -const CACHE_TTL_MS = 60 * 1000; // 1 minute cache +import { getSession } from '@/lib/auth'; /** * GET /api/posts/swarm * * Returns aggregated posts from across the swarm network. - * Results are cached for 1 minute to reduce load on other nodes. + * NSFW content is included based on user's nsfwEnabled setting. */ export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url); const refresh = searchParams.get('refresh') === 'true'; - const now = Date.now(); - - // Return cached data if fresh - if (!refresh && cachedTimeline && (now - cacheTimestamp) < CACHE_TTL_MS) { - return NextResponse.json({ - posts: cachedTimeline.posts, - sources: cachedTimeline.sources, - cached: true, - fetchedAt: cachedTimeline.fetchedAt, - }); + // Check user's NSFW preference + let includeNsfw = false; + try { + const session = await getSession(); + includeNsfw = session?.user?.nsfwEnabled ?? false; + } catch { + includeNsfw = false; } - // Fetch fresh data - const timeline = await fetchSwarmTimeline(10, 15); - - // Update cache - cachedTimeline = timeline; - cacheTimestamp = now; + // Fetch swarm timeline (no caching - user preferences vary) + const timeline = await fetchSwarmTimeline(10, 15, { includeNsfw }); return NextResponse.json({ posts: timeline.posts, diff --git a/src/lib/swarm/timeline.ts b/src/lib/swarm/timeline.ts index 6ac005b..91dd5ca 100644 --- a/src/lib/swarm/timeline.ts +++ b/src/lib/swarm/timeline.ts @@ -17,6 +17,85 @@ interface TimelineOptions { includeNsfw?: boolean; // Whether to include NSFW content } +/** + * Extract the first URL from post content + */ +function extractFirstUrl(content: string): string | null { + const urlMatch = content.match(/https?:\/\/[^\s<>"{}|\\^`[\]]+/); + if (!urlMatch) return null; + // Clean trailing punctuation + return urlMatch[0].replace(/[)\].,!?;:]+$/, ''); +} + +/** + * Fetch link preview for a URL + */ +async function fetchLinkPreview(url: string): Promise<{ + url: string; + title: string | null; + description: string | null; + image: string | null; +} | null> { + try { + 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, + title: data.title || null, + description: data.description || null, + image: data.image || null, + }; + } catch { + return null; + } +} + +/** + * Enrich swarm posts with link previews if they have URLs but no preview data + */ +async function enrichPostsWithPreviews(posts: SwarmPost[]): Promise { + 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, + linkPreviewTitle: preview.title || undefined, + linkPreviewDescription: preview.description || undefined, + linkPreviewImage: preview.image || undefined, + }; + }); + + return Promise.all(enrichmentPromises); +} + /** * Fetch timeline from a single node */ @@ -121,8 +200,11 @@ export async function fetchSwarmTimeline( return true; }); + // Enrich posts that have URLs but no link preview data + const enrichedPosts = await enrichPostsWithPreviews(uniquePosts); + return { - posts: uniquePosts, + posts: enrichedPosts, sources, fetchedAt: new Date().toISOString(), };