fix: federation avatar colors, cross-instance DMs, and instance persistence

- Fix avatar gradient colors in 7 UI sites by using homeUserId fallback
- Merge remote DM channels in populateFromReady (no longer home-only)
- Remove isHome guards from 8 DM WS event handlers for cross-instance DMs
- Route DM typing events to correct instance origin
- Fix autoConnectAll deleting disconnected instances from localStorage
- Distinguish network errors from auth errors in instance reconnection
- Start WebSocket retry for network-unreachable instances (auto-recovery)
- Add reconnectInstance method and Reconnect button in UI
This commit is contained in:
Jannis Braun
2026-03-04 18:46:16 +01:00
parent 6e44a4ef2f
commit 33ae79bae9
11 changed files with 133 additions and 43 deletions
+78 -11
View File
@@ -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<void>;
removeInstance: (origin: string) => void;
setInstanceStatus: (origin: string, status: ConnectedInstance['status'], error?: string) => void;
reconnectInstance: (origin: string) => Promise<void>;
syncInstanceList: () => Promise<void>;
autoConnectAll: () => Promise<void>;
reset: () => void;
@@ -280,6 +289,49 @@ export const useInstanceStore = create<InstanceState>((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<InstanceState>((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');