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:
@@ -268,14 +268,15 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
|||||||
<div className="mt-1 grid gap-2">
|
<div className="mt-1 grid gap-2">
|
||||||
{message.attachments.map((att) => {
|
{message.attachments.map((att) => {
|
||||||
const isImage = att.mimetype.startsWith('image/');
|
const isImage = att.mimetype.startsWith('image/');
|
||||||
|
const attUrl = att.filename.startsWith('http') ? att.filename : `/api/uploads/${att.filename}`;
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
return (
|
return (
|
||||||
<div key={att.id} className="max-w-fit mt-1 rounded-lg overflow-hidden border border-white/[0.06]">
|
<div key={att.id} className="max-w-fit mt-1 rounded-lg overflow-hidden border border-white/[0.06]">
|
||||||
<img
|
<img
|
||||||
src={`/api/uploads/${att.filename}`}
|
src={attUrl}
|
||||||
alt={att.originalName}
|
alt={att.originalName}
|
||||||
className="max-w-full max-h-[350px] object-contain cursor-pointer hover:brightness-95 transition-all"
|
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"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,7 +285,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
|||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
key={att.id}
|
key={att.id}
|
||||||
href={`/api/uploads/${att.filename}`}
|
href={attUrl}
|
||||||
download={att.originalName}
|
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"
|
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 React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
||||||
import { useChatStore } from '../../stores/chatStore';
|
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 { wsSend } from '../../hooks/useWebSocket';
|
||||||
import { api } from '../../api/client';
|
|
||||||
import { MentionPopover } from './MentionPopover';
|
import { MentionPopover } from './MentionPopover';
|
||||||
import type { MemberWithUser } from '@backspace/shared';
|
import type { MemberWithUser } from '@backspace/shared';
|
||||||
|
|
||||||
@@ -61,7 +60,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
if (isDm) {
|
if (isDm) {
|
||||||
wsSend({ type: 'dm_typing_start', dmChannelId: channelId });
|
wsSend({ type: 'dm_typing_start', dmChannelId: channelId });
|
||||||
} else {
|
} else {
|
||||||
wsSend({ type: 'typing_start', channelId });
|
wsSend({ type: 'typing_start', channelId }, getChannelOrigin(channelId));
|
||||||
}
|
}
|
||||||
typingTimeoutRef.current = setTimeout(() => {
|
typingTimeoutRef.current = setTimeout(() => {
|
||||||
typingTimeoutRef.current = undefined;
|
typingTimeoutRef.current = undefined;
|
||||||
@@ -75,10 +74,11 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
setMentionState(null);
|
setMentionState(null);
|
||||||
try {
|
try {
|
||||||
// Upload files first
|
// Upload files first — route to the correct instance for this channel
|
||||||
const attachmentIds: string[] = [];
|
const attachmentIds: string[] = [];
|
||||||
|
const uploadClient = getApiForOrigin(getChannelOrigin(channelId));
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const attachment = await api.uploads.upload(file);
|
const attachment = await uploadClient.uploads.upload(file);
|
||||||
attachmentIds.push(attachment.id);
|
attachmentIds.push(attachment.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore, getChannelOrigin } from '../../stores/serverStore';
|
||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
@@ -38,7 +38,8 @@ export function ChannelSidebar() {
|
|||||||
// Broadcast mute status via WebSocket so non-joined users can see it
|
// Broadcast mute status via WebSocket so non-joined users can see it
|
||||||
const willBeMuted = !isMuted;
|
const willBeMuted = !isMuted;
|
||||||
const { isCameraOn, isScreenSharing } = useVoiceStore.getState();
|
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 () => {
|
const handleDeafenToggle = async () => {
|
||||||
@@ -51,7 +52,8 @@ export function ChannelSidebar() {
|
|||||||
// Broadcast status via WebSocket so non-joined users can see it
|
// Broadcast status via WebSocket so non-joined users can see it
|
||||||
const willBeMuted = willDeafen ? true : false;
|
const willBeMuted = willDeafen ? true : false;
|
||||||
const { isCameraOn, isScreenSharing } = useVoiceStore.getState();
|
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) {
|
if (room) {
|
||||||
try {
|
try {
|
||||||
// Broadcast deafen state to other participants via LiveKit data channel
|
// Broadcast deafen state to other participants via LiveKit data channel
|
||||||
@@ -91,7 +93,7 @@ export function ChannelSidebar() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCurrentVoiceChannel(channelId);
|
setCurrentVoiceChannel(channelId);
|
||||||
wsSend({ type: 'voice_join', channelId });
|
wsSend({ type: 'voice_join', channelId }, getChannelOrigin(channelId));
|
||||||
navigate(`/channels/${currentServerId}/${channelId}`);
|
navigate(`/channels/${currentServerId}/${channelId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
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 { useChatStore } from '../../stores/chatStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
@@ -258,7 +258,7 @@ export function MainContent() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
useVoiceStore.getState().setCurrentVoiceChannel(currentChannelId);
|
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)]"
|
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 { useUIStore } from '../../stores/uiStore';
|
||||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||||
import { wsSend } from '../../hooks/useWebSocket';
|
import { wsSend } from '../../hooks/useWebSocket';
|
||||||
|
import { getChannelOrigin } from '../../stores/serverStore';
|
||||||
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
||||||
import { CAMERA_PRESET, startScreenShare, stopScreenShare } from '../../utils/screenShare';
|
import { CAMERA_PRESET, startScreenShare, stopScreenShare } from '../../utils/screenShare';
|
||||||
|
|
||||||
@@ -23,13 +24,15 @@ export function VoiceControlBar() {
|
|||||||
const toggleVoiceChat = useUIStore((s) => s.toggleVoiceChat);
|
const toggleVoiceChat = useUIStore((s) => s.toggleVoiceChat);
|
||||||
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
|
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
|
||||||
const toggleVoiceFullscreen = useUIStore((s) => s.toggleVoiceFullscreen);
|
const toggleVoiceFullscreen = useUIStore((s) => s.toggleVoiceFullscreen);
|
||||||
|
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||||
|
const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
|
||||||
const [qualityOpen, setQualityOpen] = useState(false);
|
const [qualityOpen, setQualityOpen] = useState(false);
|
||||||
|
|
||||||
const handleMute = React.useCallback(async () => {
|
const handleMute = React.useCallback(async () => {
|
||||||
toggleMic();
|
toggleMic();
|
||||||
// Broadcast via WebSocket so sidebar shows status without joining
|
// Broadcast via WebSocket so sidebar shows status without joining
|
||||||
wsSend({ type: 'voice_status', isMuted: !isMuted, isDeafened, isCameraOn, isScreenSharing });
|
wsSend({ type: 'voice_status', isMuted: !isMuted, isDeafened, isCameraOn, isScreenSharing }, voiceOrigin);
|
||||||
}, [isMuted, isDeafened, isCameraOn, isScreenSharing, toggleMic]);
|
}, [isMuted, isDeafened, isCameraOn, isScreenSharing, toggleMic, voiceOrigin]);
|
||||||
|
|
||||||
const handleDeafen = React.useCallback(async () => {
|
const handleDeafen = React.useCallback(async () => {
|
||||||
const room = getActiveRoom();
|
const room = getActiveRoom();
|
||||||
@@ -39,7 +42,7 @@ export function VoiceControlBar() {
|
|||||||
if (willDeafen && !isMuted) toggleMic();
|
if (willDeafen && !isMuted) toggleMic();
|
||||||
if (!willDeafen && isMuted) toggleMic();
|
if (!willDeafen && isMuted) toggleMic();
|
||||||
// Broadcast via WebSocket
|
// 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) {
|
if (room) {
|
||||||
try {
|
try {
|
||||||
// Broadcast deafen state via LiveKit data channel for in-room users
|
// Broadcast deafen state via LiveKit data channel for in-room users
|
||||||
@@ -74,7 +77,7 @@ export function VoiceControlBar() {
|
|||||||
toggleCamera();
|
toggleCamera();
|
||||||
// Broadcast camera state via WebSocket
|
// Broadcast camera state via WebSocket
|
||||||
const { isMuted: m, isDeafened: d, isScreenSharing: ss } = useVoiceStore.getState();
|
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) {
|
} catch (err) {
|
||||||
console.error('[VoiceControlBar] Failed to toggle camera:', err);
|
console.error('[VoiceControlBar] Failed to toggle camera:', err);
|
||||||
}
|
}
|
||||||
@@ -88,12 +91,12 @@ export function VoiceControlBar() {
|
|||||||
const started = await startScreenShare(room);
|
const started = await startScreenShare(room);
|
||||||
if (started) {
|
if (started) {
|
||||||
const { isMuted: m, isDeafened: d, isCameraOn: c } = useVoiceStore.getState();
|
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 {
|
} else {
|
||||||
await stopScreenShare(room);
|
await stopScreenShare(room);
|
||||||
const { isMuted: m, isDeafened: d, isCameraOn: c } = useVoiceStore.getState();
|
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) {
|
} catch (err) {
|
||||||
console.error('[VoiceControlBar] Failed to toggle screen share:', err);
|
console.error('[VoiceControlBar] Failed to toggle screen share:', err);
|
||||||
@@ -103,10 +106,10 @@ export function VoiceControlBar() {
|
|||||||
const handleDisconnect = () => {
|
const handleDisconnect = () => {
|
||||||
const { activeDmCall } = useVoiceStore.getState();
|
const { activeDmCall } = useVoiceStore.getState();
|
||||||
if (activeDmCall) {
|
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);
|
useVoiceStore.getState().setActiveDmCall(null);
|
||||||
} else {
|
} else {
|
||||||
wsSend({ type: 'voice_leave' });
|
wsSend({ type: 'voice_leave' }, voiceOrigin);
|
||||||
useVoiceStore.getState().leaveVoice();
|
useVoiceStore.getState().leaveVoice();
|
||||||
}
|
}
|
||||||
if (voiceFullscreen) {
|
if (voiceFullscreen) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore, getChannelOrigin } from '../../stores/serverStore';
|
||||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||||
import { wsSend } from '../../hooks/useWebSocket';
|
import { wsSend } from '../../hooks/useWebSocket';
|
||||||
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
||||||
@@ -27,6 +27,8 @@ export function VoiceControls() {
|
|||||||
|
|
||||||
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
|
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
|
||||||
|
|
||||||
|
const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
|
||||||
|
|
||||||
if (!currentVoiceChannelId && !activeDmCall) return null;
|
if (!currentVoiceChannelId && !activeDmCall) return null;
|
||||||
|
|
||||||
const channel = channels.find(c => c.id === currentVoiceChannelId);
|
const channel = channels.find(c => c.id === currentVoiceChannelId);
|
||||||
@@ -41,7 +43,7 @@ export function VoiceControls() {
|
|||||||
toggleCamera();
|
toggleCamera();
|
||||||
// Broadcast camera state via WebSocket
|
// Broadcast camera state via WebSocket
|
||||||
const { isMuted: m, isDeafened: d, isScreenSharing: ss } = useVoiceStore.getState();
|
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) {
|
} catch (err) {
|
||||||
console.error('[VoiceControls] Failed to toggle camera:', err);
|
console.error('[VoiceControls] Failed to toggle camera:', err);
|
||||||
}
|
}
|
||||||
@@ -55,12 +57,12 @@ export function VoiceControls() {
|
|||||||
const started = await startScreenShare(room);
|
const started = await startScreenShare(room);
|
||||||
if (started) {
|
if (started) {
|
||||||
const { isMuted: m, isDeafened: d, isCameraOn: c } = useVoiceStore.getState();
|
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 {
|
} else {
|
||||||
await stopScreenShare(room);
|
await stopScreenShare(room);
|
||||||
const { isMuted: m, isDeafened: d, isCameraOn: c } = useVoiceStore.getState();
|
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) {
|
} catch (err) {
|
||||||
console.error('[VoiceControls] Failed to toggle screen share:', err);
|
console.error('[VoiceControls] Failed to toggle screen share:', err);
|
||||||
@@ -70,10 +72,10 @@ export function VoiceControls() {
|
|||||||
const handleDisconnect = () => {
|
const handleDisconnect = () => {
|
||||||
const { activeDmCall } = useVoiceStore.getState();
|
const { activeDmCall } = useVoiceStore.getState();
|
||||||
if (activeDmCall) {
|
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);
|
useVoiceStore.getState().setActiveDmCall(null);
|
||||||
} else {
|
} else {
|
||||||
wsSend({ type: 'voice_leave' });
|
wsSend({ type: 'voice_leave' }, voiceOrigin);
|
||||||
useVoiceStore.getState().leaveVoice();
|
useVoiceStore.getState().leaveVoice();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useVoiceStore } from '../stores/voiceStore';
|
|||||||
import { useSocialStore } from '../stores/socialStore';
|
import { useSocialStore } from '../stores/socialStore';
|
||||||
import { useSettingsStore } from '../stores/settingsStore';
|
import { useSettingsStore } from '../stores/settingsStore';
|
||||||
import type { ServerEvent, ClientEvent, ActiveCallInfo } from '@backspace/shared';
|
import type { ServerEvent, ClientEvent, ActiveCallInfo } from '@backspace/shared';
|
||||||
|
import { resolveAssetUrl, normalizeUserAssets, normalizeMessageAssets } from '../utils/assetUrls';
|
||||||
|
|
||||||
// ─── Connection state ─────────────────────────────────────────────────────────
|
// ─── Connection state ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -92,6 +93,18 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
useSettingsStore.getState().fetchStreamingLimits();
|
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);
|
populateFromReady(origin, event.servers, event.folders, event.dmChannels);
|
||||||
|
|
||||||
if (isHome && currentServerId) {
|
if (isHome && currentServerId) {
|
||||||
@@ -169,6 +182,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'message_created':
|
case 'message_created':
|
||||||
|
if (!isHome) normalizeMessageAssets(event.message, origin);
|
||||||
addRealtimeMessage(event.message.channelId, event.message);
|
addRealtimeMessage(event.message.channelId, event.message);
|
||||||
{
|
{
|
||||||
const { currentChannelId, markChannelUnread } = useChatStore.getState();
|
const { currentChannelId, markChannelUnread } = useChatStore.getState();
|
||||||
@@ -179,6 +193,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'message_updated':
|
case 'message_updated':
|
||||||
|
if (!isHome) normalizeMessageAssets(event.message, origin);
|
||||||
updateMessage(event.message);
|
updateMessage(event.message);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -210,6 +225,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'member_joined':
|
case 'member_joined':
|
||||||
|
if (!isHome) normalizeUserAssets(event.member.user, origin);
|
||||||
addMember(event.member);
|
addMember(event.member);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -435,6 +451,9 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'server_updated': {
|
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();
|
const { servers: currentServers, setServers } = useServerStore.getState();
|
||||||
setServers(currentServers.map(s => s.id === event.server.id ? { ...s, ...event.server } : s));
|
setServers(currentServers.map(s => s.id === event.server.id ? { ...s, ...event.server } : s));
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { MessageWithUser, Reaction, ReadState } from '@backspace/shared';
|
|||||||
import { wsSend } from '../hooks/useWebSocket';
|
import { wsSend } from '../hooks/useWebSocket';
|
||||||
import { isDmChannel, getChannelOrigin, getApiForOrigin, useServerStore } from './serverStore';
|
import { isDmChannel, getChannelOrigin, getApiForOrigin, useServerStore } from './serverStore';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
|
import { normalizeMessageAssets } from '../utils/assetUrls';
|
||||||
|
|
||||||
const MAX_MESSAGES_PER_CHANNEL = 200;
|
const MAX_MESSAGES_PER_CHANNEL = 200;
|
||||||
const MAX_CACHED_CHANNELS = 20;
|
const MAX_CACHED_CHANNELS = 20;
|
||||||
@@ -140,6 +141,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
? await client.dm.messages(channelId)
|
? await client.dm.messages(channelId)
|
||||||
: await client.channels.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) => {
|
set((state) => {
|
||||||
const newMessages = new Map(state.messages);
|
const newMessages = new Map(state.messages);
|
||||||
newMessages.set(channelId, messages as MessageWithUser[]);
|
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.dm.messages(channelId, oldestMessage.id)
|
||||||
: await client.channels.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) => {
|
set((state) => {
|
||||||
const newMessages = new Map(state.messages);
|
const newMessages = new Map(state.messages);
|
||||||
const current = newMessages.get(channelId) ?? [];
|
const current = newMessages.get(channelId) ?? [];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { Server, Channel, MemberWithUser, ServerWithChannelsAndMembers, Role, ServerFolder, DmChannel, User } from '@backspace/shared';
|
import type { Server, Channel, MemberWithUser, ServerWithChannelsAndMembers, Role, ServerFolder, DmChannel, User } from '@backspace/shared';
|
||||||
import { api, BackspaceApiClient } from '../api/client';
|
import { api, BackspaceApiClient } from '../api/client';
|
||||||
|
import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls';
|
||||||
|
|
||||||
// ─── Instance-aware types ─────────────────────────────────────────────────────
|
// ─── Instance-aware types ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -124,6 +125,13 @@ export const useServerStore = create<ServerState>((set, get) => ({
|
|||||||
const client = getApiForOrigin(origin);
|
const client = getApiForOrigin(origin);
|
||||||
|
|
||||||
const detail = await client.servers.get(serverId);
|
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({
|
set({
|
||||||
currentServerId: serverId,
|
currentServerId: serverId,
|
||||||
channels: detail.channels.sort((a, b) => a.position - b.position),
|
channels: detail.channels.sort((a, b) => a.position - b.position),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user