diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 6f8327fd..8b07a762 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -138,6 +138,9 @@ export function runMigrations(db: Database.Database): void { // ─── Admin flag: ensure at least one admin exists (first registered user) ── migrateFirstAdmin(db); + // ─── Clean up corrupted read_states (temp_ IDs leaked from optimistic messages) ─ + migrateCorruptedReadStates(db); + console.log('Migrations complete.'); } @@ -236,6 +239,16 @@ function migrateEveryoneRoles(db: Database.Database): void { } } +/** Delete corrupted read_states rows where last_read_message_id is not a valid snowflake (numeric string) */ +function migrateCorruptedReadStates(db: Database.Database): void { + const deleted = db.prepare( + "DELETE FROM read_states WHERE last_read_message_id NOT GLOB '[0-9]*' OR last_read_message_id GLOB '*[^0-9]*'" + ).run(); + if (deleted.changes > 0) { + console.log(`Migrating: Cleaned up ${deleted.changes} corrupted read_states rows`); + } +} + /** * Rename non-namespaced replicated users: e.g. "test" → "test@nova.ddns.net" * Frees plain usernames for native user creation and makes all federated users diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 365455ee..355509f0 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -824,6 +824,8 @@ function handleChannelAck(event: Record, userId: string): void const channelId = event.channelId as string; const messageId = event.messageId as string; if (!channelId || !messageId) return; + // Validate messageId is a valid snowflake (numeric string) — reject temp/garbage IDs + if (!/^\d+$/.test(messageId)) return; const db = getDb(); diff --git a/packages/web/src/components/chat/FriendsPage.tsx b/packages/web/src/components/chat/FriendsPage.tsx index 3e1391f3..72f14a77 100644 --- a/packages/web/src/components/chat/FriendsPage.tsx +++ b/packages/web/src/components/chat/FriendsPage.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore'; import { useServerStore } from '../../stores/serverStore'; import { useInstanceStore } from '../../stores/instanceStore'; +import { useUIStore } from '../../stores/uiStore'; import { Avatar } from '../ui/Avatar'; import { MemberListToggleButton } from '../layout/MemberListToggleButton'; import { LoadingSpinner } from '../ui/LoadingSpinner'; @@ -53,6 +54,13 @@ export function FriendsPage() { const handleOpenDm = async (friendId: string, instanceOrigin: string) => { try { + // Check if a DM already exists with this user (on any instance) + const existing = useServerStore.getState().findExistingDmForUser({ id: friendId }); + if (existing) { + useUIStore.getState().setShowDms(true); + navigate(`/channels/@me/${existing.dm.id}`); + return; + } let client = api; if (instanceOrigin) { const instance = useInstanceStore.getState().instances.find(i => i.origin === instanceOrigin); diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index 1e9777b6..92065502 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -14,7 +14,7 @@ import { wsSend } from '../../hooks/useWebSocket'; import { getActiveRoom } from '../../hooks/useLiveKit'; import { AudioManager } from '../../audio/AudioManager'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; -import { parseFederatedUsername } from '../../utils/identity'; +import { parseFederatedUsername, isSelf } from '../../utils/identity'; export function ChannelSidebar() { const servers = useServerStore((s) => s.servers); @@ -189,7 +189,7 @@ export function ChannelSidebar() {
{dmChannels.map((dm) => { - const otherMembers = dm.members.filter(m => m.id !== user?.id); + const otherMembers = dm.members.filter(m => !isSelf(m, user)); if (otherMembers.length === 0) return null; const isGroup = dm.members.length > 2; const isDmUnread = unreadChannels.has(dm.id) && currentChannelId !== dm.id; diff --git a/packages/web/src/components/layout/MainContent.tsx b/packages/web/src/components/layout/MainContent.tsx index c710a762..c2df75f4 100644 --- a/packages/web/src/components/layout/MainContent.tsx +++ b/packages/web/src/components/layout/MainContent.tsx @@ -14,6 +14,7 @@ import { Avatar } from '../ui/Avatar'; import { useVoiceStore } from '../../stores/voiceStore'; import { wsSend } from '../../hooks/useWebSocket'; import { MemberListToggleButton } from './MemberListToggleButton'; +import { isSelf } from '../../utils/identity'; export function MainContent() { // 1. ALL HOOKS AT THE TOP @@ -67,7 +68,7 @@ export function MainContent() { } const dmChannel = dmChannels.find(dm => dm.id === currentChannelId); - const otherMembers = dmChannel?.members.filter(m => m.id !== authUser?.id) ?? []; + const otherMembers = dmChannel?.members.filter(m => !isSelf(m, authUser)) ?? []; const isGroupDm = (dmChannel?.members.length ?? 0) > 2; const dmName = isGroupDm ? otherMembers.map(m => m.displayName ?? m.username).join(', ') diff --git a/packages/web/src/components/modals/AddDmMemberModal.tsx b/packages/web/src/components/modals/AddDmMemberModal.tsx index 1abfbe3e..39b2302b 100644 --- a/packages/web/src/components/modals/AddDmMemberModal.tsx +++ b/packages/web/src/components/modals/AddDmMemberModal.tsx @@ -2,7 +2,7 @@ import React, { useState, useRef, useEffect } from 'react'; import { Modal } from '../ui/Modal'; import { Avatar } from '../ui/Avatar'; import { useUIStore } from '../../stores/uiStore'; -import { useServerStore } from '../../stores/serverStore'; +import { useServerStore, getApiForOrigin } from '../../stores/serverStore'; import { api } from '../../api/client'; import type { User } from '@backspace/shared'; @@ -16,6 +16,7 @@ export function AddDmMemberModal() { const modalData = useUIStore((s) => s.modalData); const closeModal = useUIStore((s) => s.closeModal); const dmChannels = useServerStore((s) => s.dmChannels); + const channelOriginMap = useServerStore((s) => s.channelOriginMap); const inputRef = useRef(null); const searchTimer = useRef>(); @@ -67,7 +68,9 @@ export function AddDmMemberModal() { setError(''); setIsAdding(true); try { - await api.dm.addMember(dmChannelId, { userId: user.id }); + const origin = channelOriginMap.get(dmChannelId) || ''; + const targetApi = getApiForOrigin(origin); + await targetApi.dm.addMember(dmChannelId, { userId: user.id }); closeModal(); } catch (err) { setError((err as Error).message || 'Failed to add member'); diff --git a/packages/web/src/components/modals/NewDmModal.tsx b/packages/web/src/components/modals/NewDmModal.tsx index ec0afc4f..a1850511 100644 --- a/packages/web/src/components/modals/NewDmModal.tsx +++ b/packages/web/src/components/modals/NewDmModal.tsx @@ -59,6 +59,13 @@ export function NewDmModal() { const handleSelectUser = async (user: User) => { setError(''); try { + const existing = useServerStore.getState().findExistingDmForUser(user); + if (existing) { + closeModal(); + useUIStore.getState().setShowDms(true); + navigate(`/channels/@me/${existing.dm.id}`); + return; + } const channel = await api.dm.create({ userId: user.id }); addDmChannel(channel); closeModal(); diff --git a/packages/web/src/components/ui/UserProfilePopout.tsx b/packages/web/src/components/ui/UserProfilePopout.tsx index afb15955..b68668b8 100644 --- a/packages/web/src/components/ui/UserProfilePopout.tsx +++ b/packages/web/src/components/ui/UserProfilePopout.tsx @@ -30,6 +30,13 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout const handleSendMessage = async () => { try { + const existing = useServerStore.getState().findExistingDmForUser(user); + if (existing) { + useUIStore.getState().setShowDms(true); + onClose(); + navigate(`/channels/@me/${existing.dm.id}`); + return; + } const channel = await api.dm.create({ userId: user.id }); addDmChannel(channel); useUIStore.getState().setShowDms(true); diff --git a/packages/web/src/components/voice/SoundController.tsx b/packages/web/src/components/voice/SoundController.tsx index 2c431e8a..a3374314 100644 --- a/packages/web/src/components/voice/SoundController.tsx +++ b/packages/web/src/components/voice/SoundController.tsx @@ -1,14 +1,22 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useVoiceStore } from '../../stores/voiceStore'; import { useChatStore } from '../../stores/chatStore'; import { useAuthStore } from '../../stores/authStore'; -import { useWebSocket } from '../../hooks/useWebSocket'; +import { getHomeWsConnected } from '../../hooks/useWebSocket'; import { AudioManager } from '../../audio/AudioManager'; export function SoundController() { const audioManager = AudioManager.getInstance(); const currentUser = useAuthStore((s) => s.user); - const { isConnected: isWsConnected } = useWebSocket(); + const [isWsConnected, setIsWsConnected] = useState(false); + + // Poll home WS connection status without managing lifecycle + useEffect(() => { + const interval = setInterval(() => { + setIsWsConnected(getHomeWsConnected()); + }, 500); + return () => clearInterval(interval); + }, []); // Refs to track previous states const isInitialMount = useRef(true); diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 776c7376..85453bfc 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -547,11 +547,17 @@ function connectToOrigin(origin: string, token: string): void { }; ws.onmessage = (e) => { + let event: ServerEvent; try { - const event = JSON.parse(e.data as string) as ServerEvent; - handleEvent(origin, event); + event = JSON.parse(e.data as string) as ServerEvent; } catch { console.error(`Failed to parse WebSocket message (${origin || 'home'})`); + return; + } + try { + handleEvent(origin, event); + } catch (err) { + console.error(`Error handling WS event "${event.type}" (${origin || 'home'}):`, err); } }; @@ -617,6 +623,12 @@ export function disconnectAllRemote(): void { } } +/** Read-only home WS connection status — safe to call from any component without managing lifecycle. */ +export function getHomeWsConnected(): boolean { + const conn = connections.get(HOME_ORIGIN); + return !!conn?.ws && conn.ws.readyState === WebSocket.OPEN; +} + /** Send an event over the WebSocket. Can be used outside of React components. */ export function wsSend(event: ClientEvent, origin: string = HOME_ORIGIN): void { const conn = connections.get(origin); diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index 6aba34d4..77bf33bc 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -478,7 +478,16 @@ export const useChatStore = create((set, get) => ({ const unread = new Set(); for (const [channelId, lastMsgId] of channelLastMessageIds) { const lastRead = rsMap.get(channelId); - if (!lastRead || BigInt(lastMsgId) > BigInt(lastRead)) { + if (!lastRead) { + unread.add(channelId); + continue; + } + try { + if (BigInt(lastMsgId) > BigInt(lastRead)) { + unread.add(channelId); + } + } catch { + // Corrupted read state (e.g. temp_ ID) — treat as unread unread.add(channelId); } } @@ -500,6 +509,8 @@ export const useChatStore = create((set, get) => ({ const lastMsg = msgs[msgs.length - 1]; if (!lastMsg) return; const messageId = lastMsg.id; + // Don't ack optimistic/temp messages — wait for the real server ID + if (messageId.startsWith('temp_')) return; // Update local state immediately set((state) => { diff --git a/packages/web/src/stores/serverStore.ts b/packages/web/src/stores/serverStore.ts index 47d5ed6c..8a38f15b 100644 --- a/packages/web/src/stores/serverStore.ts +++ b/packages/web/src/stores/serverStore.ts @@ -2,6 +2,8 @@ import { create } from 'zustand'; import type { Server, Channel, MemberWithUser, ServerWithChannelsAndMembers, Role, ServerFolder, DmChannel, User, UpdateServerRequest } from '@backspace/shared'; import { api, BackspaceApiClient } from '../api/client'; import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls'; +import { isSelf } from '../utils/identity'; +import { useAuthStore } from './authStore'; // ─── Instance-aware types ───────────────────────────────────────────────────── @@ -63,6 +65,7 @@ interface ServerState { populateFromReady: (origin: string, servers: ServerWithChannelsAndMembers[], folders?: ServerFolder[], dmChannels?: DmChannel[]) => void; addServerFromReady: (origin: string, server: ServerWithChannelsAndMembers) => void; removeInstanceServers: (origin: string) => void; + findExistingDmForUser: (targetUser: { id: string; homeUserId?: string | null }) => { dm: DmChannel; origin: string } | null; } export const useServerStore = create((set, get) => ({ @@ -118,7 +121,9 @@ export const useServerStore = create((set, get) => ({ })), closeDm: async (id) => { - await api.dm.close(id); + const origin = get().channelOriginMap.get(id) || ''; + const targetApi = getApiForOrigin(origin); + await targetApi.dm.close(id); set((state) => ({ dmChannels: state.dmChannels.filter(c => c.id !== id) })); @@ -442,6 +447,25 @@ export const useServerStore = create((set, get) => ({ })); }, + findExistingDmForUser: (targetUser) => { + const { dmChannels, channelOriginMap } = get(); + const me = useAuthStore.getState().user; + if (!me) return null; + + const targetHomeId = targetUser.homeUserId || targetUser.id; + + for (const dm of dmChannels) { + if (dm.members.length !== 2) continue; + const other = dm.members.find(m => !isSelf(m, me)); + if (!other) continue; + const otherHomeId = other.homeUserId || other.id; + if (otherHomeId === targetHomeId) { + return { dm, origin: channelOriginMap.get(dm.id) || '' }; + } + } + return null; + }, + removeInstanceServers: (origin: string) => { set((state) => { const remainingServers = state.servers.filter(s => s._instanceOrigin !== origin);