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:
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { InstanceInfoResponse } from '@backspace/shared';
|
||||
import { useInstanceStore } from '../../stores/instanceStore';
|
||||
import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
// ─── Status indicator ────────────────────────────────────────────────────────
|
||||
@@ -17,21 +17,21 @@ function StatusDot({ status }: { status: string }) {
|
||||
// ─── Add Instance flow ───────────────────────────────────────────────────────
|
||||
|
||||
type AddStep = 'url' | 'auth' | 'done';
|
||||
type AuthTab = 'register' | 'login';
|
||||
type AuthPhase = 'password' | 'fallback-login';
|
||||
|
||||
function AddInstanceFlow({ onDone }: { onDone: () => void }) {
|
||||
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 probeInstance = useInstanceStore((s) => s.probeInstance);
|
||||
|
||||
const [step, setStep] = useState<AddStep>('url');
|
||||
const [url, setUrl] = useState('');
|
||||
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 [loginUsername, setLoginUsername] = useState('');
|
||||
const [loginPassword, setLoginPassword] = useState('');
|
||||
const [fallbackUsername, setFallbackUsername] = useState('');
|
||||
const [fallbackPassword, setFallbackPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -41,7 +41,7 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
|
||||
try {
|
||||
const result = await probeInstance(url);
|
||||
setProbeResult(result);
|
||||
setAuthTab(result.registrationOpen ? 'register' : 'login');
|
||||
setAuthPhase('password');
|
||||
setStep('auth');
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
@@ -50,12 +50,12 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
const handleConnect = async () => {
|
||||
if (!probeResult) return;
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await registerOnRemote(
|
||||
await connectToRemote(
|
||||
probeResult.origin,
|
||||
password,
|
||||
user?.displayName || undefined,
|
||||
@@ -63,18 +63,25 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
|
||||
setStep('done');
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
if (err instanceof DifferentPasswordError) {
|
||||
setAuthPhase('fallback-login');
|
||||
setFallbackUsername(err.remoteUsername);
|
||||
setFallbackPassword('');
|
||||
setError('');
|
||||
} else {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
const handleFallbackLogin = async () => {
|
||||
if (!probeResult) return;
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await loginToRemote(probeResult.origin, loginUsername, loginPassword);
|
||||
await loginToRemote(probeResult.origin, fallbackUsername, fallbackPassword);
|
||||
setStep('done');
|
||||
onDone();
|
||||
} catch (err) {
|
||||
@@ -119,8 +126,8 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 2: Auth */}
|
||||
{step === 'auth' && probeResult && (
|
||||
{/* Step 2: Auth — single password */}
|
||||
{step === 'auth' && probeResult && authPhase === 'password' && (
|
||||
<>
|
||||
{/* Instance info card */}
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -131,106 +138,34 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
|
||||
</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>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="block text-xs text-txt-tertiary mb-1">
|
||||
Enter your password to connect to {new URL(probeResult.origin).host}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !isLoading && password && handleConnect()}
|
||||
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"
|
||||
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>
|
||||
<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'
|
||||
}`}
|
||||
onClick={handleConnect}
|
||||
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"
|
||||
>
|
||||
Login
|
||||
{isLoading ? 'Connecting...' : 'Connect'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Register form */}
|
||||
{authTab === 'register' && (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="block text-xs text-txt-tertiary mb-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
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
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !isLoading && password && handleRegister()}
|
||||
placeholder="Create a password for 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"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRegister}
|
||||
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"
|
||||
>
|
||||
{isLoading ? 'Registering...' : 'Register & Connect'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login form */}
|
||||
{authTab === 'login' && (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="block text-xs text-txt-tertiary mb-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={loginUsername}
|
||||
onChange={(e) => setLoginUsername(e.target.value)}
|
||||
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"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-txt-tertiary mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={loginPassword}
|
||||
onChange={(e) => setLoginPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !isLoading && loginUsername && loginPassword && handleLogin()}
|
||||
placeholder="Your password 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"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading || !loginUsername || !loginPassword}
|
||||
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'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Back button */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setStep('url'); setProbeResult(null); setError(''); }}
|
||||
@@ -248,6 +183,73 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 2b: Fallback login — different password on remote */}
|
||||
{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>
|
||||
<label className="block text-xs text-txt-tertiary mb-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fallbackUsername}
|
||||
onChange={(e) => setFallbackUsername(e.target.value)}
|
||||
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"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-txt-tertiary mb-1">Password for this instance</label>
|
||||
<input
|
||||
type="password"
|
||||
value={fallbackPassword}
|
||||
onChange={(e) => setFallbackPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !isLoading && fallbackUsername && fallbackPassword && handleFallbackLogin()}
|
||||
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"
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleFallbackLogin}
|
||||
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"
|
||||
>
|
||||
{isLoading ? 'Logging in...' : 'Login & Connect'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setAuthPhase('password'); setPassword(''); 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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-xs">
|
||||
|
||||
@@ -83,7 +83,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
const { setUser } = useAuthStore.getState();
|
||||
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useServerStore.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) {
|
||||
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) {
|
||||
clearAllVoiceUsers();
|
||||
} else {
|
||||
clearVoiceUsersForOrigin(origin);
|
||||
}
|
||||
if (event.voiceStates) {
|
||||
for (const [channelId, userIds] of Object.entries(event.voiceStates)) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useAuthStore } from './authStore';
|
||||
import { setApiForOriginResolver, useServerStore } from './serverStore';
|
||||
@@ -50,6 +50,16 @@ function saveCachedTokens(instances: ConnectedInstance[]): void {
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
function normalizeOrigin(url: string): string {
|
||||
@@ -78,7 +88,7 @@ interface InstanceState {
|
||||
error: string | null;
|
||||
|
||||
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>;
|
||||
removeInstance: (origin: string) => void;
|
||||
syncInstanceList: () => Promise<void>;
|
||||
@@ -111,21 +121,29 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
||||
return { ...info, origin };
|
||||
},
|
||||
|
||||
registerOnRemote: async (origin: string, password: string, displayName?: string) => {
|
||||
connectToRemote: async (origin: string, password: string, displayName?: string) => {
|
||||
const currentUser = useAuthStore.getState().user;
|
||||
if (!currentUser) throw new Error('Not logged in');
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
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 tempClient = createApiClient(origin, () => null);
|
||||
|
||||
let response;
|
||||
let response: AuthResponse | null = null;
|
||||
let finalUsername = currentUser.username;
|
||||
let needsLogin = false;
|
||||
|
||||
// 2a: Attempt registration with plain username
|
||||
try {
|
||||
// First attempt: register with current username
|
||||
response = await tempClient.auth.register({
|
||||
username: currentUser.username,
|
||||
password,
|
||||
@@ -134,24 +152,62 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
||||
});
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
// If username taken, retry with domain-qualified username
|
||||
if (message.includes('already taken') || message.includes('409')) {
|
||||
finalUsername = `${currentUser.username}@${homeInstance}`;
|
||||
response = await tempClient.auth.register({
|
||||
username: finalUsername,
|
||||
password,
|
||||
displayName: displayName || currentUser.displayName || undefined,
|
||||
homeInstance,
|
||||
});
|
||||
// Username collision — try domain-qualified username
|
||||
try {
|
||||
finalUsername = `${currentUser.username}@${homeInstance}`;
|
||||
response = await tempClient.auth.register({
|
||||
username: finalUsername,
|
||||
password,
|
||||
displayName: displayName || currentUser.displayName || undefined,
|
||||
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 {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch instance info for the label
|
||||
const info = await tempClient.instance.info();
|
||||
// 2b: If registration didn't work, try login with the same password
|
||||
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 instance: ConnectedInstance = {
|
||||
@@ -244,7 +300,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
||||
|
||||
// Build the replicated instances list from all connected remotes
|
||||
const replicatedInstances: ReplicatedInstance[] = instances.map(inst => ({
|
||||
domain: new URL(inst.origin).host,
|
||||
origin: inst.origin,
|
||||
username: inst.username,
|
||||
}));
|
||||
|
||||
@@ -271,8 +327,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
||||
|
||||
const cached = loadCachedTokens();
|
||||
const toConnect = currentUser.replicatedInstances.filter(ri => {
|
||||
// Build origin from domain
|
||||
const origin = `https://${ri.domain}`;
|
||||
const origin = ri.origin || `https://${ri.domain}`;
|
||||
// Only attempt if we have a cached token and aren't already connected
|
||||
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
|
||||
const results = await Promise.allSettled(
|
||||
toConnect.map(async (ri) => {
|
||||
const origin = `https://${ri.domain}`;
|
||||
const origin = ri.origin || `https://${ri.domain}`;
|
||||
const cachedEntry = cached[origin]!; // Guaranteed by filter above
|
||||
|
||||
// Create client with cached token
|
||||
@@ -291,7 +346,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
||||
// Set as connecting
|
||||
const connectingInstance: ConnectedInstance = {
|
||||
origin,
|
||||
label: cachedEntry.label || ri.domain,
|
||||
label: cachedEntry.label || new URL(origin).host,
|
||||
token: cachedEntry.token,
|
||||
user: currentUser, // Placeholder until we verify
|
||||
username: cachedEntry.username || ri.username,
|
||||
@@ -308,7 +363,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
||||
const user = await client.users.me();
|
||||
|
||||
// Fetch instance info for fresh label
|
||||
let label = cachedEntry.label || ri.domain;
|
||||
let label = cachedEntry.label || new URL(origin).host;
|
||||
try {
|
||||
const info = await client.instance.info();
|
||||
label = info.name;
|
||||
@@ -356,6 +411,12 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
||||
},
|
||||
|
||||
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
|
||||
disconnectAllRemote();
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import type { ParticipantInfo } from '../hooks/useLiveKit';
|
||||
import { AudioManager } from '../audio/AudioManager';
|
||||
import { useServerStore } from './serverStore';
|
||||
|
||||
export interface ScreenShareConfig {
|
||||
height: 1080 | 720 | 540;
|
||||
@@ -87,6 +88,7 @@ interface VoiceState {
|
||||
clearVoiceUserStatus: (userId: string) => void;
|
||||
getVoiceUsers: (channelId: string) => string[];
|
||||
clearAllVoiceUsers: () => void;
|
||||
clearVoiceUsersForOrigin: (origin: string) => void;
|
||||
leaveVoice: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
@@ -275,6 +277,19 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
|
||||
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)
|
||||
leaveVoice: () => set({
|
||||
currentVoiceChannelId: null,
|
||||
|
||||
@@ -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.
|
||||
* 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,
|
||||
origin: string,
|
||||
): T {
|
||||
@@ -36,5 +36,14 @@ export function normalizeMessageAssets<T extends { user: { avatar?: string | nul
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user