fix: prevent duplicate DMs at creation time, clean up corrupted read states
Replace the unreliable client-side DM dedup loop in populateFromReady with a creation-time guard (findExistingDmForUser) that checks all instances before opening a new DM. Guards added to FriendsPage, NewDmModal, and UserProfilePopout. Also fixes: corrupted read_states from temp_ optimistic message IDs (server migration + client-side validation), federation-aware closeDm/addDmMember API routing, isSelf-based DM member filtering in sidebar/header, and WS event error isolation.
This commit is contained in:
@@ -138,6 +138,9 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
// ─── Admin flag: ensure at least one admin exists (first registered user) ──
|
// ─── Admin flag: ensure at least one admin exists (first registered user) ──
|
||||||
migrateFirstAdmin(db);
|
migrateFirstAdmin(db);
|
||||||
|
|
||||||
|
// ─── Clean up corrupted read_states (temp_ IDs leaked from optimistic messages) ─
|
||||||
|
migrateCorruptedReadStates(db);
|
||||||
|
|
||||||
console.log('Migrations complete.');
|
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"
|
* Rename non-namespaced replicated users: e.g. "test" → "test@nova.ddns.net"
|
||||||
* Frees plain usernames for native user creation and makes all federated users
|
* Frees plain usernames for native user creation and makes all federated users
|
||||||
|
|||||||
@@ -824,6 +824,8 @@ function handleChannelAck(event: Record<string, unknown>, userId: string): void
|
|||||||
const channelId = event.channelId as string;
|
const channelId = event.channelId as string;
|
||||||
const messageId = event.messageId as string;
|
const messageId = event.messageId as string;
|
||||||
if (!channelId || !messageId) return;
|
if (!channelId || !messageId) return;
|
||||||
|
// Validate messageId is a valid snowflake (numeric string) — reject temp/garbage IDs
|
||||||
|
if (!/^\d+$/.test(messageId)) return;
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore';
|
import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore';
|
||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore } from '../../stores/serverStore';
|
||||||
import { useInstanceStore } from '../../stores/instanceStore';
|
import { useInstanceStore } from '../../stores/instanceStore';
|
||||||
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { MemberListToggleButton } from '../layout/MemberListToggleButton';
|
import { MemberListToggleButton } from '../layout/MemberListToggleButton';
|
||||||
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
||||||
@@ -53,6 +54,13 @@ export function FriendsPage() {
|
|||||||
|
|
||||||
const handleOpenDm = async (friendId: string, instanceOrigin: string) => {
|
const handleOpenDm = async (friendId: string, instanceOrigin: string) => {
|
||||||
try {
|
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;
|
let client = api;
|
||||||
if (instanceOrigin) {
|
if (instanceOrigin) {
|
||||||
const instance = useInstanceStore.getState().instances.find(i => i.origin === instanceOrigin);
|
const instance = useInstanceStore.getState().instances.find(i => i.origin === instanceOrigin);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { wsSend } from '../../hooks/useWebSocket';
|
|||||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||||
import { AudioManager } from '../../audio/AudioManager';
|
import { AudioManager } from '../../audio/AudioManager';
|
||||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||||
import { parseFederatedUsername } from '../../utils/identity';
|
import { parseFederatedUsername, isSelf } from '../../utils/identity';
|
||||||
|
|
||||||
export function ChannelSidebar() {
|
export function ChannelSidebar() {
|
||||||
const servers = useServerStore((s) => s.servers);
|
const servers = useServerStore((s) => s.servers);
|
||||||
@@ -189,7 +189,7 @@ export function ChannelSidebar() {
|
|||||||
|
|
||||||
<div className="space-y-[2px]">
|
<div className="space-y-[2px]">
|
||||||
{dmChannels.map((dm) => {
|
{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;
|
if (otherMembers.length === 0) return null;
|
||||||
const isGroup = dm.members.length > 2;
|
const isGroup = dm.members.length > 2;
|
||||||
const isDmUnread = unreadChannels.has(dm.id) && currentChannelId !== dm.id;
|
const isDmUnread = unreadChannels.has(dm.id) && currentChannelId !== dm.id;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Avatar } from '../ui/Avatar';
|
|||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { wsSend } from '../../hooks/useWebSocket';
|
import { wsSend } from '../../hooks/useWebSocket';
|
||||||
import { MemberListToggleButton } from './MemberListToggleButton';
|
import { MemberListToggleButton } from './MemberListToggleButton';
|
||||||
|
import { isSelf } from '../../utils/identity';
|
||||||
|
|
||||||
export function MainContent() {
|
export function MainContent() {
|
||||||
// 1. ALL HOOKS AT THE TOP
|
// 1. ALL HOOKS AT THE TOP
|
||||||
@@ -67,7 +68,7 @@ export function MainContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dmChannel = dmChannels.find(dm => dm.id === currentChannelId);
|
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 isGroupDm = (dmChannel?.members.length ?? 0) > 2;
|
||||||
const dmName = isGroupDm
|
const dmName = isGroupDm
|
||||||
? otherMembers.map(m => m.displayName ?? m.username).join(', ')
|
? otherMembers.map(m => m.displayName ?? m.username).join(', ')
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
|
|||||||
import { Modal } from '../ui/Modal';
|
import { Modal } from '../ui/Modal';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore, getApiForOrigin } from '../../stores/serverStore';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import type { User } from '@backspace/shared';
|
import type { User } from '@backspace/shared';
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ export function AddDmMemberModal() {
|
|||||||
const modalData = useUIStore((s) => s.modalData);
|
const modalData = useUIStore((s) => s.modalData);
|
||||||
const closeModal = useUIStore((s) => s.closeModal);
|
const closeModal = useUIStore((s) => s.closeModal);
|
||||||
const dmChannels = useServerStore((s) => s.dmChannels);
|
const dmChannels = useServerStore((s) => s.dmChannels);
|
||||||
|
const channelOriginMap = useServerStore((s) => s.channelOriginMap);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
|
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
@@ -67,7 +68,9 @@ export function AddDmMemberModal() {
|
|||||||
setError('');
|
setError('');
|
||||||
setIsAdding(true);
|
setIsAdding(true);
|
||||||
try {
|
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();
|
closeModal();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message || 'Failed to add member');
|
setError((err as Error).message || 'Failed to add member');
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ export function NewDmModal() {
|
|||||||
const handleSelectUser = async (user: User) => {
|
const handleSelectUser = async (user: User) => {
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
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 });
|
const channel = await api.dm.create({ userId: user.id });
|
||||||
addDmChannel(channel);
|
addDmChannel(channel);
|
||||||
closeModal();
|
closeModal();
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
|
|||||||
|
|
||||||
const handleSendMessage = async () => {
|
const handleSendMessage = async () => {
|
||||||
try {
|
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 });
|
const channel = await api.dm.create({ userId: user.id });
|
||||||
addDmChannel(channel);
|
addDmChannel(channel);
|
||||||
useUIStore.getState().setShowDms(true);
|
useUIStore.getState().setShowDms(true);
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
import { getHomeWsConnected } from '../../hooks/useWebSocket';
|
||||||
import { AudioManager } from '../../audio/AudioManager';
|
import { AudioManager } from '../../audio/AudioManager';
|
||||||
|
|
||||||
export function SoundController() {
|
export function SoundController() {
|
||||||
const audioManager = AudioManager.getInstance();
|
const audioManager = AudioManager.getInstance();
|
||||||
const currentUser = useAuthStore((s) => s.user);
|
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
|
// Refs to track previous states
|
||||||
const isInitialMount = useRef(true);
|
const isInitialMount = useRef(true);
|
||||||
|
|||||||
@@ -547,11 +547,17 @@ function connectToOrigin(origin: string, token: string): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
|
let event: ServerEvent;
|
||||||
try {
|
try {
|
||||||
const event = JSON.parse(e.data as string) as ServerEvent;
|
event = JSON.parse(e.data as string) as ServerEvent;
|
||||||
handleEvent(origin, event);
|
|
||||||
} catch {
|
} catch {
|
||||||
console.error(`Failed to parse WebSocket message (${origin || 'home'})`);
|
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. */
|
/** Send an event over the WebSocket. Can be used outside of React components. */
|
||||||
export function wsSend(event: ClientEvent, origin: string = HOME_ORIGIN): void {
|
export function wsSend(event: ClientEvent, origin: string = HOME_ORIGIN): void {
|
||||||
const conn = connections.get(origin);
|
const conn = connections.get(origin);
|
||||||
|
|||||||
@@ -478,7 +478,16 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
const unread = new Set<string>();
|
const unread = new Set<string>();
|
||||||
for (const [channelId, lastMsgId] of channelLastMessageIds) {
|
for (const [channelId, lastMsgId] of channelLastMessageIds) {
|
||||||
const lastRead = rsMap.get(channelId);
|
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);
|
unread.add(channelId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -500,6 +509,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
const lastMsg = msgs[msgs.length - 1];
|
const lastMsg = msgs[msgs.length - 1];
|
||||||
if (!lastMsg) return;
|
if (!lastMsg) return;
|
||||||
const messageId = lastMsg.id;
|
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
|
// Update local state immediately
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { create } from 'zustand';
|
|||||||
import type { Server, Channel, MemberWithUser, ServerWithChannelsAndMembers, Role, ServerFolder, DmChannel, User, UpdateServerRequest } from '@backspace/shared';
|
import type { Server, Channel, MemberWithUser, ServerWithChannelsAndMembers, Role, ServerFolder, DmChannel, User, UpdateServerRequest } from '@backspace/shared';
|
||||||
import { api, BackspaceApiClient } from '../api/client';
|
import { api, BackspaceApiClient } from '../api/client';
|
||||||
import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls';
|
import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls';
|
||||||
|
import { isSelf } from '../utils/identity';
|
||||||
|
import { useAuthStore } from './authStore';
|
||||||
|
|
||||||
// ─── Instance-aware types ─────────────────────────────────────────────────────
|
// ─── Instance-aware types ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -63,6 +65,7 @@ interface ServerState {
|
|||||||
populateFromReady: (origin: string, servers: ServerWithChannelsAndMembers[], folders?: ServerFolder[], dmChannels?: DmChannel[]) => void;
|
populateFromReady: (origin: string, servers: ServerWithChannelsAndMembers[], folders?: ServerFolder[], dmChannels?: DmChannel[]) => void;
|
||||||
addServerFromReady: (origin: string, server: ServerWithChannelsAndMembers) => void;
|
addServerFromReady: (origin: string, server: ServerWithChannelsAndMembers) => void;
|
||||||
removeInstanceServers: (origin: string) => void;
|
removeInstanceServers: (origin: string) => void;
|
||||||
|
findExistingDmForUser: (targetUser: { id: string; homeUserId?: string | null }) => { dm: DmChannel; origin: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useServerStore = create<ServerState>((set, get) => ({
|
export const useServerStore = create<ServerState>((set, get) => ({
|
||||||
@@ -118,7 +121,9 @@ export const useServerStore = create<ServerState>((set, get) => ({
|
|||||||
})),
|
})),
|
||||||
|
|
||||||
closeDm: async (id) => {
|
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) => ({
|
set((state) => ({
|
||||||
dmChannels: state.dmChannels.filter(c => c.id !== id)
|
dmChannels: state.dmChannels.filter(c => c.id !== id)
|
||||||
}));
|
}));
|
||||||
@@ -442,6 +447,25 @@ export const useServerStore = create<ServerState>((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) => {
|
removeInstanceServers: (origin: string) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const remainingServers = state.servers.filter(s => s._instanceOrigin !== origin);
|
const remainingServers = state.servers.filter(s => s._instanceOrigin !== origin);
|
||||||
|
|||||||
Reference in New Issue
Block a user