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);