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)',
|
background: 'var(--background)',
|
||||||
zIndex: 10,
|
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 />
|
<ArrowLeftIcon />
|
||||||
</Link>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>{user.displayName || user.handle}</h1>
|
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>{user.displayName || user.handle}</h1>
|
||||||
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>{user.postsCount} posts</p>
|
<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;
|
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 fetchOutboxItems = async (outboxUrl: string, limit: number) => {
|
||||||
const res = await fetch(outboxUrl, {
|
const res = await fetch(outboxUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -133,6 +153,41 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
if (!remote) {
|
if (!remote) {
|
||||||
return NextResponse.json({ posts: [], nextCursor: null });
|
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);
|
const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain);
|
||||||
if (!remoteProfile?.outbox) {
|
if (!remoteProfile?.outbox) {
|
||||||
return NextResponse.json({ posts: [] });
|
return NextResponse.json({ posts: [] });
|
||||||
@@ -209,6 +264,41 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
if (!remote) {
|
if (!remote) {
|
||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
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);
|
const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain);
|
||||||
if (!remoteProfile?.outbox) {
|
if (!remoteProfile?.outbox) {
|
||||||
return NextResponse.json({ posts: [] });
|
return NextResponse.json({ posts: [] });
|
||||||
|
|||||||
@@ -40,6 +40,26 @@ const fetchCollectionCount = async (url?: string | null) => {
|
|||||||
return 0;
|
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) {
|
export async function GET(request: Request, context: RouteContext) {
|
||||||
try {
|
try {
|
||||||
const { handle } = await context.params;
|
const { handle } = await context.params;
|
||||||
@@ -70,6 +90,32 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
if (remoteHandle && remoteDomain) {
|
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);
|
const remoteProfile = await resolveRemoteUser(remoteHandle, remoteDomain);
|
||||||
if (remoteProfile) {
|
if (remoteProfile) {
|
||||||
const displayName = sanitizeText(remoteProfile.name) || sanitizeText(remoteProfile.preferredUsername) || remoteHandle;
|
const displayName = sanitizeText(remoteProfile.name) || sanitizeText(remoteProfile.preferredUsername) || remoteHandle;
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface User {
|
|||||||
isRemote?: boolean;
|
isRemote?: boolean;
|
||||||
profileUrl?: string | null;
|
profileUrl?: string | null;
|
||||||
isBot?: boolean;
|
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?: {
|
botOwner?: {
|
||||||
id: string;
|
id: string;
|
||||||
handle: string;
|
handle: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user