feat: federation batch 1+2 — bug fixes and password enforcement

Batch 1 — Bug fixes:
- Fix stale voice state on remote reconnect (clearVoiceUsersForOrigin)
- Fix logout not cleaning remote servers from store
- Fix reply-to asset normalization for remote messages
- Fix HTTPS hardcoded in autoConnectAll (store full origin, legacy fallback)

Batch 2 — Password enforcement + replication flow:
- Add connectToRemote() with home password verification before remote auth
- Auto-cascade: verify home password → register on remote → login fallback
- Replace Register/Login tabs with single password field in ConnectedInstances
- Add DifferentPasswordError for typed fallback-login UI transition
- Fallback login form shown only when remote has different password
This commit is contained in:
Jannis Braun
2026-03-04 01:51:38 +01:00
parent 3f15a8e282
commit 64fd8edfc9
7 changed files with 232 additions and 139 deletions
+6 -3
View File
@@ -82,10 +82,13 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
if (!Array.isArray(replicatedInstances)) { if (!Array.isArray(replicatedInstances)) {
return reply.code(400).send({ error: 'replicatedInstances must be an array', statusCode: 400 }); return reply.code(400).send({ error: 'replicatedInstances must be an array', statusCode: 400 });
} }
// Validate each entry has domain and username strings // Validate each entry has (origin or domain) and username strings
for (const inst of replicatedInstances) { for (const inst of replicatedInstances) {
if (!inst || typeof inst.domain !== 'string' || typeof inst.username !== 'string') { if (!inst || typeof inst.username !== 'string') {
return reply.code(400).send({ error: 'Each replicated instance must have domain and username strings', statusCode: 400 }); return reply.code(400).send({ error: 'Each replicated instance must have username string', statusCode: 400 });
}
if (typeof inst.origin !== 'string' && typeof inst.domain !== 'string') {
return reply.code(400).send({ error: 'Each replicated instance must have origin or domain string', statusCode: 400 });
} }
} }
if (replicatedInstances.length > 50) { if (replicatedInstances.length > 50) {
+2 -1
View File
@@ -14,8 +14,9 @@ export interface User {
} }
export interface ReplicatedInstance { export interface ReplicatedInstance {
domain: string; origin: string; // Full URL with protocol, e.g. "https://orbit.ddns.net"
username: string; username: string;
domain?: string; // Legacy field — kept for backward compat with existing data
} }
export type UserStatus = 'online' | 'idle' | 'dnd' | 'offline'; export type UserStatus = 'online' | 'idle' | 'dnd' | 'offline';
@@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import type { InstanceInfoResponse } from '@backspace/shared'; import type { InstanceInfoResponse } from '@backspace/shared';
import { useInstanceStore } from '../../stores/instanceStore'; import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
// ─── Status indicator ──────────────────────────────────────────────────────── // ─── Status indicator ────────────────────────────────────────────────────────
@@ -17,21 +17,21 @@ function StatusDot({ status }: { status: string }) {
// ─── Add Instance flow ─────────────────────────────────────────────────────── // ─── Add Instance flow ───────────────────────────────────────────────────────
type AddStep = 'url' | 'auth' | 'done'; type AddStep = 'url' | 'auth' | 'done';
type AuthTab = 'register' | 'login'; type AuthPhase = 'password' | 'fallback-login';
function AddInstanceFlow({ onDone }: { onDone: () => void }) { function AddInstanceFlow({ onDone }: { onDone: () => void }) {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const registerOnRemote = useInstanceStore((s) => s.registerOnRemote); const connectToRemote = useInstanceStore((s) => s.connectToRemote);
const loginToRemote = useInstanceStore((s) => s.loginToRemote); const loginToRemote = useInstanceStore((s) => s.loginToRemote);
const probeInstance = useInstanceStore((s) => s.probeInstance); const probeInstance = useInstanceStore((s) => s.probeInstance);
const [step, setStep] = useState<AddStep>('url'); const [step, setStep] = useState<AddStep>('url');
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
const [probeResult, setProbeResult] = useState<(InstanceInfoResponse & { origin: string }) | null>(null); const [probeResult, setProbeResult] = useState<(InstanceInfoResponse & { origin: string }) | null>(null);
const [authTab, setAuthTab] = useState<AuthTab>('register'); const [authPhase, setAuthPhase] = useState<AuthPhase>('password');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [loginUsername, setLoginUsername] = useState(''); const [fallbackUsername, setFallbackUsername] = useState('');
const [loginPassword, setLoginPassword] = useState(''); const [fallbackPassword, setFallbackPassword] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -41,7 +41,7 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
try { try {
const result = await probeInstance(url); const result = await probeInstance(url);
setProbeResult(result); setProbeResult(result);
setAuthTab(result.registrationOpen ? 'register' : 'login'); setAuthPhase('password');
setStep('auth'); setStep('auth');
} catch (err) { } catch (err) {
setError((err as Error).message); setError((err as Error).message);
@@ -50,12 +50,12 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
} }
}; };
const handleRegister = async () => { const handleConnect = async () => {
if (!probeResult) return; if (!probeResult) return;
setError(''); setError('');
setIsLoading(true); setIsLoading(true);
try { try {
await registerOnRemote( await connectToRemote(
probeResult.origin, probeResult.origin,
password, password,
user?.displayName || undefined, user?.displayName || undefined,
@@ -63,18 +63,25 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
setStep('done'); setStep('done');
onDone(); onDone();
} catch (err) { } catch (err) {
if (err instanceof DifferentPasswordError) {
setAuthPhase('fallback-login');
setFallbackUsername(err.remoteUsername);
setFallbackPassword('');
setError('');
} else {
setError((err as Error).message); setError((err as Error).message);
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
const handleLogin = async () => { const handleFallbackLogin = async () => {
if (!probeResult) return; if (!probeResult) return;
setError(''); setError('');
setIsLoading(true); setIsLoading(true);
try { try {
await loginToRemote(probeResult.origin, loginUsername, loginPassword); await loginToRemote(probeResult.origin, fallbackUsername, fallbackPassword);
setStep('done'); setStep('done');
onDone(); onDone();
} catch (err) { } catch (err) {
@@ -119,8 +126,8 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
</> </>
)} )}
{/* Step 2: Auth */} {/* Step 2: Auth — single password */}
{step === 'auth' && probeResult && ( {step === 'auth' && probeResult && authPhase === 'password' && (
<> <>
{/* Instance info card */} {/* Instance info card */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -131,109 +138,104 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
</div> </div>
</div> </div>
{/* Auth tabs */}
<div className="flex gap-1 bg-surface-input rounded p-0.5">
{probeResult.registrationOpen && (
<button
onClick={() => setAuthTab('register')}
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded transition-colors ${
authTab === 'register'
? 'bg-surface-channel text-txt-primary'
: 'text-txt-tertiary hover:text-txt-secondary'
}`}
>
Register
</button>
)}
<button
onClick={() => setAuthTab('login')}
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded transition-colors ${
authTab === 'login'
? 'bg-surface-channel text-txt-primary'
: 'text-txt-tertiary hover:text-txt-secondary'
}`}
>
Login
</button>
</div>
{/* Register form */}
{authTab === 'register' && (
<div className="space-y-2"> <div className="space-y-2">
<div> <div>
<label className="block text-xs text-txt-tertiary mb-1">Username</label> <label className="block text-xs text-txt-tertiary mb-1">
<input Enter your password to connect to {new URL(probeResult.origin).host}
type="text" </label>
value={user?.username ?? ''}
disabled
className="w-full px-3 py-2 bg-surface-input rounded text-txt-secondary text-sm opacity-60"
/>
<div className="text-xs text-txt-tertiary mt-1">
Your username will be replicated. If taken, it becomes {user?.username}@{window.location.host}
</div>
</div>
<div>
<label className="block text-xs text-txt-tertiary mb-1">Password for this instance</label>
<input <input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !isLoading && password && handleRegister()} onKeyDown={(e) => e.key === 'Enter' && !isLoading && password && handleConnect()}
placeholder="Create a password for this instance" placeholder="Your account password"
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary" className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
disabled={isLoading} disabled={isLoading}
autoFocus
/> />
<div className="text-xs text-txt-tertiary mt-1">
Your password is verified locally, then used to create or access your account on the remote instance.
</div>
</div> </div>
<button <button
onClick={handleRegister} onClick={handleConnect}
disabled={isLoading || !password} disabled={isLoading || !password}
className="w-full px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50" className="w-full px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
> >
{isLoading ? 'Registering...' : 'Register & Connect'} {isLoading ? 'Connecting...' : 'Connect'}
</button> </button>
</div> </div>
<div className="flex gap-2">
<button
onClick={() => { setStep('url'); setProbeResult(null); setError(''); }}
className="text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
>
Back
</button>
<button
onClick={onDone}
className="text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
>
Cancel
</button>
</div>
</>
)} )}
{/* Login form */} {/* Step 2b: Fallback login — different password on remote */}
{authTab === 'login' && ( {step === 'auth' && probeResult && authPhase === 'fallback-login' && (
<>
{/* Instance info card */}
<div className="flex items-center gap-2">
<StatusDot status="connecting" />
<div>
<div className="text-sm text-txt-primary font-medium">{probeResult.name}</div>
<div className="text-xs text-txt-tertiary">{probeResult.origin}</div>
</div>
</div>
<div className="p-2 bg-accent-amber/10 border border-accent-amber/30 rounded text-xs text-accent-amber">
An account already exists on this instance with a different password. Enter the credentials you used on that instance.
</div>
<div className="space-y-2"> <div className="space-y-2">
<div> <div>
<label className="block text-xs text-txt-tertiary mb-1">Username</label> <label className="block text-xs text-txt-tertiary mb-1">Username</label>
<input <input
type="text" type="text"
value={loginUsername} value={fallbackUsername}
onChange={(e) => setLoginUsername(e.target.value)} onChange={(e) => setFallbackUsername(e.target.value)}
placeholder="Your username on this instance" placeholder="Your username on this instance"
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary" className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
disabled={isLoading} disabled={isLoading}
/> />
</div> </div>
<div> <div>
<label className="block text-xs text-txt-tertiary mb-1">Password</label> <label className="block text-xs text-txt-tertiary mb-1">Password for this instance</label>
<input <input
type="password" type="password"
value={loginPassword} value={fallbackPassword}
onChange={(e) => setLoginPassword(e.target.value)} onChange={(e) => setFallbackPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !isLoading && loginUsername && loginPassword && handleLogin()} onKeyDown={(e) => e.key === 'Enter' && !isLoading && fallbackUsername && fallbackPassword && handleFallbackLogin()}
placeholder="Your password on this instance" placeholder="Password on the remote instance"
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary" className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
disabled={isLoading} disabled={isLoading}
autoFocus
/> />
</div> </div>
<button <button
onClick={handleLogin} onClick={handleFallbackLogin}
disabled={isLoading || !loginUsername || !loginPassword} disabled={isLoading || !fallbackUsername || !fallbackPassword}
className="w-full px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50" className="w-full px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
> >
{isLoading ? 'Logging in...' : 'Login & Connect'} {isLoading ? 'Logging in...' : 'Login & Connect'}
</button> </button>
</div> </div>
)}
{/* Back button */}
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => { setStep('url'); setProbeResult(null); setError(''); }} onClick={() => { setAuthPhase('password'); setPassword(''); setError(''); }}
className="text-xs text-txt-tertiary hover:text-txt-secondary transition-colors" className="text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
> >
Back Back
+4 -2
View File
@@ -83,7 +83,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
const { setUser } = useAuthStore.getState(); const { setUser } = useAuthStore.getState();
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useServerStore.getState(); const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useServerStore.getState();
const { addMessage, addRealtimeMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState(); const { addMessage, addRealtimeMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, setVoiceUsers, setVoiceUserStatus, clearVoiceUserStatus } = useVoiceStore.getState(); const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, clearVoiceUsersForOrigin, setVoiceUsers, setVoiceUserStatus, clearVoiceUserStatus } = useVoiceStore.getState();
switch (event.type) { switch (event.type) {
case 'ready': case 'ready':
@@ -124,9 +124,11 @@ function handleEvent(origin: string, event: ServerEvent): void {
} }
} }
// Voice states — process for all origins (shows who's in voice on remote servers) // Clear voice state for the reconnecting origin before repopulating
if (isHome) { if (isHome) {
clearAllVoiceUsers(); clearAllVoiceUsers();
} else {
clearVoiceUsersForOrigin(origin);
} }
if (event.voiceStates) { if (event.voiceStates) {
for (const [channelId, userIds] of Object.entries(event.voiceStates)) { for (const [channelId, userIds] of Object.entries(event.voiceStates)) {
+76 -15
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { User, InstanceInfoResponse, ReplicatedInstance } from '@backspace/shared'; import type { User, InstanceInfoResponse, ReplicatedInstance, AuthResponse } from '@backspace/shared';
import { BackspaceApiClient, createApiClient, api } from '../api/client'; import { BackspaceApiClient, createApiClient, api } from '../api/client';
import { useAuthStore } from './authStore'; import { useAuthStore } from './authStore';
import { setApiForOriginResolver, useServerStore } from './serverStore'; import { setApiForOriginResolver, useServerStore } from './serverStore';
@@ -50,6 +50,16 @@ function saveCachedTokens(instances: ConnectedInstance[]): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(cache)); localStorage.setItem(STORAGE_KEY, JSON.stringify(cache));
} }
// ─── Error types ────────────────────────────────────────────────────────────
/** Thrown when the remote instance already has an account for this user with a different password. */
export class DifferentPasswordError extends Error {
constructor(public remoteUsername: string) {
super('Account exists with a different password on this instance');
this.name = 'DifferentPasswordError';
}
}
// ─── URL normalization ─────────────────────────────────────────────────────── // ─── URL normalization ───────────────────────────────────────────────────────
function normalizeOrigin(url: string): string { function normalizeOrigin(url: string): string {
@@ -78,7 +88,7 @@ interface InstanceState {
error: string | null; error: string | null;
probeInstance: (url: string) => Promise<InstanceInfoResponse & { origin: string }>; probeInstance: (url: string) => Promise<InstanceInfoResponse & { origin: string }>;
registerOnRemote: (origin: string, password: string, displayName?: string) => Promise<void>; connectToRemote: (origin: string, password: string, displayName?: string) => Promise<void>;
loginToRemote: (origin: string, username: string, password: string) => Promise<void>; loginToRemote: (origin: string, username: string, password: string) => Promise<void>;
removeInstance: (origin: string) => void; removeInstance: (origin: string) => void;
syncInstanceList: () => Promise<void>; syncInstanceList: () => Promise<void>;
@@ -111,21 +121,29 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
return { ...info, origin }; return { ...info, origin };
}, },
registerOnRemote: async (origin: string, password: string, displayName?: string) => { connectToRemote: async (origin: string, password: string, displayName?: string) => {
const currentUser = useAuthStore.getState().user; const currentUser = useAuthStore.getState().user;
if (!currentUser) throw new Error('Not logged in'); if (!currentUser) throw new Error('Not logged in');
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
// Step 1: Verify password against HOME instance
const { valid } = await api.users.verifyPassword(password);
if (!valid) {
throw new Error('Incorrect password');
}
// Step 2: Try register on remote, then fall back to login
const homeInstance = window.location.host; const homeInstance = window.location.host;
const tempClient = createApiClient(origin, () => null); const tempClient = createApiClient(origin, () => null);
let response; let response: AuthResponse | null = null;
let finalUsername = currentUser.username; let finalUsername = currentUser.username;
let needsLogin = false;
// 2a: Attempt registration with plain username
try { try {
// First attempt: register with current username
response = await tempClient.auth.register({ response = await tempClient.auth.register({
username: currentUser.username, username: currentUser.username,
password, password,
@@ -134,8 +152,9 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
}); });
} catch (err) { } catch (err) {
const message = (err as Error).message; const message = (err as Error).message;
// If username taken, retry with domain-qualified username
if (message.includes('already taken') || message.includes('409')) { if (message.includes('already taken') || message.includes('409')) {
// Username collision — try domain-qualified username
try {
finalUsername = `${currentUser.username}@${homeInstance}`; finalUsername = `${currentUser.username}@${homeInstance}`;
response = await tempClient.auth.register({ response = await tempClient.auth.register({
username: finalUsername, username: finalUsername,
@@ -143,15 +162,52 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
displayName: displayName || currentUser.displayName || undefined, displayName: displayName || currentUser.displayName || undefined,
homeInstance, homeInstance,
}); });
} catch (err2) {
const msg2 = (err2 as Error).message;
if (msg2.includes('already taken') || msg2.includes('409')) {
// Both usernames exist on remote — fall through to login
needsLogin = true;
} else {
throw err2;
}
}
} else if (message.includes('Registration is currently closed') || message.includes('403')) {
// Registration closed on remote — fall through to login
needsLogin = true;
} else { } else {
throw err; throw err;
} }
} }
// Fetch instance info for the label // 2b: If registration didn't work, try login with the same password
const info = await tempClient.instance.info(); if (needsLogin) {
// Try plain username first, then domain-qualified
try {
response = await tempClient.auth.login({
username: currentUser.username,
password,
});
finalUsername = currentUser.username;
} catch {
try {
finalUsername = `${currentUser.username}@${homeInstance}`;
response = await tempClient.auth.login({
username: finalUsername,
password,
});
} catch {
// Both login attempts failed — different password scenario
throw new DifferentPasswordError(currentUser.username);
}
}
}
// Create authenticated client if (!response) {
throw new Error('Failed to authenticate with remote instance');
}
// Step 3: Complete connection
const info = await tempClient.instance.info();
const authenticatedClient = createApiClient(origin, () => response.token); const authenticatedClient = createApiClient(origin, () => response.token);
const instance: ConnectedInstance = { const instance: ConnectedInstance = {
@@ -244,7 +300,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
// Build the replicated instances list from all connected remotes // Build the replicated instances list from all connected remotes
const replicatedInstances: ReplicatedInstance[] = instances.map(inst => ({ const replicatedInstances: ReplicatedInstance[] = instances.map(inst => ({
domain: new URL(inst.origin).host, origin: inst.origin,
username: inst.username, username: inst.username,
})); }));
@@ -271,8 +327,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
const cached = loadCachedTokens(); const cached = loadCachedTokens();
const toConnect = currentUser.replicatedInstances.filter(ri => { const toConnect = currentUser.replicatedInstances.filter(ri => {
// Build origin from domain const origin = ri.origin || `https://${ri.domain}`;
const origin = `https://${ri.domain}`;
// Only attempt if we have a cached token and aren't already connected // Only attempt if we have a cached token and aren't already connected
return cached[origin] && !get().instances.some(i => i.origin === origin); return cached[origin] && !get().instances.some(i => i.origin === origin);
}); });
@@ -282,7 +337,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
// Connect all in parallel, individual failures are non-blocking // Connect all in parallel, individual failures are non-blocking
const results = await Promise.allSettled( const results = await Promise.allSettled(
toConnect.map(async (ri) => { toConnect.map(async (ri) => {
const origin = `https://${ri.domain}`; const origin = ri.origin || `https://${ri.domain}`;
const cachedEntry = cached[origin]!; // Guaranteed by filter above const cachedEntry = cached[origin]!; // Guaranteed by filter above
// Create client with cached token // Create client with cached token
@@ -291,7 +346,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
// Set as connecting // Set as connecting
const connectingInstance: ConnectedInstance = { const connectingInstance: ConnectedInstance = {
origin, origin,
label: cachedEntry.label || ri.domain, label: cachedEntry.label || new URL(origin).host,
token: cachedEntry.token, token: cachedEntry.token,
user: currentUser, // Placeholder until we verify user: currentUser, // Placeholder until we verify
username: cachedEntry.username || ri.username, username: cachedEntry.username || ri.username,
@@ -308,7 +363,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
const user = await client.users.me(); const user = await client.users.me();
// Fetch instance info for fresh label // Fetch instance info for fresh label
let label = cachedEntry.label || ri.domain; let label = cachedEntry.label || new URL(origin).host;
try { try {
const info = await client.instance.info(); const info = await client.instance.info();
label = info.name; label = info.name;
@@ -356,6 +411,12 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
}, },
reset: () => { reset: () => {
// Clean up server store for each connected remote instance before tearing down
const { instances } = get();
for (const inst of instances) {
useServerStore.getState().removeInstanceServers(inst.origin);
}
// Tear down all remote WebSocket connections // Tear down all remote WebSocket connections
disconnectAllRemote(); disconnectAllRemote();
+15
View File
@@ -2,6 +2,7 @@ import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware'; import { persist, createJSONStorage } from 'zustand/middleware';
import type { ParticipantInfo } from '../hooks/useLiveKit'; import type { ParticipantInfo } from '../hooks/useLiveKit';
import { AudioManager } from '../audio/AudioManager'; import { AudioManager } from '../audio/AudioManager';
import { useServerStore } from './serverStore';
export interface ScreenShareConfig { export interface ScreenShareConfig {
height: 1080 | 720 | 540; height: 1080 | 720 | 540;
@@ -87,6 +88,7 @@ interface VoiceState {
clearVoiceUserStatus: (userId: string) => void; clearVoiceUserStatus: (userId: string) => void;
getVoiceUsers: (channelId: string) => string[]; getVoiceUsers: (channelId: string) => string[];
clearAllVoiceUsers: () => void; clearAllVoiceUsers: () => void;
clearVoiceUsersForOrigin: (origin: string) => void;
leaveVoice: () => void; leaveVoice: () => void;
reset: () => void; reset: () => void;
} }
@@ -275,6 +277,19 @@ export const useVoiceStore = create<VoiceState>()(
clearAllVoiceUsers: () => set({ voiceUsers: new Map(), voiceUserStates: new Map() }), clearAllVoiceUsers: () => set({ voiceUsers: new Map(), voiceUserStates: new Map() }),
clearVoiceUsersForOrigin: (origin: string) => {
const { channelOriginMap } = useServerStore.getState();
set((state) => {
const newVoiceUsers = new Map(state.voiceUsers);
for (const [channelId] of newVoiceUsers) {
if ((channelOriginMap.get(channelId) ?? '') === origin) {
newVoiceUsers.delete(channelId);
}
}
return { voiceUsers: newVoiceUsers };
});
},
// Leave voice without wiping the voiceUsers map (so sidebar still shows others) // Leave voice without wiping the voiceUsers map (so sidebar still shows others)
leaveVoice: () => set({ leaveVoice: () => set({
currentVoiceChannelId: null, currentVoiceChannelId: null,
+11 -2
View File
@@ -23,9 +23,9 @@ export function normalizeUserAssets<T extends { avatar?: string | null }>(user:
/** /**
* Rewrite user.avatar and attachment filenames on a message for remote origins. * Rewrite user.avatar and attachment filenames on a message for remote origins.
* Mutates in-place. * Also normalizes nested replyTo message assets. Mutates in-place.
*/ */
export function normalizeMessageAssets<T extends { user: { avatar?: string | null }; attachments?: { filename: string }[] }>( export function normalizeMessageAssets<T extends { user: { avatar?: string | null }; attachments?: { filename: string }[]; replyTo?: { user: { avatar?: string | null }; attachments?: { filename: string }[] } | null }>(
message: T, message: T,
origin: string, origin: string,
): T { ): T {
@@ -36,5 +36,14 @@ export function normalizeMessageAssets<T extends { user: { avatar?: string | nul
att.filename = resolveAssetUrl(att.filename, origin) ?? att.filename; att.filename = resolveAssetUrl(att.filename, origin) ?? att.filename;
} }
} }
// Normalize reply-to message assets (remote replies have relative URLs)
if (message.replyTo) {
normalizeUserAssets(message.replyTo.user, origin);
if (message.replyTo.attachments) {
for (const att of message.replyTo.attachments) {
att.filename = resolveAssetUrl(att.filename, origin) ?? att.filename;
}
}
}
return message; return message;
} }