diff --git a/src/app/api/chat/receive/route.ts b/src/app/api/chat/receive/route.ts index 3d62c13..f3d16ca 100644 --- a/src/app/api/chat/receive/route.ts +++ b/src/app/api/chat/receive/route.ts @@ -64,6 +64,16 @@ export async function POST(request: NextRequest) { publicKey = remoteProfile.publicKey; senderDisplayName = remoteProfile.displayName || handle; senderAvatarUrl = remoteProfile.avatarUrl; + + // CACHE: Upsert the remote user into our local database + const { upsertRemoteUser } = await import('@/lib/swarm/user-cache'); + await upsertRemoteUser({ + handle: handle, // already full handle if remote + displayName: senderDisplayName, + avatarUrl: senderAvatarUrl || null, + did: did || '', + isBot: remoteProfile.isBot || false + }); } } } else { diff --git a/src/app/api/swarm/chat/conversations/route.ts b/src/app/api/swarm/chat/conversations/route.ts index 0f655ed..901675d 100644 --- a/src/app/api/swarm/chat/conversations/route.ts +++ b/src/app/api/swarm/chat/conversations/route.ts @@ -57,15 +57,42 @@ export async function GET(request: NextRequest) { }; // Try to get cached user info - const cachedUser = await db.query.users.findFirst({ + let cachedUser = await db.query.users.findFirst({ where: eq(users.handle, participant2Handle), }); + // LAZY LOAD: If remote and not cached, try to fetch it now + if (!cachedUser && isRemote) { + try { + const [rHandle, rDomain] = participant2Handle.split('@'); + const { fetchSwarmUserProfile } = await import('@/lib/swarm/interactions'); + const profileData = await fetchSwarmUserProfile(rHandle, rDomain, 0); + + if (profileData?.profile) { + const { upsertRemoteUser } = await import('@/lib/swarm/user-cache'); + await upsertRemoteUser({ + handle: participant2Handle, + displayName: profileData.profile.displayName, + avatarUrl: profileData.profile.avatarUrl || null, + did: profileData.profile.did || '', + isBot: profileData.profile.isBot || false, + }); + + // Re-query to get the new cached user + cachedUser = await db.query.users.findFirst({ + where: eq(users.handle, participant2Handle), + }) as any; + } + } catch (e) { + console.error(`[Lazy Load] Failed for ${participant2Handle}:`, e); + } + } + if (cachedUser) { participant2Info = { handle: cachedUser.handle, - displayName: cachedUser.displayName || cachedUser.handle, - avatarUrl: cachedUser.avatarUrl, + displayName: (cachedUser as any).displayName || cachedUser.handle, + avatarUrl: (cachedUser as any).avatarUrl || null, }; } @@ -73,7 +100,7 @@ export async function GET(request: NextRequest) { ...conv, participant2: { ...participant2Info, - isBot: cachedUser?.isBot || false, + isBot: (cachedUser as any)?.isBot || false, }, unreadCount: Number(unreadCount[0]?.count || 0), }; diff --git a/src/app/api/users/[handle]/followers/route.ts b/src/app/api/users/[handle]/followers/route.ts index d125592..d35fc80 100644 --- a/src/app/api/users/[handle]/followers/route.ts +++ b/src/app/api/users/[handle]/followers/route.ts @@ -1,6 +1,6 @@ -import { NextResponse } from 'next/server'; -import { db, follows, users } from '@/db'; +import { db, follows, users, remoteFollowers } from '@/db'; import { eq } from 'drizzle-orm'; +import { hydrateSwarmUsers } from '@/lib/swarm/user-hydration'; type RouteContext = { params: Promise<{ handle: string }> }; @@ -46,7 +46,8 @@ export async function GET(request: Request, context: RouteContext) { isRemote: true, isBot: f.isBot, })); - return NextResponse.json({ followers, nextCursor: null }); + const hydratedFollowers = await hydrateSwarmUsers(followers); + return NextResponse.json({ followers: hydratedFollowers, nextCursor: null }); } // If swarm fetch fails, return empty return NextResponse.json({ followers: [], nextCursor: null }); @@ -80,7 +81,7 @@ export async function GET(request: Request, context: RouteContext) { .where(eq(follows.followingId, user.id)) .limit(limit); - const allFollowers = userFollowers.map(f => ({ + const localFollowers = userFollowers.map(f => ({ id: f.follower.id, handle: f.follower.handle, displayName: f.follower.displayName, @@ -90,13 +91,30 @@ export async function GET(request: Request, context: RouteContext) { 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. + // Get remote followers + const userRemoteFollowers = await db.query.remoteFollowers.findMany({ + where: eq(remoteFollowers.userId, user.id), + limit, + }); + + const remoteFollowersList = userRemoteFollowers.map(f => ({ + id: f.actorUrl, + handle: f.handle || 'unknown', + displayName: f.handle?.split('@')[0] || 'Unknown', + avatarUrl: null, + bio: null, + isBot: false, + isRemote: true, + })); + + // Merge and return + const allFollowers = [...localFollowers, ...remoteFollowersList].slice(0, limit); + + // Hydrate users with fresh data from swarm + const hydratedFollowers = await hydrateSwarmUsers(allFollowers); return NextResponse.json({ - followers: allFollowers, + followers: hydratedFollowers, 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 7896b94..9dc0f04 100644 --- a/src/app/api/users/[handle]/following/route.ts +++ b/src/app/api/users/[handle]/following/route.ts @@ -47,7 +47,8 @@ export async function GET(request: Request, context: RouteContext) { isRemote: true, isBot: f.isBot, })); - return NextResponse.json({ following, nextCursor: null }); + const hydratedFollowing = await hydrateSwarmUsers(following); + return NextResponse.json({ following: hydratedFollowing, nextCursor: null }); } // If swarm fetch fails, return empty return NextResponse.json({ following: [], nextCursor: null }); diff --git a/src/app/api/users/[handle]/route.ts b/src/app/api/users/[handle]/route.ts index d7129c0..65ffba8 100644 --- a/src/app/api/users/[handle]/route.ts +++ b/src/app/api/users/[handle]/route.ts @@ -46,6 +46,16 @@ export async function GET(request: Request, context: RouteContext) { if (profileData?.profile) { const profile = profileData.profile; + // CACHE: Upsert the remote user into our local database + const { upsertRemoteUser } = await import('@/lib/swarm/user-cache'); + await upsertRemoteUser({ + handle: `${profile.handle}@${remoteDomain}`, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl || null, + did: profile.did || '', + isBot: profile.isBot || false + }); + return NextResponse.json({ user: { id: `swarm:${remoteDomain}:${profile.handle}`, diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index cae2739..4e9d15b 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -549,7 +549,7 @@ export default function ChatPage() { // LIST VIEW return ( - <> +
- {loading ? ( -
- -
- ) : filteredConversations.length === 0 ? ( -
- -

No conversations yet

-
- ) : ( - filteredConversations.map(conv => ( -
{ - setMessages([]); - setSelectedConversation(conv); - }} - style={{ cursor: 'pointer', display: 'flex', alignItems: 'flex-start', gap: '12px' }} - > -
- {conv.participant2.avatarUrl ? : conv.participant2.displayName[0]} -
-
-
- {conv.participant2.displayName} - {conv.unreadCount > 0 && {conv.unreadCount}} -
-
- {conv.lastMessagePreview} -
-
+
+ {loading ? ( +
+
- )) - )} - + ) : filteredConversations.length === 0 ? ( +
+ +

No conversations yet

+
+ ) : ( + filteredConversations.map(conv => ( +
{ + setMessages([]); + setSelectedConversation(conv); + }} + style={{ cursor: 'pointer', display: 'flex', alignItems: 'flex-start', gap: '12px' }} + > +
+ {conv.participant2.avatarUrl ? : conv.participant2.displayName?.[0] || '?'} +
+
+
+ {conv.participant2.displayName || conv.participant2.handle} + {conv.unreadCount > 0 && {conv.unreadCount}} +
+
+ {conv.lastMessagePreview} +
+
+
+ )) + )} +
+
); } diff --git a/src/lib/swarm/user-cache.ts b/src/lib/swarm/user-cache.ts new file mode 100644 index 0000000..7c29cad --- /dev/null +++ b/src/lib/swarm/user-cache.ts @@ -0,0 +1,49 @@ +import { db, users } from '@/db'; +import { eq } from 'drizzle-orm'; + +export interface RemoteProfile { + handle: string; + displayName: string; + avatarUrl?: string | null; + did: string; + isBot?: boolean; +} + +/** + * Upsert a remote user into the local database for caching/display purposes. + */ +export async function upsertRemoteUser(profile: RemoteProfile) { + try { + if (!db) return; + + // Check if user already exists + const existing = await db.query.users.findFirst({ + where: eq(users.did, profile.did), + }); + + if (existing) { + // Update metadata if changed + await db.update(users) + .set({ + displayName: profile.displayName || existing.displayName, + avatarUrl: profile.avatarUrl || existing.avatarUrl, + isBot: profile.isBot ?? existing.isBot, + updatedAt: new Date(), + }) + .where(eq(users.id, existing.id)); + } else { + // Create new placeholder user + await db.insert(users).values({ + did: profile.did, + handle: profile.handle, // user@domain + displayName: profile.displayName || profile.handle, + avatarUrl: profile.avatarUrl || null, + isBot: profile.isBot || false, + publicKey: '', // We don't necessarily have their public key yet, but DMs often do + // Note: nodeId is null for remote placeholders unless we specifically link it + }); + } + } catch (error) { + console.error(`[User Cache] Failed to upsert ${profile.handle}:`, error); + } +}