feat: normalize remote asset URLs and route UI actions by instance origin

Phase 5 of multi-instance federation. Adds a two-layer fix:

Layer 1 — Data ingestion normalization: Remote instance user avatars,
server icons, and attachment filenames are rewritten to absolute URLs
when entering the app (via WebSocket events or API responses), so all
downstream components render them correctly without changes.

Layer 2 — Outbound action routing: wsSend calls (voice join/leave/status,
typing) and file uploads in UI components now route through the correct
instance based on the active channel's origin.
This commit is contained in:
Jannis Braun
2026-03-03 00:28:24 +01:00
parent 72e07c1cc1
commit 758c6a7b5b
10 changed files with 114 additions and 28 deletions
+4 -3
View File
@@ -268,14 +268,15 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
<div className="mt-1 grid gap-2">
{message.attachments.map((att) => {
const isImage = att.mimetype.startsWith('image/');
const attUrl = att.filename.startsWith('http') ? att.filename : `/api/uploads/${att.filename}`;
if (isImage) {
return (
<div key={att.id} className="max-w-fit mt-1 rounded-lg overflow-hidden border border-white/[0.06]">
<img
src={`/api/uploads/${att.filename}`}
src={attUrl}
alt={att.originalName}
className="max-w-full max-h-[350px] object-contain cursor-pointer hover:brightness-95 transition-all"
onClick={() => openImagePreview(`/api/uploads/${att.filename}`)}
onClick={() => openImagePreview(attUrl)}
loading="lazy"
/>
</div>
@@ -284,7 +285,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
return (
<a
key={att.id}
href={`/api/uploads/${att.filename}`}
href={attUrl}
download={att.originalName}
className="flex items-center gap-3 p-4 bg-surface-channel/50 rounded-lg border border-border-hard hover:bg-interactive-hover transition-all max-w-[400px] mt-1 group/att"
>
@@ -1,8 +1,7 @@
import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { useChatStore } from '../../stores/chatStore';
import { isDmChannel, useServerStore } from '../../stores/serverStore';
import { isDmChannel, getChannelOrigin, getApiForOrigin, useServerStore } from '../../stores/serverStore';
import { wsSend } from '../../hooks/useWebSocket';
import { api } from '../../api/client';
import { MentionPopover } from './MentionPopover';
import type { MemberWithUser } from '@backspace/shared';
@@ -61,7 +60,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
if (isDm) {
wsSend({ type: 'dm_typing_start', dmChannelId: channelId });
} else {
wsSend({ type: 'typing_start', channelId });
wsSend({ type: 'typing_start', channelId }, getChannelOrigin(channelId));
}
typingTimeoutRef.current = setTimeout(() => {
typingTimeoutRef.current = undefined;
@@ -75,10 +74,11 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
setIsUploading(true);
setMentionState(null);
try {
// Upload files first
// Upload files first — route to the correct instance for this channel
const attachmentIds: string[] = [];
const uploadClient = getApiForOrigin(getChannelOrigin(channelId));
for (const file of files) {
const attachment = await api.uploads.upload(file);
const attachment = await uploadClient.uploads.upload(file);
attachmentIds.push(attachment.id);
}
@@ -1,6 +1,6 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useServerStore } from '../../stores/serverStore';
import { useServerStore, getChannelOrigin } from '../../stores/serverStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore';
@@ -38,7 +38,8 @@ export function ChannelSidebar() {
// Broadcast mute status via WebSocket so non-joined users can see it
const willBeMuted = !isMuted;
const { isCameraOn, isScreenSharing } = useVoiceStore.getState();
wsSend({ type: 'voice_status', isMuted: willBeMuted, isDeafened, isCameraOn, isScreenSharing });
const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
wsSend({ type: 'voice_status', isMuted: willBeMuted, isDeafened, isCameraOn, isScreenSharing }, voiceOrigin);
};
const handleDeafenToggle = async () => {
@@ -51,7 +52,8 @@ export function ChannelSidebar() {
// Broadcast status via WebSocket so non-joined users can see it
const willBeMuted = willDeafen ? true : false;
const { isCameraOn, isScreenSharing } = useVoiceStore.getState();
wsSend({ type: 'voice_status', isMuted: willBeMuted, isDeafened: willDeafen, isCameraOn, isScreenSharing });
const voiceOrigin2 = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
wsSend({ type: 'voice_status', isMuted: willBeMuted, isDeafened: willDeafen, isCameraOn, isScreenSharing }, voiceOrigin2);
if (room) {
try {
// Broadcast deafen state to other participants via LiveKit data channel
@@ -91,7 +93,7 @@ export function ChannelSidebar() {
return;
}
setCurrentVoiceChannel(channelId);
wsSend({ type: 'voice_join', channelId });
wsSend({ type: 'voice_join', channelId }, getChannelOrigin(channelId));
navigate(`/channels/${currentServerId}/${channelId}`);
};
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useServerStore } from '../../stores/serverStore';
import { useServerStore, getChannelOrigin } from '../../stores/serverStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore';
@@ -258,7 +258,7 @@ export function MainContent() {
<button
onClick={() => {
useVoiceStore.getState().setCurrentVoiceChannel(currentChannelId);
wsSend({ type: 'voice_join', channelId: currentChannelId });
wsSend({ type: 'voice_join', channelId: currentChannelId }, getChannelOrigin(currentChannelId));
}}
className="relative z-10 px-8 py-3 bg-accent-primary hover:bg-accent-primary-hover text-white font-semibold rounded-full transition-all text-[15px] shadow-[0_4px_20px_rgba(124,108,246,0.3)]"
>
@@ -3,6 +3,7 @@ import { useVoiceStore } from '../../stores/voiceStore';
import { useUIStore } from '../../stores/uiStore';
import { getActiveRoom } from '../../hooks/useLiveKit';
import { wsSend } from '../../hooks/useWebSocket';
import { getChannelOrigin } from '../../stores/serverStore';
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
import { CAMERA_PRESET, startScreenShare, stopScreenShare } from '../../utils/screenShare';
@@ -23,13 +24,15 @@ export function VoiceControlBar() {
const toggleVoiceChat = useUIStore((s) => s.toggleVoiceChat);
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
const toggleVoiceFullscreen = useUIStore((s) => s.toggleVoiceFullscreen);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
const [qualityOpen, setQualityOpen] = useState(false);
const handleMute = React.useCallback(async () => {
toggleMic();
// Broadcast via WebSocket so sidebar shows status without joining
wsSend({ type: 'voice_status', isMuted: !isMuted, isDeafened, isCameraOn, isScreenSharing });
}, [isMuted, isDeafened, isCameraOn, isScreenSharing, toggleMic]);
wsSend({ type: 'voice_status', isMuted: !isMuted, isDeafened, isCameraOn, isScreenSharing }, voiceOrigin);
}, [isMuted, isDeafened, isCameraOn, isScreenSharing, toggleMic, voiceOrigin]);
const handleDeafen = React.useCallback(async () => {
const room = getActiveRoom();
@@ -39,7 +42,7 @@ export function VoiceControlBar() {
if (willDeafen && !isMuted) toggleMic();
if (!willDeafen && isMuted) toggleMic();
// Broadcast via WebSocket
wsSend({ type: 'voice_status', isMuted: willDeafen, isDeafened: willDeafen, isCameraOn, isScreenSharing });
wsSend({ type: 'voice_status', isMuted: willDeafen, isDeafened: willDeafen, isCameraOn, isScreenSharing }, voiceOrigin);
if (room) {
try {
// Broadcast deafen state via LiveKit data channel for in-room users
@@ -74,7 +77,7 @@ export function VoiceControlBar() {
toggleCamera();
// Broadcast camera state via WebSocket
const { isMuted: m, isDeafened: d, isScreenSharing: ss } = useVoiceStore.getState();
wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: willEnable, isScreenSharing: ss });
wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: willEnable, isScreenSharing: ss }, voiceOrigin);
} catch (err) {
console.error('[VoiceControlBar] Failed to toggle camera:', err);
}
@@ -88,12 +91,12 @@ export function VoiceControlBar() {
const started = await startScreenShare(room);
if (started) {
const { isMuted: m, isDeafened: d, isCameraOn: c } = useVoiceStore.getState();
wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: true });
wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: true }, voiceOrigin);
}
} else {
await stopScreenShare(room);
const { isMuted: m, isDeafened: d, isCameraOn: c } = useVoiceStore.getState();
wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: false });
wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: false }, voiceOrigin);
}
} catch (err) {
console.error('[VoiceControlBar] Failed to toggle screen share:', err);
@@ -103,10 +106,10 @@ export function VoiceControlBar() {
const handleDisconnect = () => {
const { activeDmCall } = useVoiceStore.getState();
if (activeDmCall) {
wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId });
wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }); // DM calls are home-only
useVoiceStore.getState().setActiveDmCall(null);
} else {
wsSend({ type: 'voice_leave' });
wsSend({ type: 'voice_leave' }, voiceOrigin);
useVoiceStore.getState().leaveVoice();
}
if (voiceFullscreen) {
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { useServerStore } from '../../stores/serverStore';
import { useServerStore, getChannelOrigin } from '../../stores/serverStore';
import { getActiveRoom } from '../../hooks/useLiveKit';
import { wsSend } from '../../hooks/useWebSocket';
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
@@ -27,6 +27,8 @@ export function VoiceControls() {
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
if (!currentVoiceChannelId && !activeDmCall) return null;
const channel = channels.find(c => c.id === currentVoiceChannelId);
@@ -41,7 +43,7 @@ export function VoiceControls() {
toggleCamera();
// Broadcast camera state via WebSocket
const { isMuted: m, isDeafened: d, isScreenSharing: ss } = useVoiceStore.getState();
wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: willEnable, isScreenSharing: ss });
wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: willEnable, isScreenSharing: ss }, voiceOrigin);
} catch (err) {
console.error('[VoiceControls] Failed to toggle camera:', err);
}
@@ -55,12 +57,12 @@ export function VoiceControls() {
const started = await startScreenShare(room);
if (started) {
const { isMuted: m, isDeafened: d, isCameraOn: c } = useVoiceStore.getState();
wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: true });
wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: true }, voiceOrigin);
}
} else {
await stopScreenShare(room);
const { isMuted: m, isDeafened: d, isCameraOn: c } = useVoiceStore.getState();
wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: false });
wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: false }, voiceOrigin);
}
} catch (err) {
console.error('[VoiceControls] Failed to toggle screen share:', err);
@@ -70,10 +72,10 @@ export function VoiceControls() {
const handleDisconnect = () => {
const { activeDmCall } = useVoiceStore.getState();
if (activeDmCall) {
wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId });
wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }); // DM calls are home-only
useVoiceStore.getState().setActiveDmCall(null);
} else {
wsSend({ type: 'voice_leave' });
wsSend({ type: 'voice_leave' }, voiceOrigin);
useVoiceStore.getState().leaveVoice();
}
};
+19
View File
@@ -6,6 +6,7 @@ import { useVoiceStore } from '../stores/voiceStore';
import { useSocialStore } from '../stores/socialStore';
import { useSettingsStore } from '../stores/settingsStore';
import type { ServerEvent, ClientEvent, ActiveCallInfo } from '@backspace/shared';
import { resolveAssetUrl, normalizeUserAssets, normalizeMessageAssets } from '../utils/assetUrls';
// ─── Connection state ─────────────────────────────────────────────────────────
@@ -92,6 +93,18 @@ function handleEvent(origin: string, event: ServerEvent): void {
useSettingsStore.getState().fetchStreamingLimits();
}
// Normalize asset URLs for remote origins before dispatching to stores
if (!isHome) {
for (const server of event.servers) {
if (server.icon) server.icon = resolveAssetUrl(server.icon, origin) ?? server.icon;
if ((server as any).members) {
for (const member of (server as any).members) {
if (member.user) normalizeUserAssets(member.user, origin);
}
}
}
}
populateFromReady(origin, event.servers, event.folders, event.dmChannels);
if (isHome && currentServerId) {
@@ -169,6 +182,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
break;
case 'message_created':
if (!isHome) normalizeMessageAssets(event.message, origin);
addRealtimeMessage(event.message.channelId, event.message);
{
const { currentChannelId, markChannelUnread } = useChatStore.getState();
@@ -179,6 +193,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
break;
case 'message_updated':
if (!isHome) normalizeMessageAssets(event.message, origin);
updateMessage(event.message);
break;
@@ -210,6 +225,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
break;
case 'member_joined':
if (!isHome) normalizeUserAssets(event.member.user, origin);
addMember(event.member);
break;
@@ -435,6 +451,9 @@ function handleEvent(origin: string, event: ServerEvent): void {
}
case 'server_updated': {
if (!isHome && event.server.icon) {
event.server.icon = resolveAssetUrl(event.server.icon, origin) ?? event.server.icon;
}
const { servers: currentServers, setServers } = useServerStore.getState();
setServers(currentServers.map(s => s.id === event.server.id ? { ...s, ...event.server } : s));
break;
+11
View File
@@ -3,6 +3,7 @@ import type { MessageWithUser, Reaction, ReadState } from '@backspace/shared';
import { wsSend } from '../hooks/useWebSocket';
import { isDmChannel, getChannelOrigin, getApiForOrigin, useServerStore } from './serverStore';
import { useAuthStore } from './authStore';
import { normalizeMessageAssets } from '../utils/assetUrls';
const MAX_MESSAGES_PER_CHANNEL = 200;
const MAX_CACHED_CHANNELS = 20;
@@ -140,6 +141,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
? await client.dm.messages(channelId)
: await client.channels.messages(channelId);
// Normalize remote asset URLs (avatars, attachment filenames)
if (origin) {
for (const msg of messages) normalizeMessageAssets(msg, origin);
}
set((state) => {
const newMessages = new Map(state.messages);
newMessages.set(channelId, messages as MessageWithUser[]);
@@ -170,6 +176,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
? await client.dm.messages(channelId, oldestMessage.id)
: await client.channels.messages(channelId, oldestMessage.id);
// Normalize remote asset URLs (avatars, attachment filenames)
if (origin) {
for (const msg of olderMessages) normalizeMessageAssets(msg, origin);
}
set((state) => {
const newMessages = new Map(state.messages);
const current = newMessages.get(channelId) ?? [];
+8
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand';
import type { Server, Channel, MemberWithUser, ServerWithChannelsAndMembers, Role, ServerFolder, DmChannel, User } from '@backspace/shared';
import { api, BackspaceApiClient } from '../api/client';
import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls';
// ─── Instance-aware types ─────────────────────────────────────────────────────
@@ -124,6 +125,13 @@ export const useServerStore = create<ServerState>((set, get) => ({
const client = getApiForOrigin(origin);
const detail = await client.servers.get(serverId);
// Normalize remote asset URLs (avatars, server icon)
if (origin) {
if (detail.icon) detail.icon = resolveAssetUrl(detail.icon, origin) ?? detail.icon;
for (const member of detail.members) {
normalizeUserAssets(member.user, origin);
}
}
set({
currentServerId: serverId,
channels: detail.channels.sort((a, b) => a.position - b.position),
+40
View File
@@ -0,0 +1,40 @@
import { getApiForOrigin } from '../stores/serverStore';
/**
* Resolve a relative asset filename to an absolute URL for remote origins.
* Home-origin filenames are returned as-is (components handle the /api/uploads/ prefix).
* Already-absolute URLs (starting with 'http') pass through unchanged.
*/
export function resolveAssetUrl(filename: string | null | undefined, origin: string): typeof filename {
if (!filename || !origin || filename.startsWith('http')) return filename;
return getApiForOrigin(origin).uploads.url(filename);
}
/**
* Rewrite the avatar field on a user-like object for remote origins.
* Mutates in-place for efficiency (called on arrays of members/messages).
*/
export function normalizeUserAssets<T extends { avatar?: string | null }>(user: T, origin: string): T {
if (origin && user.avatar) {
user.avatar = resolveAssetUrl(user.avatar, origin) ?? user.avatar;
}
return user;
}
/**
* Rewrite user.avatar and attachment filenames on a message for remote origins.
* Mutates in-place.
*/
export function normalizeMessageAssets<T extends { user: { avatar?: string | null }; attachments?: { filename: string }[] }>(
message: T,
origin: string,
): T {
if (!origin) return message;
normalizeUserAssets(message.user, origin);
if (message.attachments) {
for (const att of message.attachments) {
att.filename = resolveAssetUrl(att.filename, origin) ?? att.filename;
}
}
return message;
}