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:
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user