feat: Implement dynamic node title metadata and enhance swarm user and post hydration.
This commit is contained in:
@@ -540,6 +540,7 @@ export async function GET(request: Request) {
|
||||
displayName: sp.author.displayName,
|
||||
avatarUrl: sp.author.avatarUrl,
|
||||
isSwarm: true,
|
||||
isBot: sp.author.isBot,
|
||||
nodeDomain: sp.nodeDomain,
|
||||
},
|
||||
media: sp.media?.map((m, idx) => ({
|
||||
@@ -717,6 +718,7 @@ export async function GET(request: Request) {
|
||||
displayName: follow.displayName || profileData.profile?.displayName || handle,
|
||||
avatarUrl: follow.avatarUrl || profileData.profile?.avatarUrl,
|
||||
isRemote: true,
|
||||
isBot: profileData.profile?.isBot,
|
||||
},
|
||||
media: post.media?.map((m: any, idx: number) => ({
|
||||
id: `swarm:${domain}:${post.id}:media:${idx}`,
|
||||
|
||||
@@ -18,6 +18,7 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const refresh = searchParams.get('refresh') === 'true';
|
||||
const cursor = searchParams.get('cursor') || undefined;
|
||||
|
||||
// Check user's NSFW preference
|
||||
let includeNsfw = false;
|
||||
@@ -29,7 +30,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Fetch swarm timeline (no caching - user preferences vary)
|
||||
const timeline = await fetchSwarmTimeline(10, 15, { includeNsfw });
|
||||
const timeline = await fetchSwarmTimeline(10, 15, { includeNsfw, cursor });
|
||||
|
||||
return NextResponse.json({
|
||||
posts: timeline.posts,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, media, nodes } from '@/db';
|
||||
import { eq, desc, and, isNull } from 'drizzle-orm';
|
||||
import { eq, desc, and, isNull, lt } from 'drizzle-orm';
|
||||
|
||||
export interface SwarmPost {
|
||||
id: string;
|
||||
@@ -17,6 +17,7 @@ export interface SwarmPost {
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
isNsfw: boolean;
|
||||
isBot?: boolean;
|
||||
};
|
||||
nodeDomain: string;
|
||||
nodeIsNsfw: boolean;
|
||||
@@ -43,6 +44,8 @@ export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||
|
||||
const cursor = searchParams.get('cursor');
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ posts: [], nodeDomain: '', nodeIsNsfw: false });
|
||||
}
|
||||
@@ -55,6 +58,22 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
const nodeIsNsfw = node?.isNsfw ?? false;
|
||||
|
||||
// Use query builder for better conditional logic
|
||||
let whereCondition = and(
|
||||
isNull(posts.replyToId), // Not a reply
|
||||
eq(posts.isRemoved, false) // Not removed
|
||||
);
|
||||
|
||||
if (cursor) {
|
||||
// Find the cursor post or use timestamp directly if passed as ISO string
|
||||
// Actually, for swarm, passing ISO timestamp is safer than ID because IDs are local UUIDs
|
||||
// Let's assume cursor is an ISO date string for swarm timeline
|
||||
const cursorDate = new Date(cursor);
|
||||
if (!isNaN(cursorDate.getTime())) {
|
||||
whereCondition = and(whereCondition, lt(posts.createdAt, cursorDate));
|
||||
}
|
||||
}
|
||||
|
||||
// Get recent public posts (not replies, local users only, not removed)
|
||||
const recentPosts = await db
|
||||
.select({
|
||||
@@ -73,16 +92,12 @@ export async function GET(request: NextRequest) {
|
||||
authorDisplayName: users.displayName,
|
||||
authorAvatarUrl: users.avatarUrl,
|
||||
authorIsNsfw: users.isNsfw,
|
||||
authorIsBot: users.isBot,
|
||||
authorNodeId: users.nodeId,
|
||||
})
|
||||
.from(posts)
|
||||
.innerJoin(users, eq(posts.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
isNull(posts.replyToId), // Not a reply
|
||||
eq(posts.isRemoved, false) // Not removed
|
||||
)
|
||||
)
|
||||
.where(whereCondition)
|
||||
.orderBy(desc(posts.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
@@ -104,6 +119,7 @@ export async function GET(request: NextRequest) {
|
||||
displayName: post.authorDisplayName || post.authorHandle,
|
||||
avatarUrl: post.authorAvatarUrl || undefined,
|
||||
isNsfw: post.authorIsNsfw,
|
||||
isBot: post.authorIsBot,
|
||||
},
|
||||
nodeDomain,
|
||||
nodeIsNsfw,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, follows, users, remoteFollowers } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { hydrateSwarmUsers } from '@/lib/swarm/user-hydration';
|
||||
|
||||
export interface SwarmFollowerUser {
|
||||
handle: string;
|
||||
@@ -88,8 +89,33 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
||||
// Merge all followers
|
||||
const allFollowers = [...localFollowers, ...remoteFollowersList].slice(0, limit);
|
||||
|
||||
// Hydrate remote users (from 3rd party nodes)
|
||||
// We need to map to the HydratedUser interface temporarily for the helper
|
||||
const toHydrate = allFollowers.map(f => ({
|
||||
id: f.handle,
|
||||
handle: f.handle,
|
||||
displayName: f.displayName,
|
||||
avatarUrl: f.avatarUrl,
|
||||
bio: f.bio,
|
||||
isRemote: f.isRemote || false,
|
||||
isBot: f.isBot,
|
||||
nodeDomain: undefined,
|
||||
}));
|
||||
|
||||
const hydrated = await hydrateSwarmUsers(toHydrate);
|
||||
|
||||
// Map back to SwarmFollowerUser
|
||||
const finalFollowers: SwarmFollowerUser[] = hydrated.map(u => ({
|
||||
handle: u.handle,
|
||||
displayName: u.displayName || u.handle.split('@')[0], // Ensure non-null
|
||||
avatarUrl: u.avatarUrl || undefined, // Map null to undefined
|
||||
bio: u.bio || undefined,
|
||||
isBot: u.isBot,
|
||||
isRemote: u.isRemote,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
followers: allFollowers,
|
||||
followers: finalFollowers,
|
||||
nodeDomain,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@@ -80,15 +80,23 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
.where(eq(follows.followingId, user.id))
|
||||
.limit(limit);
|
||||
|
||||
return NextResponse.json({
|
||||
followers: userFollowers.map(f => ({
|
||||
const allFollowers = userFollowers.map(f => ({
|
||||
id: f.follower.id,
|
||||
handle: f.follower.handle,
|
||||
displayName: f.follower.displayName,
|
||||
avatarUrl: f.follower.avatarUrl,
|
||||
bio: f.follower.bio,
|
||||
isBot: f.follower.isBot,
|
||||
})),
|
||||
isRemote: false,
|
||||
}));
|
||||
|
||||
// Hydrate remote users with fresh data from swarm (if we had local storage for remote followers, we'd merge them here)
|
||||
// Since we don't store remote followers locally for local users (only incoming follows),
|
||||
// we mainly need this if we were merging remote lists which we only do in the swarm endpoint.
|
||||
// However, let's keep it consistent in case we add remote followers storage.
|
||||
|
||||
return NextResponse.json({
|
||||
followers: allFollowers,
|
||||
nextCursor: userFollowers.length === limit ? userFollowers[userFollowers.length - 1]?.id : null,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, follows, users, remoteFollows } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { hydrateSwarmUsers } from '@/lib/swarm/user-hydration';
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
@@ -108,8 +109,11 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
// Merge and return
|
||||
const allFollowing = [...localFollowing, ...remoteFollowing].slice(0, limit);
|
||||
|
||||
// Hydrate remote users with fresh data from swarm
|
||||
const hydratedFollowing = await hydrateSwarmUsers(allFollowing);
|
||||
|
||||
return NextResponse.json({
|
||||
following: allFollowing,
|
||||
following: hydratedFollowing,
|
||||
nextCursor: allFollowing.length === limit ? allFollowing[allFollowing.length - 1]?.id : null,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
+100
-2
@@ -90,6 +90,11 @@ export default function ExplorePage() {
|
||||
const [searchResults, setSearchResults] = useState<{ posts: Post[]; users: User[] }>({ posts: [], users: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [nodeCursor, setNodeCursor] = useState<string | null>(null);
|
||||
const [swarmCursor, setSwarmCursor] = useState<string | null>(null);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMoreNode, setHasMoreNode] = useState(true);
|
||||
const [hasMoreSwarm, setHasMoreSwarm] = useState(true);
|
||||
const [isNsfwNode, setIsNsfwNode] = useState(false);
|
||||
|
||||
// Fetch node info to check if NSFW
|
||||
@@ -99,7 +104,7 @@ export default function ExplorePage() {
|
||||
.then(data => {
|
||||
setIsNsfwNode(data.isNsfw || false);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => { });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -110,6 +115,8 @@ export default function ExplorePage() {
|
||||
const res = await fetch('/api/posts?type=local&limit=20');
|
||||
const data = await res.json();
|
||||
setNodePosts(data.posts || []);
|
||||
setNodeCursor(data.nextCursor || null);
|
||||
setHasMoreNode(!!data.nextCursor);
|
||||
} catch {
|
||||
setNodePosts([]);
|
||||
} finally {
|
||||
@@ -117,8 +124,10 @@ export default function ExplorePage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (activeTab === 'node' && nodePosts.length === 0) {
|
||||
loadNodePosts();
|
||||
}, []);
|
||||
}
|
||||
}, [activeTab, nodePosts.length]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load swarm posts when tab changes
|
||||
@@ -130,6 +139,15 @@ export default function ExplorePage() {
|
||||
const data = await res.json();
|
||||
setSwarmPosts(data.posts || []);
|
||||
setSwarmSources(data.sources || []);
|
||||
|
||||
// Set cursor from the last post if available
|
||||
if (data.posts && data.posts.length > 0) {
|
||||
const lastPost = data.posts[data.posts.length - 1];
|
||||
setSwarmCursor(lastPost.createdAt); // Use timestamp as cursor
|
||||
setHasMoreSwarm(true);
|
||||
} else {
|
||||
setHasMoreSwarm(false);
|
||||
}
|
||||
} catch {
|
||||
setSwarmPosts([]);
|
||||
} finally {
|
||||
@@ -140,6 +158,74 @@ export default function ExplorePage() {
|
||||
}
|
||||
}, [activeTab, swarmPosts.length]);
|
||||
|
||||
// Load more node posts
|
||||
const loadMoreNode = async () => {
|
||||
if (!nodeCursor || loadingMore || !hasMoreNode) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const res = await fetch(`/api/posts?type=local&limit=20&cursor=${nodeCursor}`);
|
||||
const data = await res.json();
|
||||
if (data.posts && data.posts.length > 0) {
|
||||
setNodePosts(prev => [...prev, ...data.posts]);
|
||||
setNodeCursor(data.nextCursor || null);
|
||||
setHasMoreNode(!!data.nextCursor);
|
||||
} else {
|
||||
setHasMoreNode(false);
|
||||
}
|
||||
} catch {
|
||||
// Error loading more
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load more swarm posts
|
||||
const loadMoreSwarm = async () => {
|
||||
if (!swarmCursor || loadingMore || !hasMoreSwarm) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
// Use timestamp of last post as cursor
|
||||
const res = await fetch(`/api/posts/swarm?limit=20&cursor=${encodeURIComponent(swarmCursor)}`);
|
||||
const data = await res.json();
|
||||
if (data.posts && data.posts.length > 0) {
|
||||
setSwarmPosts(prev => [...prev, ...data.posts]);
|
||||
|
||||
const lastPost = data.posts[data.posts.length - 1];
|
||||
setSwarmCursor(lastPost.createdAt);
|
||||
setHasMoreSwarm(true);
|
||||
} else {
|
||||
setHasMoreSwarm(false);
|
||||
}
|
||||
} catch {
|
||||
// Error loading more
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Intersection Observer for Infinite Scroll
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
if (activeTab === 'node') {
|
||||
loadMoreNode();
|
||||
} else if (activeTab === 'swarm') {
|
||||
loadMoreSwarm();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
);
|
||||
|
||||
const sentinel = document.getElementById('scroll-sentinel');
|
||||
if (sentinel) {
|
||||
observer.observe(sentinel);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [activeTab, nodeCursor, swarmCursor, loadingMore, hasMoreNode, hasMoreSwarm]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load users when tab changes to users
|
||||
if (activeTab === 'users' && users.length === 0) {
|
||||
@@ -278,6 +364,12 @@ export default function ExplorePage() {
|
||||
{nodePosts.map((post) => (
|
||||
<PostCard key={post.id} post={post} onLike={handleLike} onRepost={handleRepost} onDelete={handleDelete} />
|
||||
))}
|
||||
{/* Sentinel for Infinite Scroll */}
|
||||
{hasMoreNode && (
|
||||
<div id="scroll-sentinel" style={{ height: '20px', margin: '20px 0', textAlign: 'center', opacity: 0.5 }}>
|
||||
{loadingMore ? 'Loading more...' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
@@ -342,6 +434,12 @@ export default function ExplorePage() {
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* Sentinel for Infinite Scroll */}
|
||||
{hasMoreSwarm && (
|
||||
<div id="scroll-sentinel" style={{ height: '20px', margin: '20px 0', textAlign: 'center', opacity: 0.5 }}>
|
||||
{loadingMore ? 'Loading more...' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
+21
-3
@@ -13,8 +13,25 @@ const sairaCondensed = Saira_Condensed({
|
||||
variable: "--font-saira",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Synapsis",
|
||||
import { db } from "@/db";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
let title = "Synapsis";
|
||||
|
||||
try {
|
||||
const node = await db.query.nodes.findFirst();
|
||||
if (node?.name) {
|
||||
title = node.name;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch node info for metadata", e);
|
||||
}
|
||||
|
||||
return {
|
||||
title: {
|
||||
default: title,
|
||||
template: `%s | ${title}`,
|
||||
},
|
||||
description: "Synapsis is designed to function like a global signal layer rather than a culture-bound platform. Anyone can run their own node and still participate in a shared, interconnected network, with global identity, clean terminology, and a modern interface that feels current rather than experimental.",
|
||||
manifest: "/manifest.json",
|
||||
icons: {
|
||||
@@ -22,7 +39,8 @@ export const metadata: Metadata = {
|
||||
},
|
||||
themeColor: "#0a0a0a",
|
||||
viewport: "width=device-width, initial-scale=1, maximum-scale=1",
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Force all routes to be dynamic (no static generation at build time)
|
||||
// This is appropriate for a social network where all content is user-generated
|
||||
|
||||
@@ -187,7 +187,7 @@ function NotificationItem({
|
||||
>
|
||||
<Link href={actor ? `/@${actor.handle}` : '#'} style={{ flexShrink: 0 }}>
|
||||
{actor?.avatarUrl ? (
|
||||
<Image
|
||||
<img
|
||||
src={actor.avatarUrl}
|
||||
alt={actor.displayName || actor.handle}
|
||||
width={40}
|
||||
@@ -220,7 +220,7 @@ function NotificationItem({
|
||||
href={actor ? `/@${actor.handle}` : '#'}
|
||||
style={{ fontWeight: 600, color: 'var(--foreground)', textDecoration: 'none' }}
|
||||
>
|
||||
{actor?.displayName || actor?.handle || 'Someone'}
|
||||
{actor?.displayName || actor?.handle || 'Someone'} <span style={{ fontWeight: 400, color: 'var(--foreground-tertiary)' }}>@{actor?.handle}</span>
|
||||
</Link>
|
||||
<span style={{ color: 'var(--foreground-secondary)' }}>
|
||||
{getNotificationText(notification)}
|
||||
|
||||
@@ -441,7 +441,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
||||
<Link href={`/${profileHandle}`} className="post-handle" onClick={(e) => e.stopPropagation()}>
|
||||
{post.author.displayName || post.author.handle}
|
||||
</Link>
|
||||
{post.bot && (
|
||||
{(post.bot || post.author.isBot) && (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
@@ -454,7 +454,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
||||
color: 'var(--accent)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
title={`AI Account: ${post.bot.name}`}
|
||||
title={post.bot ? `AI Account: ${post.bot.name}` : `AI Account: ${post.author.displayName || post.author.handle}`}
|
||||
>
|
||||
<Bot size={12} />
|
||||
AI Account
|
||||
@@ -663,8 +663,11 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
||||
currentUser.id === post.author.id ||
|
||||
(post.bot && currentUser.id === post.bot.ownerId) ||
|
||||
(parentPostAuthorId && currentUser.id === parentPostAuthorId) ||
|
||||
// Allow deleting own remote posts (where ID format differs but handle matches)
|
||||
(post.author.id.startsWith('swarm:') && post.author.handle === currentUser.handle)
|
||||
// Allow deleting own remote posts where handle might be username@node_domain
|
||||
(post.author.id.startsWith('swarm:') && (
|
||||
post.author.handle === currentUser.handle ||
|
||||
post.author.handle === `${currentUser.handle}@${NODE_DOMAIN}`
|
||||
))
|
||||
)) && (
|
||||
<button className="post-action delete-action" onClick={handleDelete} disabled={deleting} title="Delete post">
|
||||
<TrashIcon />
|
||||
|
||||
@@ -15,6 +15,7 @@ interface TimelineResult {
|
||||
|
||||
interface TimelineOptions {
|
||||
includeNsfw?: boolean; // Whether to include NSFW content
|
||||
cursor?: string; // Timestamp cursor for pagination
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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,7 +150,7 @@ 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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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