Preserve embedded original posts when rendering swarm reposts

Hop-State: A_06FPEZ3WB9ZANP4K6YVY4S0
Hop-Proposal: R_06FPEZ34Y13T1DBP9HTRMBR
Hop-Task: T_06FPEYF7BR1M5HZ8Z1684KG
Hop-Attempt: AT_06FPEYF7BS5KHWEJRTK2PW0
This commit is contained in:
2026-07-15 13:33:59 -07:00
committed by Hop
parent 7143f6cfd7
commit 1b9857b427
4 changed files with 142 additions and 95 deletions
+2 -40
View File
@@ -10,6 +10,7 @@ import { shouldIncludeNsfwFeed } from '@/lib/nsfw/feed-access';
import { isLocalNodeNsfw } from '@/lib/node/local-node'; import { isLocalNodeNsfw } from '@/lib/node/local-node';
import { hasPublishablePostContent } from '@/lib/posts/content-policy'; import { hasPublishablePostContent } from '@/lib/posts/content-policy';
import { decodeFeedCursor, encodeFeedCursor } from '@/lib/posts/feed-pagination'; import { decodeFeedCursor, encodeFeedCursor } from '@/lib/posts/feed-pagination';
import { mapSwarmPostToPost } from '@/lib/swarm/feed-post';
const POST_MAX_LENGTH = 600; const POST_MAX_LENGTH = 600;
const CURATION_WINDOW_HOURS = 72; const CURATION_WINDOW_HOURS = 72;
@@ -759,46 +760,7 @@ export async function GET(request: Request) {
includeNsfw, includeNsfw,
}); });
const mapSwarmPostToFeedPost = (sp: (typeof swarmResult.posts)[number]): any => ({ const swarmPosts = swarmResult.posts.map(mapSwarmPostToPost);
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);
let mutedIds = new Set<string>(); let mutedIds = new Set<string>();
let blockedIds = new Set<string>(); let blockedIds = new Set<string>();
+3 -55
View File
@@ -11,6 +11,7 @@ import { useAuth } from '@/lib/contexts/AuthContext';
import { signedAPI } from '@/lib/api/signed-fetch'; import { signedAPI } from '@/lib/api/signed-fetch';
import { AvatarImage } from '@/components/AvatarImage'; import { AvatarImage } from '@/components/AvatarImage';
import { useRuntimeConfig } from '@/lib/contexts/ConfigContext'; import { useRuntimeConfig } from '@/lib/contexts/ConfigContext';
import { mapSwarmPostToPost, type InteractiveSwarmPost } from '@/lib/swarm/feed-post';
interface User { interface User {
id: string; 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() { export default function ExplorePage() {
const { user, did, handle } = useAuth(); const { user, did, handle } = useAuth();
const { config } = useRuntimeConfig(); const { config } = useRuntimeConfig();
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [activeTab, setActiveTab] = useState<'node' | 'swarm' | 'users' | 'search'>('node'); const [activeTab, setActiveTab] = useState<'node' | 'swarm' | 'users' | 'search'>('node');
const [nodePosts, setNodePosts] = useState<Post[]>([]); const [nodePosts, setNodePosts] = useState<Post[]>([]);
const [swarmPosts, setSwarmPosts] = useState<SwarmPost[]>([]); const [swarmPosts, setSwarmPosts] = useState<InteractiveSwarmPost[]>([]);
const [swarmSources, setSwarmSources] = useState<{ domain: string; postCount: number }[]>([]); const [swarmSources, setSwarmSources] = useState<{ domain: string; postCount: number }[]>([]);
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [searchResults, setSearchResults] = useState<{ posts: Post[]; users: User[] }>({ posts: [], users: [] }); const [searchResults, setSearchResults] = useState<{ posts: Post[]; users: User[] }>({ posts: [], users: [] });
@@ -433,37 +411,7 @@ export default function ExplorePage() {
</div> </div>
<div className="explore-posts"> <div className="explore-posts">
{swarmPosts.map((post) => { {swarmPosts.map((post) => {
// Transform swarm post to Post format for PostCard const transformedPost = mapSwarmPostToPost(post);
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,
};
return ( return (
<PostCard <PostCard
key={`${post.nodeDomain}:${post.id}`} key={`${post.nodeDomain}:${post.id}`}
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import type { SwarmPost } from '@/app/api/swarm/timeline/route';
import { mapSwarmPostToPost } from './feed-post';
const original: SwarmPost = {
id: 'original-id',
content: 'Original post body',
createdAt: '2026-07-15T18:49:15.000Z',
author: {
handle: 'original-author',
displayName: 'Original Author',
avatarUrl: 'https://example.com/original.png',
isNsfw: false,
},
nodeDomain: 'remote.example',
nodeIsNsfw: true,
isNsfw: false,
likeCount: 2,
repostCount: 1,
replyCount: 0,
media: [{ url: 'https://example.com/image.jpg', mimeType: 'image/jpeg' }],
};
describe('mapSwarmPostToPost', () => {
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',
});
});
});
+55
View File
@@ -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,
};
}