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.uploads.url is referenced during render for avatar/banner sources;
|
||||||
// api.users.reattach is the peer call the fallback action fires on confirm.
|
// api.users.reattach is the peer call the fallback action fires on confirm.
|
||||||
vi.mock('../../../api/client', () => ({
|
vi.mock('../../../api/client', () => ({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useMemo } from 'react';
|
|||||||
import { useAuthStore } from '../../../stores/authStore';
|
import { useAuthStore } from '../../../stores/authStore';
|
||||||
import { useUIStore } from '../../../stores/uiStore';
|
import { useUIStore } from '../../../stores/uiStore';
|
||||||
import { useInstanceStore } from '../../../stores/instanceStore';
|
import { useInstanceStore } from '../../../stores/instanceStore';
|
||||||
|
import { useSpaceStore } from '../../../stores/spaceStore';
|
||||||
import { Avatar } from '../../ui/Avatar';
|
import { Avatar } from '../../ui/Avatar';
|
||||||
import { ImageCropModal } from '../../ui/ImageCropModal';
|
import { ImageCropModal } from '../../ui/ImageCropModal';
|
||||||
import { DeleteAccountModal } from '../DeleteAccountModal';
|
import { DeleteAccountModal } from '../DeleteAccountModal';
|
||||||
@@ -110,6 +111,10 @@ export function AccountPanel() {
|
|||||||
const { token } = await homeConnection.api.auth.attachProof(window.location.hostname);
|
const { token } = await homeConnection.api.auth.attachProof(window.location.hostname);
|
||||||
const res = await api.users.reattach({ token });
|
const res = await api.users.reattach({ token });
|
||||||
useAuthStore.getState().setUser(res.user);
|
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);
|
addToast(`Account re-linked with ${homeConnection.username}`, 'success', 3000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setReattachError(err instanceof Error ? err.message : 'Re-attach failed');
|
setReattachError(err instanceof Error ? err.message : 'Re-attach failed');
|
||||||
|
|||||||
@@ -29,12 +29,13 @@ vi.mock('./authStore', () => ({
|
|||||||
|
|
||||||
import { useInstanceStore, maybeAutoReattach } from './instanceStore';
|
import { useInstanceStore, maybeAutoReattach } from './instanceStore';
|
||||||
import type { ConnectedInstance } from './instanceStore';
|
import type { ConnectedInstance } from './instanceStore';
|
||||||
|
import { useSpaceStore } from './spaceStore';
|
||||||
|
|
||||||
function makeInstance(overrides: Partial<ConnectedInstance> & { origin: string }): ConnectedInstance {
|
function makeInstance(overrides: Partial<ConnectedInstance> & { origin: string }): ConnectedInstance {
|
||||||
return {
|
return {
|
||||||
label: 'x', token: 't', status: 'connected',
|
label: 'x', token: 't', status: 'connected',
|
||||||
username: overrides.user?.username ?? 'u',
|
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,
|
user: { id: 'id', username: 'u' } as User,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -71,6 +72,33 @@ describe('maybeAutoReattach', () => {
|
|||||||
expect(stored.user.federationHomeOrphaned).toBe(false);
|
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 () => {
|
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 homeConn = makeInstance({ origin: 'https://orbit.test', username: 'hans', user: { id: 'h', username: 'hans' } as User });
|
||||||
const detachedConn = makeInstance({
|
const detachedConn = makeInstance({
|
||||||
|
|||||||
@@ -195,6 +195,11 @@ export async function maybeAutoReattach(instance: ConnectedInstance): Promise<vo
|
|||||||
useInstanceStore.setState({ registry, registryUpdatedAt: Date.now() });
|
useInstanceStore.setState({ registry, registryUpdatedAt: Date.now() });
|
||||||
useUIStore.getState().addToast(`Account re-linked with ${homeDomain}`, 'success');
|
useUIStore.getState().addToast(`Account re-linked with ${homeDomain}`, 'success');
|
||||||
useInstanceStore.getState().syncRegistry().catch(() => {});
|
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) {
|
} catch (err) {
|
||||||
// Non-fatal: the connection works either way; the explicit re-attach
|
// Non-fatal: the connection works either way; the explicit re-attach
|
||||||
// action in AccountPanel remains available.
|
// action in AccountPanel remains available.
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ interface SpaceState {
|
|||||||
setRoles: (roles: Role[]) => void;
|
setRoles: (roles: Role[]) => void;
|
||||||
setDmChannels: (channels: DmChannel[]) => void;
|
setDmChannels: (channels: DmChannel[]) => void;
|
||||||
addDmChannel: (channel: DmChannel, origin?: string) => void;
|
addDmChannel: (channel: DmChannel, origin?: string) => void;
|
||||||
|
reloadDmsForOrigin: (origin: string) => Promise<void>;
|
||||||
removeDmChannel: (id: string) => void;
|
removeDmChannel: (id: string) => void;
|
||||||
addDmMember: (dmChannelId: string, user: User) => void;
|
addDmMember: (dmChannelId: string, user: User) => void;
|
||||||
removeDmMember: (dmChannelId: string, userId: string) => 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) => {
|
upsertUserView: (user, deliveringOrigin) => set((state) => {
|
||||||
const key = canonicalUserKey(user);
|
const key = canonicalUserKey(user);
|
||||||
const incomingIsHome = isDeliveryFromHome(user, deliveringOrigin);
|
const incomingIsHome = isDeliveryFromHome(user, deliveringOrigin);
|
||||||
|
|||||||
Reference in New Issue
Block a user