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:
@@ -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>();
|
||||||
|
|||||||
@@ -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}`}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user