feat(web): refetch DM list after re-attach so reconciled conversation replaces the split (reattach-dm-reconcile spec §3.4)

This commit is contained in:
Jannis Braun
2026-07-03 12:44:00 +02:00
parent 011a9be92a
commit 21c8731f3a
5 changed files with 134 additions and 1 deletions
@@ -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', () => ({
@@ -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');
@@ -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<ConnectedInstance> & { 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<typeof vi.fn> } }).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<typeof vi.fn> } }).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({
+5
View File
@@ -195,6 +195,11 @@ export async function maybeAutoReattach(instance: ConnectedInstance): Promise<vo
useInstanceStore.setState({ registry, registryUpdatedAt: Date.now() });
useUIStore.getState().addToast(`Account re-linked with ${homeDomain}`, 'success');
useInstanceStore.getState().syncRegistry().catch(() => {});
// 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.
+86
View File
@@ -126,6 +126,7 @@ interface SpaceState {
setRoles: (roles: Role[]) => void;
setDmChannels: (channels: DmChannel[]) => void;
addDmChannel: (channel: DmChannel, origin?: string) => void;
reloadDmsForOrigin: (origin: string) => Promise<void>;
removeDmChannel: (id: string) => void;
addDmMember: (dmChannelId: string, user: User) => void;
removeDmMember: (dmChannelId: string, userId: string) => void;
@@ -285,6 +286,91 @@ export const useSpaceStore = create<SpaceState>((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<string, string>();
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<string, Map<string, string>>();
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);