From 21c8731f3a3498b90f19cda057aae3d33aee3ad3 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:44:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20refetch=20DM=20list=20after=20re-a?= =?UTF-8?q?ttach=20so=20reconciled=20conversation=20replaces=20the=20split?= =?UTF-8?q?=20(reattach-dm-reconcile=20spec=20=C2=A73.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AccountPanel.detachedNotice.test.tsx | 9 ++ .../modals/settingsPanels/AccountPanel.tsx | 5 ++ .../stores/instanceStore.autoReattach.test.ts | 30 ++++++- packages/web/src/stores/instanceStore.ts | 5 ++ packages/web/src/stores/spaceStore.ts | 86 +++++++++++++++++++ 5 files changed, 134 insertions(+), 1 deletion(-) diff --git a/packages/web/src/components/modals/settingsPanels/AccountPanel.detachedNotice.test.tsx b/packages/web/src/components/modals/settingsPanels/AccountPanel.detachedNotice.test.tsx index a230131a..30cb3c4f 100644 --- a/packages/web/src/components/modals/settingsPanels/AccountPanel.detachedNotice.test.tsx +++ b/packages/web/src/components/modals/settingsPanels/AccountPanel.detachedNotice.test.tsx @@ -56,6 +56,15 @@ vi.mock('../../../stores/transferStore', () => ({ ), })); +// spaceStore is imported for the post-reattach DM refetch (reloadDmsForOrigin); +// stub it so this isolated render doesn't load the real store's audio import chain. +vi.mock('../../../stores/spaceStore', () => ({ + useSpaceStore: Object.assign( + (selector: (s: unknown) => unknown) => selector({}), + { getState: () => ({ reloadDmsForOrigin: vi.fn().mockResolvedValue(undefined) }), setState: vi.fn(), subscribe: vi.fn() }, + ), +})); + // api.uploads.url is referenced during render for avatar/banner sources; // api.users.reattach is the peer call the fallback action fires on confirm. vi.mock('../../../api/client', () => ({ diff --git a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx index 434b45e9..1b0c7a6f 100644 --- a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx +++ b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useMemo } from 'react'; import { useAuthStore } from '../../../stores/authStore'; import { useUIStore } from '../../../stores/uiStore'; import { useInstanceStore } from '../../../stores/instanceStore'; +import { useSpaceStore } from '../../../stores/spaceStore'; import { Avatar } from '../../ui/Avatar'; import { ImageCropModal } from '../../ui/ImageCropModal'; import { DeleteAccountModal } from '../DeleteAccountModal'; @@ -110,6 +111,10 @@ export function AccountPanel() { const { token } = await homeConnection.api.auth.attachProof(window.location.hostname); const res = await api.users.reattach({ token }); useAuthStore.getState().setUser(res.user); + // Re-attach reconciled this (home) account's 1-on-1 DM federatedIds on the + // server; refetch the home DM list so the split conversation collapses + // without a reload. + try { await useSpaceStore.getState().reloadDmsForOrigin(''); } catch { /* non-fatal */ } addToast(`Account re-linked with ${homeConnection.username}`, 'success', 3000); } catch (err) { setReattachError(err instanceof Error ? err.message : 'Re-attach failed'); diff --git a/packages/web/src/stores/instanceStore.autoReattach.test.ts b/packages/web/src/stores/instanceStore.autoReattach.test.ts index d77ea06e..e2df0d78 100644 --- a/packages/web/src/stores/instanceStore.autoReattach.test.ts +++ b/packages/web/src/stores/instanceStore.autoReattach.test.ts @@ -29,12 +29,13 @@ vi.mock('./authStore', () => ({ import { useInstanceStore, maybeAutoReattach } from './instanceStore'; import type { ConnectedInstance } from './instanceStore'; +import { useSpaceStore } from './spaceStore'; function makeInstance(overrides: Partial & { origin: string }): ConnectedInstance { return { label: 'x', token: 't', status: 'connected', username: overrides.user?.username ?? 'u', - api: { auth: { attachProof: vi.fn() }, users: { reattach: vi.fn() } } as unknown as BackspaceApiClient, + api: { auth: { attachProof: vi.fn() }, users: { reattach: vi.fn() }, dm: { list: vi.fn().mockResolvedValue([]) } } as unknown as BackspaceApiClient, user: { id: 'id', username: 'u' } as User, ...overrides, }; @@ -71,6 +72,33 @@ describe('maybeAutoReattach', () => { expect(stored.user.federationHomeOrphaned).toBe(false); }); + it('refetches the DM list for the connection after a successful re-attach', async () => { + const homeConn = makeInstance({ + origin: 'https://orbit.test', + username: 'youruser', + user: { id: 'new-home-1', username: 'youruser' } as User, + }); + (homeConn.api as unknown as { auth: { attachProof: ReturnType } }).auth.attachProof = + vi.fn().mockResolvedValue({ token: 'a'.repeat(64) }); + + const updatedUser = { id: 'detached-1', username: 'youruser@orbit.test', federationHomeOrphaned: false, homeInstance: 'orbit.test' } as User; + const detachedConn = makeInstance({ + origin: 'https://nova.test', + user: { id: 'detached-1', username: 'youruser@orbit.test', federationHomeOrphaned: true, homeInstance: 'orbit.test' } as User, + }); + (detachedConn.api as unknown as { users: { reattach: ReturnType } }).users.reattach = + vi.fn().mockResolvedValue({ success: true, user: updatedUser }); + + const dmRefetchMock = vi.fn().mockResolvedValue(undefined); + const spy = vi.spyOn(useSpaceStore.getState(), 'reloadDmsForOrigin').mockImplementation(dmRefetchMock); + + useInstanceStore.setState({ instances: [homeConn, detachedConn] }); + await maybeAutoReattach(detachedConn); + + expect(dmRefetchMock).toHaveBeenCalledWith('https://nova.test'); + spy.mockRestore(); + }); + it('skips silently on username-base mismatch (cross-name binds are manual-only)', async () => { const homeConn = makeInstance({ origin: 'https://orbit.test', username: 'hans', user: { id: 'h', username: 'hans' } as User }); const detachedConn = makeInstance({ diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index 266bd6cb..0f94d877 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -195,6 +195,11 @@ export async function maybeAutoReattach(instance: ConnectedInstance): Promise {}); + // Re-attach reconciled this connection's 1-on-1 DM federatedIds (merge/re-key + // on the server); refetch the DM list so the split conversation collapses + // without a reload. Belt-and-suspenders for the connection that triggered it + // — the server's dm_channel_closed/created events cover the live sidebar too. + try { await useSpaceStore.getState().reloadDmsForOrigin(instance.origin); } catch { /* non-fatal */ } } catch (err) { // Non-fatal: the connection works either way; the explicit re-attach // action in AccountPanel remains available. diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index f093250d..e5b6f112 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -126,6 +126,7 @@ interface SpaceState { setRoles: (roles: Role[]) => void; setDmChannels: (channels: DmChannel[]) => void; addDmChannel: (channel: DmChannel, origin?: string) => void; + reloadDmsForOrigin: (origin: string) => Promise; removeDmChannel: (id: string) => void; addDmMember: (dmChannelId: string, user: User) => void; removeDmMember: (dmChannelId: string, userId: string) => void; @@ -285,6 +286,91 @@ export const useSpaceStore = create((set, get) => ({ }; }), + // Refetch and replace the DM list for a single origin, mirroring the DM + // portion of populateFromReady (dedup vs other origins by federatedId, origin + // map, last-message map, failover alternatives, userViews). Used after a + // re-attach reconciles this connection's 1-on-1 federatedIds (merge/re-key) + // so the split conversation collapses without a full WS reconnect. Origin '' + // is the home instance. Non-fatal: the caller wraps it in try/catch. + reloadDmsForOrigin: async (origin: string) => { + const client = getApiForOrigin(origin); + const incomingDms = await client.dm.list(); + + // Normalize remote-origin DM member asset URLs (home origin serves clean paths). + if (origin !== '') { + for (const dm of incomingDms) { + for (const member of dm.members) { + normalizeUserAssets(member, origin); + } + } + } + + set((state) => { + // Upsert every DM member into the userViews cache (home + remote). + const { upsertUserView } = get(); + for (const dm of incomingDms) { + for (const member of dm.members) { + upsertUserView(member, origin); + } + } + + // Dedup vs DMs already loaded from OTHER origins (same federatedId). + const existingFederatedIds = new Map(); + for (const dm of state.dmChannels) { + if (dm.federatedId && (state.channelOriginMap.get(dm.id) ?? '') !== origin) { + existingFederatedIds.set(dm.federatedId, dm.id); + } + } + + const channelOriginMap = new Map(state.channelOriginMap); + const channelLastMessageIds = new Map(state.channelLastMessageIds); + // Drop this origin's stale channel-map entries before repopulating. + for (const dm of state.dmChannels) { + if ((state.channelOriginMap.get(dm.id) ?? '') === origin) { + channelOriginMap.delete(dm.id); + channelLastMessageIds.delete(dm.id); + } + } + + const dmAlternatives = new Map>(); + for (const [fid, byOrigin] of state.dmAlternatives) { + dmAlternatives.set(fid, new Map(byOrigin)); + } + + const filteredDms: DmChannel[] = []; + for (const dm of incomingDms) { + if (dm.federatedId && existingFederatedIds.has(dm.federatedId)) { + continue; // duplicate cross-instance DM — keep the copy from the other origin + } + filteredDms.push(dm); + if (dm.federatedId) existingFederatedIds.set(dm.federatedId, dm.id); + } + for (const dm of filteredDms) { + channelOriginMap.set(dm.id, origin); + if (dm.lastMessage?.id) channelLastMessageIds.set(dm.id, dm.lastMessage.id); + } + // Record every DM's (origin → localChannelId) for failover lookup. + for (const dm of incomingDms) { + if (!dm.federatedId) continue; + let byOrigin = dmAlternatives.get(dm.federatedId); + if (!byOrigin) { + byOrigin = new Map(); + dmAlternatives.set(dm.federatedId, byOrigin); + } + byOrigin.set(origin, dm.id); + } + + const existingDmsFromOtherOrigins = state.dmChannels.filter( + dm => (state.channelOriginMap.get(dm.id) ?? '') !== origin, + ); + const mergedDms = [...existingDmsFromOtherOrigins, ...filteredDms]; + const { unreadChannels, currentChannelId } = useChatStore.getState(); + const sortedDms = sortDmChannels(mergedDms, unreadChannels, currentChannelId); + + return { dmChannels: sortedDms, channelOriginMap, channelLastMessageIds, dmAlternatives }; + }); + }, + upsertUserView: (user, deliveringOrigin) => set((state) => { const key = canonicalUserKey(user); const incomingIsHome = isDeliveryFromHome(user, deliveringOrigin);