Compare commits

..
1 Commits
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 36e697613b fix(messages): the delivery indicator updates when a send completes (#306)
CI / Build & Quality Checks (pull_request) Successful in 3m47s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 13s
CI / Playwright smoke (e2e) (pull_request) Successful in 14m53s
DeliveryStatus showed whatever mEvent.status was at the last timeline
re-render, and a local echo's status changes (SENDING → SENT → confirmed,
NOT_SENT → retried) only emit event-level events, so the last message in
a quiet room kept "Sending..." (or a stale "Failed to send. Click to
retry" after an outbox retry) indefinitely. It now re-renders on
MatrixEventEvent.Status / LocalEventIdReplaced.

Not browser-verified in this change (checks: tsc, unit tests, eslint).

Fixes #306

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 22:25:41 -04:00
14 changed files with 37 additions and 228 deletions
+3 -47
View File
@@ -18,10 +18,8 @@ import {
toRem,
} from 'folds';
import {
ClientEvent,
EventTimelineSetHandlerMap,
EventType,
SyncState,
RelationType,
Room,
RoomEvent,
@@ -74,7 +72,7 @@ import { useSetting } from '../state/hooks/settings';
import { useCallPreferences } from '../state/hooks/callPreferences';
import { settingsAtom } from '../state/settings';
import { getStateEvent, getMemberName } from '../utils/room';
import { declinedRing, hasActiveCallMembership, isRingableRoom } from '../utils/callRing';
import { isRingableRoom } from '../utils/callRing';
import { StateEvent } from '../../types/matrix/room';
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
@@ -598,50 +596,11 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
refEventId,
};
// [Gitea #312] Don't ring for a call that is already over or handled: after
// a reload the first sync replays the ring together with the caller's
// hang-up and our own decline. Check the room STATE (not the possibly
// stale RTC session). The caller's membership can arrive just after the
// ring, so an absent one gets a short grace period before giving up.
const myUserId = mx.getSafeUserId();
if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) {
return;
}
if (hasActiveCallMembership(room, sender)) {
setCallInfo(info);
return;
}
setTimeout(() => {
if (Date.now() >= senderTs + lifetime) return;
if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) {
return;
}
if (hasActiveCallMembership(room, sender)) setCallInfo(info);
}, 4000);
setCallInfo(info);
},
[mx, directs, callEmbed, setToast],
);
// [Gitea #312] A catch-up sync (after a reload or reconnect) can replay a
// call's join and leave in one batch, and the RTC session may keep the join
// (stale "1 Live", call screen instead of chat). Once sync is live again,
// recompute every active session from the room's current state.
useEffect(() => {
const onSync = (state: SyncState, prev: SyncState | null) => {
if (state !== SyncState.Syncing || prev === SyncState.Syncing) return;
mx.getRooms().forEach((r) => {
mx.matrixRTC
.getActiveRoomSession(r)
?._onRTCSessionMemberUpdate()
.catch(() => undefined);
});
};
mx.on(ClientEvent.Sync, onSync);
return () => {
mx.removeListener(ClientEvent.Sync, onSync);
};
}, [mx]);
useEffect(() => {
mx.on(RoomEvent.Timeline, handleTimelineEvent);
return () => {
@@ -670,10 +629,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const { memberships } = session;
if (memberships.some((m) => m.sender === myUserId))
dismiss(); // answered elsewhere
else if (memberships.length === 0)
dismiss(); // caller gone
// [Gitea #312] The session can be stale after a reload; the room state isn't.
else if (!hasActiveCallMembership(room, callInfo.sender)) dismiss();
else if (memberships.length === 0) dismiss(); // caller gone
};
const onTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
event,
@@ -43,7 +43,6 @@ import { DebounceOptions, useDebounce } from '../../hooks/useDebounce';
import { VirtualTile } from '../../components/virtualizer';
import { stopPropagation } from '../../utils/keyboard';
import { MsgTypeFilter } from './useMessageSearch';
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
const MSG_TYPE_FILTER_OPTIONS: { msgType: MsgTypeFilter; label: string; icon: IconSrc }[] = [
{ msgType: 'm.image', label: 'Images', icon: Icons.Photo },
@@ -138,19 +137,14 @@ type SelectRoomButtonProps = {
onChange: (rooms?: string[]) => void;
};
function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButtonProps) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient();
const scrollRef = useRef<HTMLDivElement>(null);
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [localSelected, setLocalSelected] = useState(selectedRooms);
const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
(rId) => {
const name = mx.getRoom(rId)?.name ?? rId;
// [Gitea #336] match the personal "Rename for me" name too
return localNames[rId] ? [localNames[rId], name] : name;
},
[mx, localNames],
(rId) => mx.getRoom(rId)?.name ?? rId,
[mx],
);
const [searchResult, _searchRoom, resetSearch] = useAsyncSearch(
@@ -298,7 +292,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
}
>
<Text truncate size="T300">
{localNames[room.roomId] ?? room.name}
{room.name}
</Text>
</MenuItem>
</VirtualTile>
@@ -707,7 +701,6 @@ export function SearchFilters({
pinnedOnly,
onTogglePinnedOnly,
}: SearchFiltersProps) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient();
return (
@@ -755,7 +748,7 @@ export function SearchFilters({
}
after={<Icon size="50" src={Icons.Cross} />}
>
<Text size="T200">{localNames[room.roomId] ?? room.name}</Text>
<Text size="T200">{room.name}</Text>
</Chip>
);
})}
@@ -50,7 +50,6 @@ import {
} from '../../hooks/useMemberPowerTag';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
type SearchResultGroupProps = {
room: Room;
@@ -74,7 +73,6 @@ export function SearchResultGroup({
hour24Clock,
dateFormatString,
}: SearchResultGroupProps) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]);
@@ -214,7 +212,7 @@ export function SearchResultGroup({
/>
</Avatar>
<Text size="H4" truncate>
{localNames[room.roomId] ?? room.name}
{room.name}
</Text>
</Box>
</Header>
+2 -5
View File
@@ -121,7 +121,7 @@ import { ThreadSummary } from './thread/ThreadSummary';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
import { useStateEvent } from '../../hooks/useStateEvent';
import { RetentionContent, effectiveRetentionMs, isExpired } from '../../utils/retention';
import { RetentionContent, isExpired } from '../../utils/retention';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
import { RenderMessageContent } from '../../components/RenderMessageContent';
@@ -494,10 +494,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// timeline (unless "show hidden events" is on). Reactive so a policy change
// re-renders. `undefined` = no policy.
const retentionEvent = useStateEvent(room, StateEvent.RoomRetention);
// [Gitea #298] Same floor as everywhere else: a tiny value must not blank the room.
const retentionMs = effectiveRetentionMs(
retentionEvent?.getContent<RetentionContent>().max_lifetime,
);
const retentionMs = retentionEvent?.getContent<RetentionContent>().max_lifetime;
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
+16 -1
View File
@@ -31,12 +31,13 @@ import React, {
MouseEventHandler,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import FocusTrap from 'focus-trap-react';
import { useHover, useFocusWithin } from 'react-aria';
import { MatrixEvent, Room, EventStatus } from 'matrix-js-sdk';
import { MatrixEvent, MatrixEventEvent, Room, EventStatus } from 'matrix-js-sdk';
import { Relations } from 'matrix-js-sdk/lib/models/relations';
import classNames from 'classnames';
import { useAtom, useAtomValue } from 'jotai';
@@ -114,6 +115,20 @@ function DeliveryStatus({
}) {
const mx = useMatrixClient();
const offline = useAtomValue(sendOfflineAtom);
// [Gitea #306] A local echo's status changes (SENDING → SENT → confirmed,
// NOT_SENT → retried) don't re-render the timeline, so the last message in a
// quiet room kept "Sending..." (or a stale "Failed to send") indefinitely.
// Re-render on the event's own status updates.
const [, setStatusTick] = useState(0);
useEffect(() => {
const bump = () => setStatusTick((t) => t + 1);
mEvent.on(MatrixEventEvent.Status, bump);
mEvent.on(MatrixEventEvent.LocalEventIdReplaced, bump);
return () => {
mEvent.off(MatrixEventEvent.Status, bump);
mEvent.off(MatrixEventEvent.LocalEventIdReplaced, bump);
};
}, [mEvent]);
const { status } = mEvent;
if (status === null) return null; // confirmed by server — read receipts take over
let iconSrc: IconSrc;
+1 -3
View File
@@ -32,7 +32,6 @@ import { useThreadNotificationMode } from '../../../hooks/useThreadNotifications
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
import { getMemberName } from '../../../utils/room';
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
type ThreadPanelHeaderProps = {
room: Room;
@@ -40,7 +39,6 @@ type ThreadPanelHeaderProps = {
requestClose: () => void;
};
function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) {
const localNames = useLocalRoomNames();
const mode = useThreadNotificationMode(room.roomId, threadId);
const { format } = useTimestampFormatter();
// [Gitea #165] Who started the thread and when — the first thing you want
@@ -57,7 +55,7 @@ function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderPr
Thread
</Text>
<Text size="T200" truncate style={{ opacity: 0.65 }}>
{localNames[room.roomId] ?? room.name}
{room.name}
{startedBy && root && ` · started by ${startedBy}, ${format(root.getTs())}`}
</Text>
</Box>
@@ -48,7 +48,6 @@ import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
import { formatRelativeAge } from '../../../utils/formatTimestamp';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
// Persisted across panel opens (the panel unmounts on close). getOnInit reads
// localStorage synchronously so the chosen filter/sort apply on first render.
@@ -236,7 +235,6 @@ export type ThreadsListPanelProps = {
onOpenThread: (threadId: string) => void;
};
export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPanelProps) {
const localNames = useLocalRoomNames();
const { navigateRoom } = useRoomNavigate();
const screenSize = useScreenSizeContext();
const handleJump = useCallback(
@@ -363,7 +361,7 @@ export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPan
Threads
</Text>
<Text size="T200" truncate style={{ opacity: 0.65 }}>
{localNames[room.roomId] ?? room.name}
{room.name}
</Text>
</Box>
{totalUnread > 0 && (
+1 -9
View File
@@ -68,12 +68,7 @@ import { setPresenceWithRetry } from '../../utils/presenceWrite';
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
import { dismissToastAtom, toastQueueAtom } from '../../state/toast';
import { useReminders } from '../../hooks/useReminders';
import {
getRoomRetentionMs,
getRoomRetentionSetAt,
isExpired,
retentionDeletesAllowed,
} from '../../utils/retention';
import { getRoomRetentionMs, isExpired } from '../../utils/retention';
import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpdater';
import { settingsRequestAtom } from '../../state/settingsRequest';
import { isNetworkUpdateError } from '../../utils/updateErrors';
@@ -915,9 +910,6 @@ function RetentionSweeper() {
mx.getRooms().forEach((room) => {
const maxLifetime = getRoomRetentionMs(room);
if (!maxLifetime) return;
// [Gitea #298] Never delete under a policy that is less than a day old.
const setAt = getRoomRetentionSetAt(room);
if (setAt === undefined || !retentionDeletesAllowed(setAt, now)) return;
room
.getLiveTimeline()
.getEvents()
+1 -3
View File
@@ -98,7 +98,6 @@ import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { PresenceRingAvatar } from '../../../components/presence';
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
type RoomNotificationsGroup = {
roomId: string;
@@ -224,7 +223,6 @@ function RoomNotificationsGroupComp({
hour24Clock,
dateFormatString,
}: RoomNotificationsGroupProps) {
const localNames = useLocalRoomNames();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
@@ -430,7 +428,7 @@ function RoomNotificationsGroupComp({
/>
</Avatar>
<Text size="H4" truncate>
{localNames[room.roomId] ?? room.name}
{room.name}
</Text>
</Box>
<Box shrink="No">
+1 -41
View File
@@ -1,7 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { Room } from 'matrix-js-sdk';
import { declinedRing, hasActiveCallMembership, isRingableRoom } from './callRing';
import { isRingableRoom } from './callRing';
const fakeRoom = (opts: { callRoom?: boolean; joinRule?: string; spaceParent?: boolean }): Room =>
({
@@ -27,43 +27,3 @@ test('space channels, public and restricted rooms do not ring', () => {
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), false), false);
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'restricted' }), false), false);
});
const stateRoom = (
members: Array<{ sender: string; content: Record<string, unknown> }>,
timeline: unknown[] = [],
): Room =>
({
getLiveTimeline: () => ({
getState: () => ({
getStateEvents: () =>
members.map((m) => ({
getSender: () => m.sender,
getStateKey: () => `_${m.sender}_DEV_m.call`,
getContent: () => m.content,
})),
}),
getEvents: () => timeline,
}),
}) as unknown as Room;
test('hasActiveCallMembership reads the room state: joined vs left (#312)', () => {
const room = stateRoom([
{ sender: '@bob:x', content: { application: 'm.call', call_id: '' } },
{ sender: '@carol:x', content: {} },
]);
assert.equal(hasActiveCallMembership(room, '@bob:x'), true);
assert.equal(hasActiveCallMembership(room, '@carol:x'), false, 'left = empty content');
assert.equal(hasActiveCallMembership(room, '@dave:x'), false);
});
test('declinedRing finds our own decline of that ring only (#312)', () => {
const decline = (sender: string, rel: string) => ({
getType: () => 'org.matrix.msc4310.rtc.decline',
getSender: () => sender,
getRelation: () => ({ event_id: rel }),
});
const room = stateRoom([], [decline('@me:x', '$ring1'), decline('@bob:x', '$ring2')]);
assert.equal(declinedRing(room, '$ring1', '@me:x'), true);
assert.equal(declinedRing(room, '$ring2', '@me:x'), false, "someone else's decline");
assert.equal(declinedRing(room, '$ring3', '@me:x'), false);
});
-34
View File
@@ -16,37 +16,3 @@ export const isRingableRoom = (room: Room, isDirect: boolean): boolean => {
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
return !isSpaceChild && room.getJoinRule() === JoinRule.Invite;
};
/** A call-membership state event that is still "in the call" (left = empty content). */
const isActiveMembership = (content: Record<string, unknown> | undefined): boolean =>
!!content &&
(typeof content.application === 'string' ||
(Array.isArray(content.memberships) && content.memberships.length > 0));
/**
* [Gitea #312] Whether `userId` is in a call in `room` according to the room's
* current STATE. After a reload the MatrixRTC session can be stale (a join and
* leave replayed in one catch-up batch), but the state is right.
*/
export const hasActiveCallMembership = (room: Room, userId: string): boolean =>
getStateEvents(room, StateEvent.GroupCallMemberPrefix).some(
(ev) =>
(ev.getSender() === userId || ev.getStateKey()?.includes(userId)) &&
isActiveMembership(ev.getContent()),
);
/**
* [Gitea #312] Whether we already handled the ring `refEventId` points at:
* declined it (our own RTCDecline relates to it) — e.g. replayed together with
* the ring in the first sync after a restart, before any dismiss listener.
*/
export const declinedRing = (room: Room, refEventId: string, myUserId: string): boolean =>
room
.getLiveTimeline()
.getEvents()
.some(
(ev) =>
ev.getType() === 'org.matrix.msc4310.rtc.decline' &&
ev.getSender() === myUserId &&
ev.getRelation()?.event_id === refEventId,
);
+1 -27
View File
@@ -1,13 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
effectiveRetentionMs,
isExpired,
RETENTION_DELETE_GRACE_MS,
RETENTION_MIN_MS,
RETENTION_PRESETS,
retentionDeletesAllowed,
} from './retention';
import { isExpired, RETENTION_PRESETS, RETENTION_MIN_MS } from './retention';
// MSC1763 retention: `isExpired` decides whether a message is past the room's
// retention window. It must be strict (> window, not >=) and a disabled policy
@@ -47,22 +40,3 @@ test('presets: Off is 0 and the rest are strictly increasing, all >= the floor',
'all presets above the floor',
);
});
test('effectiveRetentionMs: tiny or invalid windows count as off (#298)', () => {
assert.equal(effectiveRetentionMs(1000), undefined, '1 s is a typo, not a policy');
assert.equal(effectiveRetentionMs(RETENTION_MIN_MS - 1), undefined);
assert.equal(effectiveRetentionMs(RETENTION_MIN_MS), RETENTION_MIN_MS);
assert.equal(effectiveRetentionMs(86_400_000), 86_400_000);
assert.equal(effectiveRetentionMs(0), undefined);
assert.equal(effectiveRetentionMs(-5), undefined);
assert.equal(effectiveRetentionMs('86400000'), undefined);
assert.equal(effectiveRetentionMs(Number.NaN), undefined);
});
test('retentionDeletesAllowed: no deletions in the first 24 h of a policy (#298)', () => {
const now = 1_000 * HOUR;
assert.equal(retentionDeletesAllowed(now - 2 * HOUR, now), false);
assert.equal(retentionDeletesAllowed(now - RETENTION_DELETE_GRACE_MS + 1, now), false);
assert.equal(retentionDeletesAllowed(now - RETENTION_DELETE_GRACE_MS, now), true);
assert.equal(retentionDeletesAllowed(now - 72 * HOUR, now), true);
});
+2 -27
View File
@@ -20,38 +20,13 @@ export const RETENTION_PRESETS: RetentionPreset[] = [
{ label: '1 Month', ms: 30 * DAY_MS },
];
/**
* [Gitea #298] A usable retention window, or `undefined`. Anything under
* RETENTION_MIN_MS is treated as off: a fat-fingered `max_lifetime: 1000` (set
* from any client by anyone with state power) used to blank every timeline and
* get opted-in users' messages redacted within a minute.
*/
export const effectiveRetentionMs = (maxLifetime: unknown): number | undefined =>
typeof maxLifetime === 'number' && Number.isFinite(maxLifetime) && maxLifetime >= RETENTION_MIN_MS
? maxLifetime
: undefined;
/** The room's active retention window in ms, or `undefined` when unset/disabled. */
export const getRoomRetentionMs = (room: Room): number | undefined => {
const event = room.currentState.getStateEvents(StateEvent.RoomRetention, '');
return effectiveRetentionMs(event?.getContent<RetentionContent>()?.max_lifetime);
const ms = event?.getContent<RetentionContent>()?.max_lifetime;
return typeof ms === 'number' && ms > 0 ? ms : undefined;
};
/**
* [Gitea #298] Deletion grace: the auto-delete sweeper ignores a retention
* policy for this long after it was set, so a mistaken or malicious change can
* be reverted before anything is permanently redacted. (Hiding expired
* messages locally is not destructive and applies at once.)
*/
export const RETENTION_DELETE_GRACE_MS = DAY_MS;
export const retentionDeletesAllowed = (policySetAtMs: number, nowMs: number): boolean =>
nowMs - policySetAtMs >= RETENTION_DELETE_GRACE_MS;
/** When the room's current retention policy was set (its state event's ts). */
export const getRoomRetentionSetAt = (room: Room): number | undefined =>
room.currentState.getStateEvents(StateEvent.RoomRetention, '')?.getTs();
/** True when an event at `tsMs` has passed the `maxLifetimeMs` retention window. */
export const isExpired = (tsMs: number, maxLifetimeMs: number, nowMs: number): boolean =>
maxLifetimeMs > 0 && nowMs - tsMs > maxLifetimeMs;
+3 -14
View File
@@ -20,12 +20,9 @@ export const IDB_VERSION_CONFLICT = 'IDB_VERSION_CONFLICT';
* session. When that happens the device "resurrects" with a blank key store and the
* client re-uploads a one-time key at an id Synapse already holds → a permanent
* `400 M_UNKNOWN: … already exists` upload-conflict storm (and, downstream,
* undecryptable to-device/media keys). Chromium/WebKit answer `persist()` at once
* (engagement / PWA install); Firefox shows a permission prompt and the promise
* stays pending until it's answered. Denial/absence is non-fatal.
* undecryptable to-device/media keys). `persist()` grants based on engagement / PWA
* install and shows no prompt; denial/absence is non-fatal.
*/
const PERSIST_WAIT_MS = 1500;
export const requestPersistentStorage = async (): Promise<boolean> => {
try {
if (!navigator.storage?.persist) return false;
@@ -42,15 +39,7 @@ export const requestPersistentStorage = async (): Promise<boolean> => {
export const initClient = async (session: Session): Promise<MatrixClient> => {
// Protect the crypto store from eviction before we create and write to it.
// [Gitea #303] Never block startup on the answer: Firefox prompts and leaves
// the promise pending until the user responds, which froze the app on
// "Heating up". KE-1 only needs the request made; give it 1.5 s at most.
await Promise.race([
requestPersistentStorage(),
new Promise<boolean>((resolve) => {
setTimeout(() => resolve(false), PERSIST_WAIT_MS);
}),
]);
await requestPersistentStorage();
const indexedDBStore = new IndexedDBStore({
indexedDB: globalThis.indexedDB,