feat(federation): dmOriginFailover utility (rekey + failover)
failoverDmOriginsFromDisconnected(origin) walks pinned DMs and re-keys them to a connected sibling origin's local channel id (via dmAlternatives federatedId lookup). Preference: home first, then any connected remote in insertion order. rekeyDmChannel performs the atomic rename across spaceStore (dmChannels / channelOriginMap / channelLastMessageIds / dmAlternatives), chatStore (via rekeyChannelState), and the URL (via history.replaceState when viewing the rekeyed DM). Voice state is intentionally untouched — LiveKit sessions can't migrate across origins. Old origin's local id is retained in dmAlternatives for possible later fail-back without another ready round-trip.
This commit is contained in:
@@ -0,0 +1,314 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock useWebSocket imports that instanceStore / chatStore transitively depend on.
|
||||||
|
vi.mock('../hooks/useWebSocket', () => ({
|
||||||
|
wsSend: vi.fn(),
|
||||||
|
wsSendAll: vi.fn(),
|
||||||
|
connectInstance: vi.fn(),
|
||||||
|
disconnectInstance: vi.fn(),
|
||||||
|
disconnectAllRemote: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('../utils/federationOps', () => ({
|
||||||
|
clearPasswordSyncTimers: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Stub AudioManager to avoid AudioWorkletNode reference error in jsdom.
|
||||||
|
vi.mock('../audio/AudioManager', () => ({
|
||||||
|
AudioManager: {
|
||||||
|
getInstance: vi.fn().mockReturnValue({
|
||||||
|
setOutputDevice: vi.fn(),
|
||||||
|
setVolume: vi.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Stub authStore to avoid localStorage access during module init.
|
||||||
|
vi.mock('../stores/authStore', () => ({
|
||||||
|
useAuthStore: Object.assign(
|
||||||
|
(selector: (s: unknown) => unknown) => selector({ user: null, token: null }),
|
||||||
|
{
|
||||||
|
getState: () => ({ user: null, token: null }),
|
||||||
|
setState: vi.fn(),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Stub instanceStore to avoid TDZ crash from the module-level
|
||||||
|
// setApiForOriginResolver() call in instanceStore.ts.
|
||||||
|
// We use a factory so vitest hoisting sees a self-contained mock.
|
||||||
|
vi.mock('../stores/instanceStore', async () => {
|
||||||
|
const { create } = await import('zustand');
|
||||||
|
const store = create<{ instances: unknown[] }>()(() => ({ instances: [] }));
|
||||||
|
return { useInstanceStore: store };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stub voiceStore to avoid Zustand persist localStorage issues in jsdom.
|
||||||
|
// The test only needs getState()/setState() to verify voice state is untouched.
|
||||||
|
vi.mock('../stores/voiceStore', async () => {
|
||||||
|
const { create } = await import('zustand');
|
||||||
|
const store = create<{
|
||||||
|
activeDmCall: { dmChannelId: string } | null;
|
||||||
|
outgoingCall: { dmChannelId: string } | null;
|
||||||
|
incomingCall: unknown;
|
||||||
|
}>()(() => ({
|
||||||
|
activeDmCall: null,
|
||||||
|
outgoingCall: null,
|
||||||
|
incomingCall: null,
|
||||||
|
}));
|
||||||
|
return { useVoiceStore: store };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { useSpaceStore } from '../stores/spaceStore';
|
||||||
|
import { useChatStore } from '../stores/chatStore';
|
||||||
|
import { useInstanceStore } from '../stores/instanceStore';
|
||||||
|
import { failoverDmOriginsFromDisconnected } from './dmOriginFailover';
|
||||||
|
import type { DmChannel, User } from '@backspace/shared';
|
||||||
|
import type { ConnectedInstance as Inst } from '../stores/instanceStore';
|
||||||
|
|
||||||
|
function makeDm(id: string, federatedId: string | null): DmChannel {
|
||||||
|
return { id, federatedId, createdAt: 1000, members: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeUser(id: string): User {
|
||||||
|
return {
|
||||||
|
id, username: id, displayName: null, avatar: null, accentColor: null,
|
||||||
|
banner: null, bio: null, status: 'online', activities: [], createdAt: 1,
|
||||||
|
homeInstance: null, homeUserId: null,
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeInstance(origin: string, status: Inst['status']): Inst {
|
||||||
|
return {
|
||||||
|
origin,
|
||||||
|
label: origin,
|
||||||
|
token: 'tok',
|
||||||
|
user: fakeUser(`u-${origin}`),
|
||||||
|
username: 'u',
|
||||||
|
status,
|
||||||
|
api: {} as any,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useSpaceStore.getState().reset();
|
||||||
|
useChatStore.setState({
|
||||||
|
messages: new Map(),
|
||||||
|
typingUsers: new Map(),
|
||||||
|
hasMore: new Map(),
|
||||||
|
readStates: new Map(),
|
||||||
|
unreadChannels: new Set(),
|
||||||
|
channelAccessTimes: new Map(),
|
||||||
|
scrollPositions: new Map(),
|
||||||
|
currentChannelId: null,
|
||||||
|
});
|
||||||
|
useInstanceStore.setState({ instances: [] });
|
||||||
|
// Replace history.replaceState to observe URL writes in tests.
|
||||||
|
vi.spyOn(window.history, 'replaceState').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('failoverDmOriginsFromDisconnected', () => {
|
||||||
|
it('no-ops when disconnected origin has no DMs pinned to it', () => {
|
||||||
|
useSpaceStore.setState({
|
||||||
|
dmChannels: [makeDm('home-1', 'fed-aaa')],
|
||||||
|
channelOriginMap: new Map([['home-1', '']]),
|
||||||
|
dmAlternatives: new Map([['fed-aaa', new Map([['', 'home-1']])]]),
|
||||||
|
});
|
||||||
|
useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] });
|
||||||
|
|
||||||
|
failoverDmOriginsFromDisconnected('https://b.example');
|
||||||
|
|
||||||
|
expect(useSpaceStore.getState().channelOriginMap.get('home-1')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-keys a DM to the connected home alternative', () => {
|
||||||
|
useSpaceStore.setState({
|
||||||
|
dmChannels: [makeDm('b-1', 'fed-aaa')],
|
||||||
|
channelOriginMap: new Map([['b-1', 'https://b.example']]),
|
||||||
|
channelLastMessageIds: new Map([['b-1', 'msg-last-on-b']]),
|
||||||
|
dmAlternatives: new Map([
|
||||||
|
['fed-aaa', new Map([
|
||||||
|
['https://b.example', 'b-1'],
|
||||||
|
['', 'home-1'],
|
||||||
|
])],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] });
|
||||||
|
useChatStore.setState({
|
||||||
|
messages: new Map([['b-1', []]]),
|
||||||
|
readStates: new Map([['b-1', 'prev']]),
|
||||||
|
unreadChannels: new Set(['b-1']),
|
||||||
|
currentChannelId: 'b-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
failoverDmOriginsFromDisconnected('https://b.example');
|
||||||
|
|
||||||
|
const sp = useSpaceStore.getState();
|
||||||
|
expect(sp.dmChannels.find(d => d.id === 'home-1')).toBeTruthy();
|
||||||
|
expect(sp.dmChannels.find(d => d.id === 'b-1')).toBeUndefined();
|
||||||
|
expect(sp.channelOriginMap.get('home-1')).toBe('');
|
||||||
|
expect(sp.channelOriginMap.has('b-1')).toBe(false);
|
||||||
|
expect(sp.channelLastMessageIds.has('b-1')).toBe(false);
|
||||||
|
|
||||||
|
const ch = useChatStore.getState();
|
||||||
|
expect(ch.messages.has('b-1')).toBe(false);
|
||||||
|
expect(ch.readStates.has('b-1')).toBe(false);
|
||||||
|
expect(ch.unreadChannels.has('home-1')).toBe(true);
|
||||||
|
expect(ch.unreadChannels.has('b-1')).toBe(false);
|
||||||
|
expect(ch.currentChannelId).toBe('home-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers home (empty-string origin) when multiple alternatives are connected', () => {
|
||||||
|
useSpaceStore.setState({
|
||||||
|
dmChannels: [makeDm('b-1', 'fed-aaa')],
|
||||||
|
channelOriginMap: new Map([['b-1', 'https://b.example']]),
|
||||||
|
dmAlternatives: new Map([
|
||||||
|
['fed-aaa', new Map([
|
||||||
|
['https://b.example', 'b-1'],
|
||||||
|
['https://c.example', 'c-1'],
|
||||||
|
['', 'home-1'],
|
||||||
|
])],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
useInstanceStore.setState({
|
||||||
|
instances: [
|
||||||
|
fakeInstance('https://b.example', 'disconnected'),
|
||||||
|
fakeInstance('https://c.example', 'connected'),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
failoverDmOriginsFromDisconnected('https://b.example');
|
||||||
|
|
||||||
|
expect(useSpaceStore.getState().channelOriginMap.get('home-1')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a connected remote when home is not an alternative', () => {
|
||||||
|
useSpaceStore.setState({
|
||||||
|
dmChannels: [makeDm('b-1', 'fed-aaa')],
|
||||||
|
channelOriginMap: new Map([['b-1', 'https://b.example']]),
|
||||||
|
dmAlternatives: new Map([
|
||||||
|
['fed-aaa', new Map([
|
||||||
|
['https://b.example', 'b-1'],
|
||||||
|
['https://c.example', 'c-1'],
|
||||||
|
])],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
useInstanceStore.setState({
|
||||||
|
instances: [
|
||||||
|
fakeInstance('https://b.example', 'disconnected'),
|
||||||
|
fakeInstance('https://c.example', 'connected'),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
failoverDmOriginsFromDisconnected('https://b.example');
|
||||||
|
|
||||||
|
const sp = useSpaceStore.getState();
|
||||||
|
expect(sp.channelOriginMap.get('c-1')).toBe('https://c.example');
|
||||||
|
expect(sp.dmChannels.find(d => d.id === 'c-1')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the pin untouched when no alternatives are connected', () => {
|
||||||
|
useSpaceStore.setState({
|
||||||
|
dmChannels: [makeDm('b-1', 'fed-aaa')],
|
||||||
|
channelOriginMap: new Map([['b-1', 'https://b.example']]),
|
||||||
|
dmAlternatives: new Map([
|
||||||
|
['fed-aaa', new Map([
|
||||||
|
['https://b.example', 'b-1'],
|
||||||
|
['https://c.example', 'c-1'],
|
||||||
|
])],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
useInstanceStore.setState({
|
||||||
|
instances: [
|
||||||
|
fakeInstance('https://b.example', 'disconnected'),
|
||||||
|
fakeInstance('https://c.example', 'error'),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
failoverDmOriginsFromDisconnected('https://b.example');
|
||||||
|
|
||||||
|
expect(useSpaceStore.getState().channelOriginMap.get('b-1')).toBe('https://b.example');
|
||||||
|
expect(useSpaceStore.getState().dmChannels.find(d => d.id === 'b-1')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips DMs without a federatedId', () => {
|
||||||
|
useSpaceStore.setState({
|
||||||
|
dmChannels: [makeDm('b-local', null)],
|
||||||
|
channelOriginMap: new Map([['b-local', 'https://b.example']]),
|
||||||
|
dmAlternatives: new Map(),
|
||||||
|
});
|
||||||
|
useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] });
|
||||||
|
|
||||||
|
failoverDmOriginsFromDisconnected('https://b.example');
|
||||||
|
|
||||||
|
expect(useSpaceStore.getState().channelOriginMap.get('b-local')).toBe('https://b.example');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains oldOrigin → oldLocalId in dmAlternatives after rekey for fail-back', () => {
|
||||||
|
useSpaceStore.setState({
|
||||||
|
dmChannels: [makeDm('b-1', 'fed-aaa')],
|
||||||
|
channelOriginMap: new Map([['b-1', 'https://b.example']]),
|
||||||
|
dmAlternatives: new Map([
|
||||||
|
['fed-aaa', new Map([
|
||||||
|
['https://b.example', 'b-1'],
|
||||||
|
['', 'home-1'],
|
||||||
|
])],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] });
|
||||||
|
|
||||||
|
failoverDmOriginsFromDisconnected('https://b.example');
|
||||||
|
|
||||||
|
const alts = useSpaceStore.getState().dmAlternatives.get('fed-aaa');
|
||||||
|
// Old primary (b) retained as alternative for possible future fail-back.
|
||||||
|
expect(alts?.get('https://b.example')).toBe('b-1');
|
||||||
|
// Alternative map entry for the new primary's own origin is removed — it IS the primary now.
|
||||||
|
expect(alts?.has('')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the URL via history.replaceState when rekeying the current channel', () => {
|
||||||
|
window.history.pushState({}, '', '/channels/@me/b-1');
|
||||||
|
useSpaceStore.setState({
|
||||||
|
dmChannels: [makeDm('b-1', 'fed-aaa')],
|
||||||
|
channelOriginMap: new Map([['b-1', 'https://b.example']]),
|
||||||
|
dmAlternatives: new Map([
|
||||||
|
['fed-aaa', new Map([
|
||||||
|
['https://b.example', 'b-1'],
|
||||||
|
['', 'home-1'],
|
||||||
|
])],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] });
|
||||||
|
useChatStore.setState({ currentChannelId: 'b-1' });
|
||||||
|
|
||||||
|
failoverDmOriginsFromDisconnected('https://b.example');
|
||||||
|
|
||||||
|
expect(window.history.replaceState).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
'',
|
||||||
|
expect.stringContaining('/channels/@me/home-1'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not touch voice state during failover (voice is handled separately)', async () => {
|
||||||
|
useSpaceStore.setState({
|
||||||
|
dmChannels: [makeDm('b-1', 'fed-aaa')],
|
||||||
|
channelOriginMap: new Map([['b-1', 'https://b.example']]),
|
||||||
|
dmAlternatives: new Map([
|
||||||
|
['fed-aaa', new Map([['https://b.example', 'b-1'], ['', 'home-1']])],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] });
|
||||||
|
|
||||||
|
const { useVoiceStore } = await import('../stores/voiceStore');
|
||||||
|
const beforeActive = useVoiceStore.getState().activeDmCall;
|
||||||
|
const beforeOutgoing = useVoiceStore.getState().outgoingCall;
|
||||||
|
useVoiceStore.setState({ activeDmCall: { dmChannelId: 'b-1' } });
|
||||||
|
|
||||||
|
failoverDmOriginsFromDisconnected('https://b.example');
|
||||||
|
|
||||||
|
expect(useVoiceStore.getState().activeDmCall?.dmChannelId).toBe('b-1');
|
||||||
|
// Restore
|
||||||
|
useVoiceStore.setState({ activeDmCall: beforeActive, outgoingCall: beforeOutgoing });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { useSpaceStore } from '../stores/spaceStore';
|
||||||
|
import { useChatStore } from '../stores/chatStore';
|
||||||
|
import { useInstanceStore } from '../stores/instanceStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DM origin failover: when a remote instance disconnects mid-session,
|
||||||
|
* re-point every DM pinned to it onto a connected sibling that holds the
|
||||||
|
* same federated DM (via S2S mirroring). See
|
||||||
|
* `docs/superpowers/specs/2026-04-23-dm-origin-failover-design.md`.
|
||||||
|
*
|
||||||
|
* No-op when:
|
||||||
|
* - no DMs are pinned to the disconnected origin
|
||||||
|
* - pinned DMs have no `federatedId` (never-federated)
|
||||||
|
* - no alternative origin is currently connected
|
||||||
|
*
|
||||||
|
* Voice state (activeDmCall / outgoingCall / incomingCall) is intentionally
|
||||||
|
* NOT rewritten — a LiveKit session bound to the disconnected origin cannot
|
||||||
|
* migrate; voice cleans up through its own disconnect paths.
|
||||||
|
*/
|
||||||
|
export function failoverDmOriginsFromDisconnected(disconnectedOrigin: string): void {
|
||||||
|
const spaceState = useSpaceStore.getState();
|
||||||
|
const { dmChannels, channelOriginMap, dmAlternatives } = spaceState;
|
||||||
|
|
||||||
|
// Build the set of connected-origin candidates (home is always considered
|
||||||
|
// connected while auth is live — its WS disconnect path doesn't go through
|
||||||
|
// instanceStore at all).
|
||||||
|
const instances = useInstanceStore.getState().instances;
|
||||||
|
const connectedRemotes = new Set(
|
||||||
|
instances.filter(i => i.status === 'connected').map(i => i.origin),
|
||||||
|
);
|
||||||
|
const isOriginConnected = (o: string): boolean => o === '' || connectedRemotes.has(o);
|
||||||
|
|
||||||
|
type Rekey = { oldId: string; newId: string; newOrigin: string; federatedId: string };
|
||||||
|
const rekeys: Rekey[] = [];
|
||||||
|
|
||||||
|
for (const dm of dmChannels) {
|
||||||
|
if (channelOriginMap.get(dm.id) !== disconnectedOrigin) continue;
|
||||||
|
if (!dm.federatedId) continue;
|
||||||
|
|
||||||
|
const byOrigin = dmAlternatives.get(dm.federatedId);
|
||||||
|
if (!byOrigin) continue;
|
||||||
|
|
||||||
|
// Preference order: home ('') first, then any connected remote in
|
||||||
|
// insertion order (Map preserves insertion order in ES2015+).
|
||||||
|
let chosenOrigin: string | null = null;
|
||||||
|
let chosenLocalId: string | null = null;
|
||||||
|
if (byOrigin.has('') && isOriginConnected('') && '' !== disconnectedOrigin) {
|
||||||
|
chosenOrigin = '';
|
||||||
|
chosenLocalId = byOrigin.get('')!;
|
||||||
|
} else {
|
||||||
|
for (const [altOrigin, altLocalId] of byOrigin) {
|
||||||
|
if (altOrigin === disconnectedOrigin) continue;
|
||||||
|
if (!isOriginConnected(altOrigin)) continue;
|
||||||
|
chosenOrigin = altOrigin;
|
||||||
|
chosenLocalId = altLocalId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chosenOrigin === null || chosenLocalId === null) continue;
|
||||||
|
if (chosenLocalId === dm.id) continue; // shouldn't happen, but guard
|
||||||
|
|
||||||
|
rekeys.push({
|
||||||
|
oldId: dm.id,
|
||||||
|
newId: chosenLocalId,
|
||||||
|
newOrigin: chosenOrigin,
|
||||||
|
federatedId: dm.federatedId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rekeys.length === 0) return;
|
||||||
|
|
||||||
|
for (const r of rekeys) {
|
||||||
|
rekeyDmChannel(r.oldId, r.newId, r.newOrigin, r.federatedId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomic rename of a DM across spaceStore + chatStore + URL.
|
||||||
|
* Exported for unit tests; not part of the public failover API.
|
||||||
|
*/
|
||||||
|
export function rekeyDmChannel(
|
||||||
|
oldId: string,
|
||||||
|
newId: string,
|
||||||
|
newOrigin: string,
|
||||||
|
federatedId: string,
|
||||||
|
): void {
|
||||||
|
useSpaceStore.setState((state) => {
|
||||||
|
const dmChannels = state.dmChannels.map(dm =>
|
||||||
|
dm.id === oldId ? { ...dm, id: newId } : dm,
|
||||||
|
);
|
||||||
|
|
||||||
|
const channelOriginMap = new Map(state.channelOriginMap);
|
||||||
|
channelOriginMap.delete(oldId);
|
||||||
|
channelOriginMap.set(newId, newOrigin);
|
||||||
|
|
||||||
|
const channelLastMessageIds = new Map(state.channelLastMessageIds);
|
||||||
|
channelLastMessageIds.delete(oldId); // old id's stored last-message id is origin-local
|
||||||
|
|
||||||
|
// dmAlternatives update: remove the chosen origin's entry from the inner map
|
||||||
|
// (it IS the primary now — not an alternative), but RETAIN every other origin's
|
||||||
|
// entry including the old origin (so a later fail-back can use it without
|
||||||
|
// needing another ready round-trip).
|
||||||
|
const dmAlternatives = new Map<string, Map<string, string>>();
|
||||||
|
for (const [fid, byOrigin] of state.dmAlternatives) {
|
||||||
|
const next = new Map(byOrigin);
|
||||||
|
if (fid === federatedId) next.delete(newOrigin);
|
||||||
|
if (next.size > 0) dmAlternatives.set(fid, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dmChannels, channelOriginMap, channelLastMessageIds, dmAlternatives };
|
||||||
|
});
|
||||||
|
|
||||||
|
useChatStore.getState().rekeyChannelState(oldId, newId);
|
||||||
|
|
||||||
|
// URL update: if the user is viewing the DM whose id just changed, swap the
|
||||||
|
// last path segment in place. No router navigation — the chat view re-renders
|
||||||
|
// from the updated currentChannelId.
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const marker = `/channels/@me/`;
|
||||||
|
const idx = path.indexOf(marker);
|
||||||
|
if (idx !== -1 && path.slice(idx + marker.length) === oldId) {
|
||||||
|
const nextPath = path.slice(0, idx + marker.length) + newId;
|
||||||
|
window.history.replaceState(window.history.state, '', nextPath + window.location.search);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user