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:
Jannis Braun
2026-03-04 20:04:15 +01:00
parent 33ae79bae9
commit 65e9ee5203
12 changed files with 108 additions and 12 deletions
@@ -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);
@@ -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() {
<div className="space-y-[2px]">
{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;
@@ -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(', ')
@@ -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<HTMLInputElement>(null);
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
@@ -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');
@@ -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();
@@ -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);
@@ -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);