diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 48877e3..56b9ea7 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -10,6 +10,7 @@ import { shouldIncludeNsfwFeed } from '@/lib/nsfw/feed-access'; import { isLocalNodeNsfw } from '@/lib/node/local-node'; import { hasPublishablePostContent } from '@/lib/posts/content-policy'; import { decodeFeedCursor, encodeFeedCursor } from '@/lib/posts/feed-pagination'; +import { mapSwarmPostToPost } from '@/lib/swarm/feed-post'; const POST_MAX_LENGTH = 600; const CURATION_WINDOW_HOURS = 72; @@ -759,46 +760,7 @@ export async function GET(request: Request) { includeNsfw, }); - const mapSwarmPostToFeedPost = (sp: (typeof swarmResult.posts)[number]): any => ({ - id: `swarm:${sp.nodeDomain}:${sp.id}`, - originalPostId: sp.id, // Keep the original ID for replies - content: sp.content, - createdAt: new Date(sp.createdAt), - likesCount: sp.likeCount, - repostsCount: sp.repostCount, - repliesCount: sp.replyCount, - isSwarm: true, - nodeDomain: sp.nodeDomain, - repostOfId: sp.repostOfId ? `swarm:${sp.nodeDomain}:${sp.repostOfId}` : null, - repostOf: sp.repostOf ? mapSwarmPostToFeedPost(sp.repostOf) : null, - author: { - id: `swarm:${sp.nodeDomain}:${sp.author.handle}`, - handle: sp.author.handle, - displayName: sp.author.displayName, - avatarUrl: sp.author.avatarUrl, - isSwarm: true, - isBot: sp.author.isBot, - isNsfw: sp.author.isNsfw, - nodeIsNsfw: sp.nodeIsNsfw, - nodeDomain: sp.nodeDomain, - }, - media: sp.media?.map((m, idx) => ({ - id: `swarm:${sp.nodeDomain}:${sp.id}:media:${idx}`, - url: m.url, - altText: m.altText || null, - mimeType: m.mimeType || null, - })) || [], - linkPreviewUrl: sp.linkPreviewUrl || null, - linkPreviewTitle: sp.linkPreviewTitle || null, - linkPreviewDescription: sp.linkPreviewDescription || null, - linkPreviewImage: sp.linkPreviewImage || null, - linkPreviewType: sp.linkPreviewType || null, - linkPreviewVideoUrl: sp.linkPreviewVideoUrl || null, - linkPreviewMedia: sp.linkPreviewMedia || null, - replyTo: null, - }); - - const swarmPosts = swarmResult.posts.map(mapSwarmPostToFeedPost); + const swarmPosts = swarmResult.posts.map(mapSwarmPostToPost); let mutedIds = new Set(); let blockedIds = new Set(); diff --git a/src/app/explore/page.tsx b/src/app/explore/page.tsx index afdab84..1348ee8 100644 --- a/src/app/explore/page.tsx +++ b/src/app/explore/page.tsx @@ -11,6 +11,7 @@ import { useAuth } from '@/lib/contexts/AuthContext'; import { signedAPI } from '@/lib/api/signed-fetch'; import { AvatarImage } from '@/components/AvatarImage'; import { useRuntimeConfig } from '@/lib/contexts/ConfigContext'; +import { mapSwarmPostToPost, type InteractiveSwarmPost } from '@/lib/swarm/feed-post'; interface User { id: string; @@ -61,36 +62,13 @@ function UserCard({ user }: { user: User }) { ); } -interface SwarmPost { - id: string; - content: string; - createdAt: string; - author: { - handle: string; - displayName: string; - avatarUrl?: string; - isNsfw: boolean; - }; - nodeDomain: string; - nodeIsNsfw: boolean; - likeCount: number; - repostCount: number; - replyCount: number; - media?: { url: string; mimeType?: string; altText?: string }[]; - linkPreviewUrl?: string; - linkPreviewTitle?: string; - linkPreviewDescription?: string; - linkPreviewImage?: string; - isLiked?: boolean; -} - export default function ExplorePage() { const { user, did, handle } = useAuth(); const { config } = useRuntimeConfig(); const [query, setQuery] = useState(''); const [activeTab, setActiveTab] = useState<'node' | 'swarm' | 'users' | 'search'>('node'); const [nodePosts, setNodePosts] = useState([]); - const [swarmPosts, setSwarmPosts] = useState([]); + const [swarmPosts, setSwarmPosts] = useState([]); const [swarmSources, setSwarmSources] = useState<{ domain: string; postCount: number }[]>([]); const [users, setUsers] = useState([]); const [searchResults, setSearchResults] = useState<{ posts: Post[]; users: User[] }>({ posts: [], users: [] }); @@ -433,37 +411,7 @@ export default function ExplorePage() {
{swarmPosts.map((post) => { - // Transform swarm post to Post format for PostCard - const transformedPost: Post = { - id: `swarm:${post.nodeDomain}:${post.id}`, - originalPostId: post.id, - content: post.content, - createdAt: post.createdAt, - likesCount: post.likeCount, - repostsCount: post.repostCount, - repliesCount: post.replyCount, - isSwarm: true, - nodeDomain: post.nodeDomain, - author: { - id: `swarm:${post.nodeDomain}:${post.author.handle}`, - handle: post.author.handle, - displayName: post.author.displayName, - avatarUrl: post.author.avatarUrl, - isNsfw: post.author.isNsfw, - nodeIsNsfw: post.nodeIsNsfw, - }, - media: post.media?.map((m, idx) => ({ - id: `swarm:${post.nodeDomain}:${post.id}:media:${idx}`, - url: m.url, - altText: m.altText || null, - mimeType: m.mimeType || null, - })) || [], - linkPreviewUrl: post.linkPreviewUrl || null, - linkPreviewTitle: post.linkPreviewTitle || null, - linkPreviewDescription: post.linkPreviewDescription || null, - linkPreviewImage: post.linkPreviewImage || null, - isLiked: post.isLiked || false, - }; + const transformedPost = mapSwarmPostToPost(post); return ( { + it('preserves the complete embedded original when mapping a repost', () => { + const repost: SwarmPost = { + id: 'repost-id', + content: '', + createdAt: '2026-07-15T20:07:48.000Z', + repostOfId: original.id, + repostOf: original, + author: { + handle: 'reposter', + displayName: 'Reposter', + isNsfw: false, + }, + nodeDomain: 'remote.example', + nodeIsNsfw: true, + isNsfw: false, + likeCount: 0, + repostCount: 0, + replyCount: 0, + }; + + const mapped = mapSwarmPostToPost(repost); + + expect(mapped.repostOfId).toBe('swarm:remote.example:original-id'); + expect(mapped.repostOf).toMatchObject({ + id: 'swarm:remote.example:original-id', + content: 'Original post body', + nodeDomain: 'remote.example', + author: { + handle: 'original-author', + nodeIsNsfw: true, + }, + media: [{ + id: 'swarm:remote.example:original-id:media:0', + url: 'https://example.com/image.jpg', + mimeType: 'image/jpeg', + }], + }); + }); + + it('preserves interaction and link-preview fields', () => { + const mapped = mapSwarmPostToPost({ + ...original, + isLiked: true, + isReposted: true, + linkPreviewUrl: 'https://example.com/story', + linkPreviewTitle: 'Story', + linkPreviewType: 'card', + }); + + expect(mapped).toMatchObject({ + isLiked: true, + isReposted: true, + linkPreviewUrl: 'https://example.com/story', + linkPreviewTitle: 'Story', + linkPreviewType: 'card', + }); + }); +}); diff --git a/src/lib/swarm/feed-post.ts b/src/lib/swarm/feed-post.ts new file mode 100644 index 0000000..da75b8b --- /dev/null +++ b/src/lib/swarm/feed-post.ts @@ -0,0 +1,55 @@ +import type { SwarmPost } from '@/app/api/swarm/timeline/route'; +import type { Post } from '@/lib/types'; + +export type InteractiveSwarmPost = SwarmPost & { + isLiked?: boolean; + isReposted?: boolean; +}; + +export function mapSwarmPostToPost(post: InteractiveSwarmPost): Post { + const normalizedId = `swarm:${post.nodeDomain}:${post.id}`; + + return { + id: normalizedId, + originalPostId: post.id, + content: post.content, + createdAt: post.createdAt, + likesCount: post.likeCount, + repostsCount: post.repostCount, + repliesCount: post.replyCount, + isSwarm: true, + nodeDomain: post.nodeDomain, + repostOfId: post.repostOfId + ? `swarm:${post.nodeDomain}:${post.repostOfId}` + : null, + repostOf: post.repostOf + ? mapSwarmPostToPost(post.repostOf as InteractiveSwarmPost) + : null, + author: { + id: `swarm:${post.nodeDomain}:${post.author.handle}`, + handle: post.author.handle, + displayName: post.author.displayName, + avatarUrl: post.author.avatarUrl, + isSwarm: true, + isBot: post.author.isBot, + isNsfw: post.author.isNsfw, + nodeIsNsfw: post.nodeIsNsfw, + nodeDomain: post.nodeDomain, + }, + media: post.media?.map((item, index) => ({ + id: `${normalizedId}:media:${index}`, + url: item.url, + altText: item.altText || null, + mimeType: item.mimeType || null, + })) || [], + linkPreviewUrl: post.linkPreviewUrl || null, + linkPreviewTitle: post.linkPreviewTitle || null, + linkPreviewDescription: post.linkPreviewDescription || null, + linkPreviewImage: post.linkPreviewImage || null, + linkPreviewType: post.linkPreviewType || null, + linkPreviewVideoUrl: post.linkPreviewVideoUrl || null, + linkPreviewMedia: post.linkPreviewMedia || null, + isLiked: post.isLiked || false, + isReposted: post.isReposted || false, + }; +}