From cf0dfa4b6652e66a26222088017dc664dffa99f7 Mon Sep 17 00:00:00 2001 From: Christomatt Date: Mon, 26 Jan 2026 17:09:21 +0100 Subject: [PATCH] feat: Implement dynamic node title metadata and enhance swarm user and post hydration. --- src/app/api/posts/route.ts | 70 ++++++----- src/app/api/posts/swarm/route.ts | 5 +- src/app/api/swarm/timeline/route.ts | 34 +++-- .../swarm/users/[handle]/followers/route.ts | 28 ++++- src/app/api/users/[handle]/followers/route.ts | 26 ++-- src/app/api/users/[handle]/following/route.ts | 8 +- src/app/explore/page.tsx | 116 ++++++++++++++++-- src/app/layout.tsx | 38 ++++-- src/app/notifications/page.tsx | 4 +- src/components/PostCard.tsx | 11 +- src/lib/swarm/timeline.ts | 48 ++++---- src/lib/swarm/user-hydration.ts | 97 +++++++++++++++ 12 files changed, 380 insertions(+), 105 deletions(-) create mode 100644 src/lib/swarm/user-hydration.ts diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 54045db..80d5a87 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -120,7 +120,7 @@ export async function POST(request: Request) { (async () => { try { const targetUrl = `https://${data.swarmReplyTo!.nodeDomain}/api/swarm/replies`; - + const replyPayload = { postId: data.swarmReplyTo!.postId, reply: { @@ -159,18 +159,18 @@ export async function POST(request: Request) { try { const { extractMentions } = await import('@/lib/swarm/interactions'); const { notifications } = await import('@/db'); - + const mentions = extractMentions(data.content); - + for (const mention of mentions) { // Only handle local mentions (no domain) if (mention.domain) continue; - + // Find the mentioned user const mentionedUser = await db.query.users.findFirst({ where: eq(users.handle, mention.handle.toLowerCase()), }); - + if (mentionedUser && mentionedUser.id !== user.id && !mentionedUser.isSuspended) { // Create notification for the mentioned user with actor info stored directly await db.insert(notifications).values({ @@ -184,7 +184,7 @@ export async function POST(request: Request) { postContent: post.content?.slice(0, 200) || null, type: 'mention', }); - + // Also notify bot owner if this is a bot being mentioned if (mentionedUser.isBot && mentionedUser.botOwnerId) { await db.insert(notifications).values({ @@ -210,7 +210,7 @@ export async function POST(request: Request) { (async () => { try { const { deliverSwarmMentions } = await import('@/lib/swarm/interactions'); - + const result = await deliverSwarmMentions( data.content, post.id, @@ -221,7 +221,7 @@ export async function POST(request: Request) { nodeDomain, } ); - + if (result.delivered > 0) { console.log(`[Swarm] Delivered ${result.delivered} mentions (${result.failed} failed)`); } @@ -235,7 +235,7 @@ export async function POST(request: Request) { try { // SWARM-FIRST: Deliver to swarm followers directly const { deliverPostToSwarmFollowers } = await import('@/lib/swarm/interactions'); - + const swarmResult = await deliverPostToSwarmFollowers( user.id, post, @@ -248,7 +248,7 @@ export async function POST(request: Request) { attachedMedia, nodeDomain ); - + if (swarmResult.delivered > 0) { console.log(`[Swarm] Post ${post.id} delivered to ${swarmResult.delivered} swarm nodes (${swarmResult.failed} failed)`); } @@ -399,7 +399,7 @@ export async function GET(request: Request) { if (type === 'local') { // Local node posts only - no fediverse content let whereCondition = baseFilter; - + // Apply cursor-based pagination if (cursor) { const cursorPost = await db.query.posts.findFirst({ @@ -409,7 +409,7 @@ export async function GET(request: Request) { whereCondition = buildWhere(baseFilter, lt(posts.createdAt, cursorPost.createdAt)); } } - + feedPosts = await db.query.posts.findMany({ where: whereCondition, with: { @@ -454,7 +454,7 @@ export async function GET(request: Request) { } else if (type === 'user' && userId) { // User's posts (excluding replies) let whereCondition = buildWhere(baseFilter, eq(posts.userId, userId)); - + // Apply cursor-based pagination if (cursor) { const cursorPost = await db.query.posts.findFirst({ @@ -464,7 +464,7 @@ export async function GET(request: Request) { whereCondition = buildWhere(baseFilter, eq(posts.userId, userId), lt(posts.createdAt, cursorPost.createdAt)); } } - + feedPosts = await db.query.posts.findMany({ where: whereCondition, with: { @@ -481,7 +481,7 @@ export async function GET(request: Request) { } else if (type === 'replies' && userId) { // User's replies only let whereCondition = buildWhere(repliesFilter, eq(posts.userId, userId)); - + // Apply cursor-based pagination if (cursor) { const cursorPost = await db.query.posts.findFirst({ @@ -491,7 +491,7 @@ export async function GET(request: Request) { whereCondition = buildWhere(repliesFilter, eq(posts.userId, userId), lt(posts.createdAt, cursorPost.createdAt)); } } - + feedPosts = await db.query.posts.findMany({ where: whereCondition, with: { @@ -522,7 +522,7 @@ export async function GET(request: Request) { // Fetch swarm posts with user's NSFW preference const { fetchSwarmTimeline } = await import('@/lib/swarm/timeline'); const swarmResult = await fetchSwarmTimeline(10, 30, { includeNsfw }); - + // Transform swarm posts to match local post format const swarmPosts = swarmResult.posts.map(sp => ({ id: `swarm:${sp.nodeDomain}:${sp.id}`, @@ -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) => ({ @@ -630,13 +631,13 @@ export async function GET(request: Request) { .from(follows) .where(eq(follows.followerId, user.id)); const followingIds = followRows.map(row => row.followingId); - + // Include own posts + posts from followed users const allowedUserIds = [user.id, ...followingIds]; // Build where condition with cursor support let whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds)); - + if (cursor) { const cursorPost = await db.query.posts.findFirst({ where: eq(posts.id, cursor), @@ -671,7 +672,7 @@ export async function GET(request: Request) { if (followedRemoteUsers.length > 0) { const { fetchSwarmUserProfile, isSwarmNode } = await import('@/lib/swarm/interactions'); const { resolveRemoteUser } = await import('@/lib/activitypub/fetch'); - + // Wrap each fetch with a timeout to prevent slow nodes from blocking const withTimeout = (promise: Promise, ms: number): Promise => { return Promise.race([ @@ -679,25 +680,25 @@ export async function GET(request: Request) { new Promise((resolve) => setTimeout(() => resolve(null), ms)) ]); }; - + const fetchPromises = followedRemoteUsers.map(async (follow) => { try { const atIndex = follow.targetHandle.lastIndexOf('@'); if (atIndex === -1) return []; - + const handle = follow.targetHandle.slice(0, atIndex); const domain = follow.targetHandle.slice(atIndex + 1); - + // Check if swarm node - use swarm API (faster) const isSwarm = await isSwarmNode(domain); - + if (isSwarm) { const profileData = await withTimeout( fetchSwarmUserProfile(handle, domain, limit), 5000 // 5s timeout per node ); if (!profileData?.posts) return []; - + return profileData.posts.map(post => ({ id: `swarm:${domain}:${post.id}`, content: post.content, @@ -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}`, @@ -729,14 +731,14 @@ export async function GET(request: Request) { // ActivityPub - fetch from outbox const remoteProfile = await resolveRemoteUser(handle, domain); if (!remoteProfile?.outbox) return []; - + // For AP, fall back to cached posts (live outbox fetch is slower) const cachedPosts = await db.query.remotePosts.findMany({ where: eq(remotePosts.authorHandle, follow.targetHandle), orderBy: [desc(remotePosts.publishedAt)], limit: limit, }); - + return transformRemotePosts(cachedPosts); } } catch (error) { @@ -744,7 +746,7 @@ export async function GET(request: Request) { return []; } }); - + const results = await Promise.all(fetchPromises); liveRemotePosts = results.flat(); } @@ -781,11 +783,11 @@ export async function GET(request: Request) { if (session?.user && feedPosts && feedPosts.length > 0) { const viewer = session.user; const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; - + // Separate local and swarm posts const localPostIds: string[] = []; const swarmPosts: Array<{ id: string; domain: string; originalId: string }> = []; - + for (const p of feedPosts as Array<{ id: string }>) { if (p.id.startsWith('swarm:')) { const parts = p.id.split(':'); @@ -804,7 +806,7 @@ export async function GET(request: Request) { // Check local likes const likedPostIds = new Set(); const repostedPostIds = new Set(); - + if (localPostIds.length > 0) { const viewerLikes = await db.query.likes.findMany({ where: and( @@ -829,12 +831,12 @@ export async function GET(request: Request) { try { const protocol = sp.domain.includes('localhost') ? 'http' : 'https'; const url = `${protocol}://${sp.domain}/api/swarm/posts/${sp.originalId}/likes?checkHandle=${viewer.handle}&checkDomain=${nodeDomain}`; - + const res = await fetch(url, { headers: { 'Accept': 'application/json' }, signal: AbortSignal.timeout(3000), }); - + if (res.ok) { const data = await res.json(); if (data.isLiked) { @@ -845,7 +847,7 @@ export async function GET(request: Request) { // Timeout or error - just skip } }); - + await Promise.all(checkPromises); } diff --git a/src/app/api/posts/swarm/route.ts b/src/app/api/posts/swarm/route.ts index 38a9b80..afb899b 100644 --- a/src/app/api/posts/swarm/route.ts +++ b/src/app/api/posts/swarm/route.ts @@ -18,7 +18,8 @@ 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; try { @@ -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, diff --git a/src/app/api/swarm/timeline/route.ts b/src/app/api/swarm/timeline/route.ts index 02108ff..95ae889 100644 --- a/src/app/api/swarm/timeline/route.ts +++ b/src/app/api/swarm/timeline/route.ts @@ -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,18 +44,36 @@ 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 }); } const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost'; - + // Get node NSFW status const node = await db.query.nodes.findFirst({ where: eq(nodes.domain, nodeDomain), }); 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,22 +92,18 @@ 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); // Fetch media for each post const swarmPosts: SwarmPost[] = []; - + for (const post of recentPosts) { const postMedia = await db .select({ url: media.url, mimeType: media.mimeType, altText: media.altText }) @@ -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, diff --git a/src/app/api/swarm/users/[handle]/followers/route.ts b/src/app/api/swarm/users/[handle]/followers/route.ts index 39edc36..f3364fb 100644 --- a/src/app/api/swarm/users/[handle]/followers/route.ts +++ b/src/app/api/swarm/users/[handle]/followers/route.ts @@ -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(), }); diff --git a/src/app/api/users/[handle]/followers/route.ts b/src/app/api/users/[handle]/followers/route.ts index f3462d8..d125592 100644 --- a/src/app/api/users/[handle]/followers/route.ts +++ b/src/app/api/users/[handle]/followers/route.ts @@ -31,7 +31,7 @@ export async function GET(request: Request, context: RouteContext) { // Check if this is a remote user const [remoteHandle, remoteDomain] = cleanHandle.split('@'); - + if (remoteDomain) { // Fetch from remote swarm node const swarmData = await fetchSwarmFollowers(remoteHandle, remoteDomain, limit); @@ -80,15 +80,23 @@ export async function GET(request: Request, context: RouteContext) { .where(eq(follows.followingId, user.id)) .limit(limit); + 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: 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, - })), + followers: allFollowers, nextCursor: userFollowers.length === limit ? userFollowers[userFollowers.length - 1]?.id : null, }); } catch (error) { diff --git a/src/app/api/users/[handle]/following/route.ts b/src/app/api/users/[handle]/following/route.ts index 5eedf0a..4bf2bb9 100644 --- a/src/app/api/users/[handle]/following/route.ts +++ b/src/app/api/users/[handle]/following/route.ts @@ -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 }> }; @@ -31,7 +32,7 @@ export async function GET(request: Request, context: RouteContext) { // Check if this is a remote user const [remoteHandle, remoteDomain] = cleanHandle.split('@'); - + if (remoteDomain) { // Fetch from remote swarm node const swarmData = await fetchSwarmFollowing(remoteHandle, remoteDomain, limit); @@ -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) { diff --git a/src/app/explore/page.tsx b/src/app/explore/page.tsx index 8d04dde..161119d 100644 --- a/src/app/explore/page.tsx +++ b/src/app/explore/page.tsx @@ -34,15 +34,15 @@ function UserCard({ user }: { user: User }) {
{user.displayName || user.handle} {user.isBot && ( - ({ posts: [], users: [] }); const [loading, setLoading] = useState(true); const [searching, setSearching] = useState(false); + const [nodeCursor, setNodeCursor] = useState(null); + const [swarmCursor, setSwarmCursor] = useState(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() { } }; - loadNodePosts(); - }, []); + 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) => ( ))} + {/* Sentinel for Infinite Scroll */} + {hasMoreNode && ( +
+ {loadingMore ? 'Loading more...' : ''} +
+ )}
) @@ -342,6 +434,12 @@ export default function ExplorePage() { /> ); })} + {/* Sentinel for Infinite Scroll */} + {hasMoreSwarm && ( +
+ {loadingMore ? 'Loading more...' : ''} +
+ )} ) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 61f5397..51481ee 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,16 +13,34 @@ const sairaCondensed = Saira_Condensed({ variable: "--font-saira", }); -export const metadata: Metadata = { - title: "Synapsis", - 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: { - icon: "/api/favicon", - }, - themeColor: "#0a0a0a", - viewport: "width=device-width, initial-scale=1, maximum-scale=1", -}; +import { db } from "@/db"; + +export async function generateMetadata(): Promise { + 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: { + icon: "/api/favicon", + }, + 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 diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index ee0451d..20da3c5 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -187,7 +187,7 @@ function NotificationItem({ > {actor?.avatarUrl ? ( - {actor.displayName - {actor?.displayName || actor?.handle || 'Someone'} + {actor?.displayName || actor?.handle || 'Someone'} @{actor?.handle} {getNotificationText(notification)} diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index e5fe380..f008696 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -441,7 +441,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, e.stopPropagation()}> {post.author.displayName || post.author.handle} - {post.bot && ( + {(post.bot || post.author.isBot) && ( 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}` + )) )) && (