feat(swarm,profile): Add swarm user profile endpoint and improve navigation
- Create new GET /api/swarm/users/[handle] endpoint for federated user profile queries - Add SwarmUserProfile and SwarmUserPost interfaces for standardized remote profile data - Include user profile metadata (handle, displayName, bio, avatar, stats) in swarm responses - Fetch and include user's recent posts with media and link preview data - Implement fetchSwarmUserPosts helper to query remote Synapsis nodes via swarm API - Update /api/users/[handle]/posts to prioritize swarm API for Synapsis node federation - Replace profile page back navigation from Link to router.back() button for better UX - Add proper error handling and suspended user filtering in swarm endpoint - Support configurable post limit (max 50) for swarm profile queries
This commit is contained in:
@@ -331,9 +331,20 @@ export default function ProfilePage() {
|
||||
background: 'var(--background)',
|
||||
zIndex: 10,
|
||||
}}>
|
||||
<Link href="/" style={{ color: 'var(--foreground)' }}>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
style={{
|
||||
color: 'var(--foreground)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
</Link>
|
||||
</button>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>{user.displayName || user.handle}</h1>
|
||||
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>{user.postsCount} posts</p>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Swarm User Profile Endpoint
|
||||
*
|
||||
* GET: Returns a user's profile and posts for swarm requests
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, media, nodes } from '@/db';
|
||||
import { eq, desc, and } from 'drizzle-orm';
|
||||
|
||||
export interface SwarmUserProfile {
|
||||
handle: string;
|
||||
displayName: string;
|
||||
bio?: string;
|
||||
avatarUrl?: string;
|
||||
headerUrl?: string;
|
||||
website?: string;
|
||||
followersCount: number;
|
||||
followingCount: number;
|
||||
postsCount: number;
|
||||
createdAt: string;
|
||||
isBot?: boolean;
|
||||
nodeDomain: string;
|
||||
}
|
||||
|
||||
export interface SwarmUserPost {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
isNsfw: boolean;
|
||||
likesCount: number;
|
||||
repostsCount: number;
|
||||
repliesCount: number;
|
||||
media?: { url: string; mimeType?: string; altText?: string }[];
|
||||
linkPreviewUrl?: string;
|
||||
linkPreviewTitle?: string;
|
||||
linkPreviewDescription?: string;
|
||||
linkPreviewImage?: string;
|
||||
}
|
||||
|
||||
type RouteContext = { params: Promise<{ handle: string }> };
|
||||
|
||||
/**
|
||||
* GET /api/swarm/users/[handle]
|
||||
*
|
||||
* Returns a user's profile and recent posts.
|
||||
* Used by other nodes to display remote user profiles.
|
||||
*/
|
||||
export async function GET(request: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { handle } = await context.params;
|
||||
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
|
||||
|
||||
if (!db) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||
|
||||
// Find the user
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.handle, cleanHandle),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (user.isSuspended) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Build profile response
|
||||
const profile: SwarmUserProfile = {
|
||||
handle: user.handle,
|
||||
displayName: user.displayName || user.handle,
|
||||
bio: user.bio || undefined,
|
||||
avatarUrl: user.avatarUrl || undefined,
|
||||
headerUrl: user.headerUrl || undefined,
|
||||
website: user.website || undefined,
|
||||
followersCount: user.followersCount,
|
||||
followingCount: user.followingCount,
|
||||
postsCount: user.postsCount,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
isBot: user.isBot || undefined,
|
||||
nodeDomain,
|
||||
};
|
||||
|
||||
// Get user's recent posts
|
||||
const userPosts = await db
|
||||
.select({
|
||||
id: posts.id,
|
||||
content: posts.content,
|
||||
createdAt: posts.createdAt,
|
||||
isNsfw: posts.isNsfw,
|
||||
likesCount: posts.likesCount,
|
||||
repostsCount: posts.repostsCount,
|
||||
repliesCount: posts.repliesCount,
|
||||
linkPreviewUrl: posts.linkPreviewUrl,
|
||||
linkPreviewTitle: posts.linkPreviewTitle,
|
||||
linkPreviewDescription: posts.linkPreviewDescription,
|
||||
linkPreviewImage: posts.linkPreviewImage,
|
||||
})
|
||||
.from(posts)
|
||||
.where(
|
||||
and(
|
||||
eq(posts.userId, user.id),
|
||||
eq(posts.isRemoved, false)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(posts.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
// Fetch media for each post
|
||||
const swarmPosts: SwarmUserPost[] = [];
|
||||
|
||||
for (const post of userPosts) {
|
||||
const postMedia = await db
|
||||
.select({ url: media.url, mimeType: media.mimeType, altText: media.altText })
|
||||
.from(media)
|
||||
.where(eq(media.postId, post.id));
|
||||
|
||||
swarmPosts.push({
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt.toISOString(),
|
||||
isNsfw: post.isNsfw,
|
||||
likesCount: post.likesCount,
|
||||
repostsCount: post.repostsCount,
|
||||
repliesCount: post.repliesCount,
|
||||
media: postMedia.length > 0 ? postMedia.map(m => ({
|
||||
url: m.url,
|
||||
mimeType: m.mimeType || undefined,
|
||||
altText: m.altText || undefined,
|
||||
})) : undefined,
|
||||
linkPreviewUrl: post.linkPreviewUrl || undefined,
|
||||
linkPreviewTitle: post.linkPreviewTitle || undefined,
|
||||
linkPreviewDescription: post.linkPreviewDescription || undefined,
|
||||
linkPreviewImage: post.linkPreviewImage || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
profile,
|
||||
posts: swarmPosts,
|
||||
nodeDomain,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Swarm user profile error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch user profile' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,26 @@ const parseRemoteHandle = (handle: string) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch remote user posts via Swarm API (preferred for Synapsis nodes)
|
||||
*/
|
||||
const fetchSwarmUserPosts = async (handle: string, domain: string, limit: number) => {
|
||||
try {
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const url = `${protocol}://${domain}/api/swarm/users/${handle}?limit=${limit}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!data.profile || !data.posts) return null;
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchOutboxItems = async (outboxUrl: string, limit: number) => {
|
||||
const res = await fetch(outboxUrl, {
|
||||
headers: {
|
||||
@@ -133,6 +153,41 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
if (!remote) {
|
||||
return NextResponse.json({ posts: [], nextCursor: null });
|
||||
}
|
||||
|
||||
// Try Swarm API first (for Synapsis nodes)
|
||||
const swarmData = await fetchSwarmUserPosts(remote.handle, remote.domain, limit);
|
||||
if (swarmData?.posts) {
|
||||
const profile = swarmData.profile;
|
||||
const authorHandle = `${profile.handle}@${remote.domain}`;
|
||||
const author = {
|
||||
id: `swarm:${remote.domain}:${profile.handle}`,
|
||||
handle: authorHandle,
|
||||
displayName: profile.displayName || profile.handle,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
};
|
||||
|
||||
const posts = swarmData.posts.map((post: any) => ({
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt,
|
||||
likesCount: post.likesCount || 0,
|
||||
repostsCount: post.repostsCount || 0,
|
||||
repliesCount: post.repliesCount || 0,
|
||||
author,
|
||||
media: post.media || [],
|
||||
linkPreviewUrl: post.linkPreviewUrl || null,
|
||||
linkPreviewTitle: post.linkPreviewTitle || null,
|
||||
linkPreviewDescription: post.linkPreviewDescription || null,
|
||||
linkPreviewImage: post.linkPreviewImage || null,
|
||||
isSwarm: true,
|
||||
nodeDomain: remote.domain,
|
||||
originalPostId: post.id,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ posts, nextCursor: null });
|
||||
}
|
||||
|
||||
// Fall back to ActivityPub for non-Synapsis nodes
|
||||
const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain);
|
||||
if (!remoteProfile?.outbox) {
|
||||
return NextResponse.json({ posts: [] });
|
||||
@@ -209,6 +264,41 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
if (!remote) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Try Swarm API first (for Synapsis nodes)
|
||||
const swarmData = await fetchSwarmUserPosts(remote.handle, remote.domain, limit);
|
||||
if (swarmData?.posts) {
|
||||
const profile = swarmData.profile;
|
||||
const authorHandle = `${profile.handle}@${remote.domain}`;
|
||||
const author = {
|
||||
id: `swarm:${remote.domain}:${profile.handle}`,
|
||||
handle: authorHandle,
|
||||
displayName: profile.displayName || profile.handle,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
};
|
||||
|
||||
const posts = swarmData.posts.map((post: any) => ({
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt,
|
||||
likesCount: post.likesCount || 0,
|
||||
repostsCount: post.repostsCount || 0,
|
||||
repliesCount: post.repliesCount || 0,
|
||||
author,
|
||||
media: post.media || [],
|
||||
linkPreviewUrl: post.linkPreviewUrl || null,
|
||||
linkPreviewTitle: post.linkPreviewTitle || null,
|
||||
linkPreviewDescription: post.linkPreviewDescription || null,
|
||||
linkPreviewImage: post.linkPreviewImage || null,
|
||||
isSwarm: true,
|
||||
nodeDomain: remote.domain,
|
||||
originalPostId: post.id,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ posts, nextCursor: null });
|
||||
}
|
||||
|
||||
// Fall back to ActivityPub for non-Synapsis nodes
|
||||
const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain);
|
||||
if (!remoteProfile?.outbox) {
|
||||
return NextResponse.json({ posts: [] });
|
||||
|
||||
@@ -40,6 +40,26 @@ const fetchCollectionCount = async (url?: string | null) => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch remote user profile via Swarm API (preferred for Synapsis nodes)
|
||||
*/
|
||||
const fetchSwarmProfile = async (handle: string, domain: string) => {
|
||||
try {
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const url = `${protocol}://${domain}/api/swarm/users/${handle}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!data.profile) return null;
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { handle } = await context.params;
|
||||
@@ -70,6 +90,32 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
|
||||
if (!user) {
|
||||
if (remoteHandle && remoteDomain) {
|
||||
// Try Swarm API first (for Synapsis nodes)
|
||||
const swarmData = await fetchSwarmProfile(remoteHandle, remoteDomain);
|
||||
if (swarmData?.profile) {
|
||||
const profile = swarmData.profile;
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: `swarm:${remoteDomain}:${profile.handle}`,
|
||||
handle: `${profile.handle}@${remoteDomain}`,
|
||||
displayName: profile.displayName,
|
||||
bio: profile.bio || null,
|
||||
avatarUrl: profile.avatarUrl || null,
|
||||
headerUrl: profile.headerUrl || null,
|
||||
followersCount: profile.followersCount,
|
||||
followingCount: profile.followingCount,
|
||||
postsCount: profile.postsCount,
|
||||
website: profile.website || null,
|
||||
createdAt: profile.createdAt,
|
||||
isRemote: true,
|
||||
isSwarm: true,
|
||||
nodeDomain: remoteDomain,
|
||||
isBot: profile.isBot || false,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fall back to ActivityPub for non-Synapsis nodes
|
||||
const remoteProfile = await resolveRemoteUser(remoteHandle, remoteDomain);
|
||||
if (remoteProfile) {
|
||||
const displayName = sanitizeText(remoteProfile.name) || sanitizeText(remoteProfile.preferredUsername) || remoteHandle;
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface User {
|
||||
isRemote?: boolean;
|
||||
profileUrl?: string | null;
|
||||
isBot?: boolean;
|
||||
isSwarm?: boolean; // Whether this user is from a Synapsis swarm node
|
||||
nodeDomain?: string | null; // Domain of the node this user is from (for swarm users)
|
||||
botOwner?: {
|
||||
id: string;
|
||||
handle: string;
|
||||
|
||||
Reference in New Issue
Block a user