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