diff --git a/packages/web/src/components/chat/FriendsPage.tsx b/packages/web/src/components/chat/FriendsPage.tsx index f5239b43..3e1391f3 100644 --- a/packages/web/src/components/chat/FriendsPage.tsx +++ b/packages/web/src/components/chat/FriendsPage.tsx @@ -237,7 +237,7 @@ function FriendItem({ friend, onRemove, onDm }: { friend: TaggedFriend, onRemove return (
- +
{friend.displayName ?? friend.username} @@ -289,7 +289,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: { return (
- +
{user.displayName ?? user.username} diff --git a/packages/web/src/components/chat/MentionPopover.tsx b/packages/web/src/components/chat/MentionPopover.tsx index b2579ea1..891f5e2b 100644 --- a/packages/web/src/components/chat/MentionPopover.tsx +++ b/packages/web/src/components/chat/MentionPopover.tsx @@ -71,7 +71,7 @@ export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopove name={displayName} size={24} status={member.user.status} - userId={member.user.id} + userId={member.user.homeUserId ?? member.user.id} /> - +
{user.displayName ?? user.username} diff --git a/packages/web/src/components/modals/ConnectedInstances.tsx b/packages/web/src/components/modals/ConnectedInstances.tsx index 69e2bfbb..93dde701 100644 --- a/packages/web/src/components/modals/ConnectedInstances.tsx +++ b/packages/web/src/components/modals/ConnectedInstances.tsx @@ -265,6 +265,7 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) { export function ConnectedInstances() { const instances = useInstanceStore((s) => s.instances); const removeInstance = useInstanceStore((s) => s.removeInstance); + const reconnectInstance = useInstanceStore((s) => s.reconnectInstance); const [showAddForm, setShowAddForm] = useState(false); return ( @@ -312,13 +313,24 @@ export function ConnectedInstances() { )}
- +
+ {(inst.status === 'disconnected' || inst.status === 'error') && ( + + )} + +
))} diff --git a/packages/web/src/components/modals/NewDmModal.tsx b/packages/web/src/components/modals/NewDmModal.tsx index 36c04110..ec0afc4f 100644 --- a/packages/web/src/components/modals/NewDmModal.tsx +++ b/packages/web/src/components/modals/NewDmModal.tsx @@ -100,7 +100,7 @@ export function NewDmModal() { onClick={() => handleSelectUser(user)} className="w-full flex items-center gap-3 px-3 py-2 rounded-[4px] hover:bg-interactive-hover transition-colors text-left" > - +
{user.displayName ?? user.username} diff --git a/packages/web/src/components/modals/ServerSettings.tsx b/packages/web/src/components/modals/ServerSettings.tsx index 7d5f4a8d..7dabb230 100644 --- a/packages/web/src/components/modals/ServerSettings.tsx +++ b/packages/web/src/components/modals/ServerSettings.tsx @@ -414,7 +414,7 @@ function JoinRequestsSection({ serverId }: { serverId: string }) { src={user?.avatar} name={displayName} size={32} - userId={user?.id} + userId={user?.homeUserId ?? user?.id} />
diff --git a/packages/web/src/components/modals/UserSettings.tsx b/packages/web/src/components/modals/UserSettings.tsx index 036529a5..05c2fcc3 100644 --- a/packages/web/src/components/modals/UserSettings.tsx +++ b/packages/web/src/components/modals/UserSettings.tsx @@ -65,7 +65,7 @@ export function UserSettingsModal() { name={user.displayName ?? user.username} size={64} status={user.status} - userId={user.id} + userId={user.homeUserId ?? user.id} />
{user.displayName ?? user.username}
diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 09f38b24..776c7376 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -258,7 +258,7 @@ function handleEvent(origin: string, event: ServerEvent): void { // ─── DM events (home-only) ────────────────────────────────────────────── case 'dm_message_created': { - if (!isHome) break; + if (!isHome) normalizeMessageAssets(event.message as any, origin); addRealtimeMessage(event.message.dmChannelId, event.message as any); const { dmChannels: currentDmChannels, setDmChannels: setDms, addDmChannel: addDmCh } = useServerStore.getState(); const knownDm = currentDmChannels.find(dm => dm.id === event.message.dmChannelId); @@ -292,17 +292,15 @@ function handleEvent(origin: string, event: ServerEvent): void { } case 'dm_message_updated': - if (!isHome) break; + if (!isHome) normalizeMessageAssets(event.message as any, origin); updateMessage(event.message as any); break; case 'dm_message_deleted': - if (!isHome) break; removeMessage(event.messageId, event.dmChannelId); break; case 'dm_typing': - if (!isHome) break; setTyping(event.dmChannelId, event.userId, event.username); break; @@ -387,24 +385,26 @@ function handleEvent(origin: string, event: ServerEvent): void { // ─── DM channel events (home-only) ────────────────────────────────────── case 'dm_channel_created': - if (!isHome) break; + if (!isHome) { + for (const m of event.dmChannel.members) { + normalizeUserAssets(m, origin); + } + } addDmChannel(event.dmChannel, origin); break; case 'dm_channel_closed': - if (!isHome) break; removeDmChannel(event.dmChannelId); break; case 'dm_member_added': { - if (!isHome) break; + if (!isHome) normalizeUserAssets(event.user, origin); const { addDmMember } = useServerStore.getState(); addDmMember(event.dmChannelId, event.user); break; } case 'dm_member_removed': { - if (!isHome) break; const { removeDmMember } = useServerStore.getState(); removeDmMember(event.dmChannelId, event.userId); break; diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index fb850f44..3926e605 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -50,6 +50,14 @@ function saveCachedTokens(instances: ConnectedInstance[]): void { localStorage.setItem(STORAGE_KEY, JSON.stringify(cache)); } +// ─── Network error detection ──────────────────────────────────────────────── + +/** Detect network-level failures (unreachable, DNS, timeout) vs application errors (401, etc.) */ +function isNetworkError(err: unknown): boolean { + return err instanceof TypeError || + (err instanceof Error && /fetch|network|ECONNREFUSED|ETIMEDOUT/i.test(err.message)); +} + // ─── Error types ──────────────────────────────────────────────────────────── /** Thrown when the remote instance already has an account for this user with a different password. */ @@ -92,6 +100,7 @@ interface InstanceState { loginToRemote: (origin: string, username: string, password: string) => Promise; removeInstance: (origin: string) => void; setInstanceStatus: (origin: string, status: ConnectedInstance['status'], error?: string) => void; + reconnectInstance: (origin: string) => Promise; syncInstanceList: () => Promise; autoConnectAll: () => Promise; reset: () => void; @@ -280,6 +289,49 @@ export const useInstanceStore = create((set, get) => ({ get().syncInstanceList().catch(() => {}); }, + reconnectInstance: async (origin: string) => { + const inst = get().instances.find(i => i.origin === origin); + if (!inst || inst.status === 'connected' || inst.status === 'connecting') return; + + // Set to connecting + set((state) => ({ + instances: state.instances.map(i => + i.origin === origin ? { ...i, status: 'connecting' as const, error: undefined } : i + ), + })); + + try { + const user = await inst.api.users.me(); + + set((state) => ({ + instances: state.instances.map(i => + i.origin === origin ? { ...i, status: 'connected' as const, user, error: undefined } : i + ), + })); + + connectInstance(origin, inst.token); + } catch (err) { + if (isNetworkError(err)) { + set((state) => ({ + instances: state.instances.map(i => + i.origin === origin + ? { ...i, status: 'disconnected' as const, error: 'Instance unreachable — retrying in background' } + : i + ), + })); + connectInstance(origin, inst.token); + } else { + set((state) => ({ + instances: state.instances.map(i => + i.origin === origin + ? { ...i, status: 'error' as const, error: 'Token expired — re-authenticate to reconnect' } + : i + ), + })); + } + } + }, + syncInstanceList: async () => { const { instances } = get(); const currentUser = useAuthStore.getState().user; @@ -388,21 +440,36 @@ export const useInstanceStore = create((set, get) => ({ // Open WebSocket connection now that we've verified the token connectInstance(origin, cachedEntry.token); - } catch { - // Token expired or instance unreachable - set((state) => ({ - instances: state.instances.map(i => - i.origin === origin - ? { ...i, status: 'disconnected' as const, error: 'Token expired — re-authenticate to reconnect' } - : i - ), - })); + } catch (err) { + if (isNetworkError(err)) { + // Instance unreachable (NAT hairpinning, DNS, server down) — token may still be valid + set((state) => ({ + instances: state.instances.map(i => + i.origin === origin + ? { ...i, status: 'disconnected' as const, error: 'Instance unreachable — retrying in background' } + : i + ), + })); + // Start WebSocket — its built-in exponential backoff retry will auto-recover + // when the network path becomes available (e.g. user switches networks) + connectInstance(origin, cachedEntry.token); + } else { + // Auth failure (401, invalid token, etc.) + set((state) => ({ + instances: state.instances.map(i => + i.origin === origin + ? { ...i, status: 'error' as const, error: 'Token expired — re-authenticate to reconnect' } + : i + ), + })); + } } }) ); - // Save final state to localStorage - saveCachedTokens(get().instances.filter(i => i.status === 'connected')); + // Save final state to localStorage — persist ALL instances regardless of status + // so disconnected instances survive page reload and can auto-reconnect later + saveCachedTokens(get().instances); // Log any failures for debugging const failures = results.filter(r => r.status === 'rejected'); diff --git a/packages/web/src/stores/serverStore.ts b/packages/web/src/stores/serverStore.ts index 0f2e99f7..47d5ed6c 100644 --- a/packages/web/src/stores/serverStore.ts +++ b/packages/web/src/stores/serverStore.ts @@ -359,19 +359,31 @@ export const useServerStore = create((set, get) => ({ } } - // DM channels are home-only - const dms = isHome ? (dmChannels || []) : get().dmChannels; - if (isHome) { - for (const dm of dms) { - channelOriginMap.set(dm.id, origin); - if (dm.lastMessage?.id) { - channelLastMessageIds.set(dm.id, dm.lastMessage.id); + // DM channels: process from any origin, normalize remote assets + const incomingDms = dmChannels || []; + if (!isHome) { + for (const dm of incomingDms) { + for (const member of dm.members) { + normalizeUserAssets(member, origin); } } } + for (const dm of incomingDms) { + channelOriginMap.set(dm.id, origin); + if (dm.lastMessage?.id) { + channelLastMessageIds.set(dm.id, dm.lastMessage.id); + } + } + // Merge: remove DMs belonging to this origin from existing state, then append incoming + const existingDmsFromOtherOrigins = get().dmChannels.filter(dm => { + const dmOrigin = get().channelOriginMap.get(dm.id); + return dmOrigin !== origin; + }); + const mergedDms = [...existingDmsFromOtherOrigins, ...incomingDms]; const update: Partial = { servers: mergedServers, + dmChannels: mergedDms, channelToServerMap, channelLastMessageIds, serverPermissions, @@ -379,10 +391,9 @@ export const useServerStore = create((set, get) => ({ channelOriginMap, }; - // Only set folders and dmChannels from home origin + // Only set folders from home origin if (isHome) { update.folders = folders || []; - update.dmChannels = dms; } set(update as any);