Compare commits

...
2 Commits
Author SHA1 Message Date
jaredandClaude Opus 5 c6c2e88df5 feat(settings): storage usage tile with persistence status (#120)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
One tile under Settings → General → Storage: total local usage vs the
browser's quota from navigator.storage.estimate(), the Chromium
usageDetails breakdown when available (IndexedDB = sync cache + encryption
keys + search index; Cache Storage = offline app files), and whether the
browser granted persistent storage — green "Protected" or amber "May be
evicted" with a "Keep my data" button that calls storage.persist(). That
last bit is the useful diagnostic for the KE-1 storage-eviction cluster.

No clear button: media lives in the browser's own HTTP cache (not in the
estimate, and not clearable from a page), the crypto store must never be
casually cleared, and the search-index clear already lives in Message
Search. The About page's "Clear Cache & Reload" now says it deletes this
device's encryption keys too. Hidden entirely when estimate() is missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 14:53:42 -04:00
jaredandClaude Opus 5 dc0d524989 feat(calls): rejoin the voice room after a crash, update or reload (#118)
While joined, a device-local record {roomId, deviceId, joinedAt,
lastSeen, mic, video} is written and refreshed every 30 s; a deliberate
hangup (HangupCall/Close) and logout clear it. On the next start, once
the first sync is in and the room's MatrixRTC session has reported its
members (waited for up to 10 s — it fills a moment after sync), a fresh
record (< 10 min) for this device with someone still in the call yields
either a sticky "Rejoin voice?" toast (tap to rejoin) or an automatic
rejoin, per the new Settings → Calls → After a Restart (Ask / Rejoin
automatically / Do nothing; default Ask). Skipped when our own membership
is already live from another device, or only our stale one is left.
Mic/camera state comes from the record (camera still gated by
cameraOnJoin); PTT is applied by startCall as usual.

Pure decision in utils/callRejoin.ts with tests. Verified headless:
reload mid-call → toast → tap → back in the call; hangup → reload → no
toast; auto mode → back in without a prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 14:50:57 -04:00
10 changed files with 505 additions and 1 deletions
+8
View File
@@ -48,6 +48,7 @@ import { useCallMembersChange, useCallSession } from '../hooks/useCall';
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
import { useCallPolicyRevokedToast } from '../hooks/useCallPolicyRevokedToast';
import { useCallEndedToast } from '../hooks/useCallEndedToast';
import { useCallRejoin } from '../hooks/useCallRejoin';
import { useCallAnnouncements } from '../hooks/useCallAnnouncements';
import { useMutedTalkWarning } from '../hooks/useMutedTalkWarning';
import { callAnnouncementAtom } from '../state/callAnnouncement';
@@ -842,6 +843,12 @@ function PipMuteOverlay({ callEmbed }: { callEmbed: CallEmbed }) {
type CallEmbedProviderProps = {
children?: ReactNode;
};
/** [Gitea #118] Heartbeat + rejoin after a crash/restart; needs the embed container mounted. */
function CallRejoin({ callEmbed, joined }: { callEmbed?: CallEmbed; joined: boolean }) {
useCallRejoin(callEmbed, joined);
return null;
}
export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const callEmbed = useAtomValue(callEmbedAtom);
const callEmbedRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
@@ -1294,6 +1301,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
<CallAnnouncementRegion />
<CallEmbedRefContextProvider value={callEmbedRef}>
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
<CallRejoin callEmbed={callEmbed} joined={joined} />
{children}
</CallEmbedRefContextProvider>
<div
+1 -1
View File
@@ -159,7 +159,7 @@ export function About({ requestClose }: AboutProps) {
>
<SettingTile
title="Clear Cache & Reload"
description="Clear all your locally stored data and reload from server."
description="Clear all your locally stored data and reload from server. This also deletes this device's encryption keys — older encrypted messages stay unreadable here unless key backup is set up."
after={
<Button
onClick={() => clearCacheAndReload(mx)}
@@ -121,6 +121,7 @@ import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
import { DenoiseTester } from './DenoiseTester';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
import { isBindableCallKey } from '../../../utils/callKeybind';
import { StorageUsage } from './StorageUsage';
/**
* P5-47 — opt-in TDS window chrome toggle (desktop only). Renders nothing in the
@@ -1651,6 +1652,7 @@ function Calls() {
);
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
const [callRejoin, setCallRejoin] = useSetting(settingsAtom, 'callRejoinAfterRestart');
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
const [deafenHotkey, setDeafenHotkey] = useSetting(settingsAtom, 'deafenHotkey');
@@ -1946,6 +1948,21 @@ function Calls() {
direction="Column"
gap="400"
>
<SettingTile
title="After a Restart"
description="If Lotus crashes, reloads or restarts for an update while you are in a voice room, put you back in."
after={
<SettingsSelect<'ask' | 'auto' | 'off'>
value={callRejoin}
onChange={setCallRejoin}
options={[
{ value: 'ask', label: 'Ask' },
{ value: 'auto', label: 'Rejoin automatically' },
{ value: 'off', label: 'Do nothing' },
]}
/>
}
/>
<SettingTile
title="Push to Talk"
description="Mute your microphone by default. Hold the PTT key to speak."
@@ -2725,6 +2742,7 @@ export function General({ requestClose }: GeneralProps) {
<Editor />
<Messages />
<Privacy />
<StorageUsage />
<SettingsSyncSection />
<Calls />
<AppUpdates />
@@ -0,0 +1,103 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Badge, Box, Button, Text } from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { bytesToSize } from '../../../utils/common';
import {
percentOf,
readStorageUsage,
requestPersistentStorage,
StorageUsage as Usage,
storageUsageSupported,
} from '../../../utils/storageUsage';
/**
* [Gitea #120] One tile: how much this device stores for Lotus, whether the
* browser has promised to keep it, and a way to ask. Hidden entirely when
* `navigator.storage.estimate()` is unavailable rather than showing zeros.
*/
export function StorageUsage() {
const [usage, setUsage] = useState<Usage | undefined>();
const [requesting, setRequesting] = useState(false);
const refresh = useCallback(() => {
readStorageUsage()
.then(setUsage)
.catch(() => setUsage(undefined));
}, []);
useEffect(() => {
if (storageUsageSupported()) refresh();
}, [refresh]);
if (!usage) return null;
const handlePersist = async () => {
setRequesting(true);
await requestPersistentStorage();
setRequesting(false);
refresh();
};
const breakdown: string[] = [];
if (typeof usage.indexedDB === 'number')
breakdown.push(
`${bytesToSize(usage.indexedDB)} in IndexedDB (sync cache, encryption keys, search index)`,
);
if (typeof usage.caches === 'number')
breakdown.push(`${bytesToSize(usage.caches)} offline app files`);
return (
<Box direction="Column" gap="100">
<Text size="L400">Storage</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title={`${bytesToSize(usage.usage)} used on this device`}
description={
<Box direction="Column" gap="100">
<Text size="T200">
{percentOf(usage.usage, usage.quota)}% of the {bytesToSize(usage.quota)} the browser
allows this site.
{breakdown.length > 0 && ` ${breakdown.join(' · ')}.`}
</Text>
<Text size="T200">
Images and videos you have viewed are kept in the browser&apos;s own cache, which it
manages and empties itself; they are not counted here. Encryption keys are never
cleared from this page — losing them makes older encrypted messages unreadable on
this device. Cached search results can be cleared from Message Search.
</Text>
</Box>
}
after={
usage.persisted === undefined ? undefined : (
<Box direction="Column" alignItems="End" gap="100">
<Badge
variant={usage.persisted ? 'Success' : 'Warning'}
fill="Soft"
radii="300"
size="400"
>
<Text size="L400">{usage.persisted ? 'Protected' : 'May be evicted'}</Text>
</Badge>
{!usage.persisted && (
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
outlined
disabled={requesting}
onClick={handlePersist}
>
<Text size="B300">Keep my data</Text>
</Button>
)}
</Box>
)
}
/>
</SequenceCard>
</Box>
);
}
+172
View File
@@ -0,0 +1,172 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { MatrixClient, SyncState } from 'matrix-js-sdk';
import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc';
import { useAtomValue, useSetAtom } from 'jotai';
import { Icons } from 'folds';
import { ElementWidgetActions } from '../plugins/call/types';
import { CallEmbed, useCallControlState, useClientWidgetApiEvent } from '../plugins/call';
import { useMatrixClient } from './useMatrixClient';
import { useSyncState } from './useSyncState';
import { useCallStart } from './useCallEmbed';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { useCallPreferences } from '../state/hooks/callPreferences';
import { toastQueueAtom, dismissToastAtom } from '../state/toast';
import { mDirectAtom } from '../state/mDirectList';
import {
CALL_SESSION_HEARTBEAT_MS,
clearCallSession,
decideRejoin,
readCallSession,
writeCallSession,
} from '../utils/callRejoin';
const TOAST_ID = 'call-rejoin';
/** How long after the first sync to wait for the RTC session to report members. */
const MEMBERSHIP_WAIT_MS = 10_000;
const isSynced = (mx: MatrixClient): boolean => {
const state = mx.getSyncState();
return state === SyncState.Prepared || state === SyncState.Syncing;
};
/**
* [Gitea #118] Keep a heartbeat record of the voice room you are in, clear
* it on a deliberate hangup, and on the next start put you back (or ask)
* when the call is still going and the record is fresh.
*/
export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): void {
const mx = useMatrixClient();
const [mode] = useSetting(settingsAtom, 'callRejoinAfterRestart');
const { microphone, video } = useCallControlState(embed?.control);
const setToast = useSetAtom(toastQueueAtom);
const dismissToast = useSetAtom(dismissToastAtom);
const directs = useAtomValue(mDirectAtom);
const { microphone: prefMic, sound } = useCallPreferences();
const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
const startRoomCall = useCallStart(false);
const startDmCall = useCallStart(true);
// --- record while joined
useEffect(() => {
if (!embed || !joined) return undefined;
const deviceId = mx.getDeviceId() ?? '';
const write = () =>
writeCallSession({
roomId: embed.roomId,
deviceId,
joinedAt: embed.joinedAt ?? Date.now(),
lastSeen: Date.now(),
microphone,
video,
});
write();
const timer = window.setInterval(write, CALL_SESSION_HEARTBEAT_MS);
return () => window.clearInterval(timer);
}, [embed, joined, mx, microphone, video]);
// --- deliberate hangup clears it
useClientWidgetApiEvent(embed?.call, ElementWidgetActions.HangupCall, clearCallSession);
useClientWidgetApiEvent(embed?.call, ElementWidgetActions.Close, clearCallSession);
// --- startup: offer / perform a rejoin once
// The startup effect must run exactly once and outlive re-renders (its
// membership wait spans several seconds), so it reads these through a ref.
const latest = useRef({
mode,
prefMic,
sound,
cameraOnJoin,
directs,
startRoomCall,
startDmCall,
});
latest.current = { mode, prefMic, sound, cameraOnJoin, directs, startRoomCall, startDmCall };
const checkedRef = useRef(false);
const [synced, setSynced] = useState(() => isSynced(mx));
useSyncState(
mx,
useCallback(() => {
if (isSynced(mx)) setSynced(true);
}, [mx]),
);
useEffect(() => {
// Wait for the first sync so the room and its call memberships are known;
// deciding earlier would read an empty session and wrongly drop the record.
if (checkedRef.current || embed || !synced) return undefined;
checkedRef.current = true;
const record = readCallSession();
if (!record) return undefined;
const room = mx.getRoom(record.roomId);
if (!room) {
clearCallSession();
return undefined;
}
const session = mx.matrixRTC.getRoomSession(room);
const rejoin = () => {
const l = latest.current;
const pref = {
microphone: record.microphone ?? l.prefMic,
video: l.cameraOnJoin && record.video,
sound: l.sound,
};
dismissToast(TOAST_ID);
clearCallSession();
try {
(l.directs.has(room.roomId) ? l.startDmCall : l.startRoomCall)(room, pref);
} catch {
/* no embed container yet — the user can join from the room */
}
};
let done = false;
let timer: number | undefined;
const evaluate = (final: boolean) => {
if (done) return;
const memberships = session.memberships;
const decision = decideRejoin({
record,
mode: latest.current.mode,
now: Date.now(),
myDeviceId: mx.getDeviceId() ?? '',
roomJoined: room.getMyMembership() === 'join',
ownMemberDevices: memberships
.filter((m) => m.userId === mx.getUserId())
.map((m) => m.deviceId),
memberCount: memberships.length,
});
// The RTC session fills in a moment after sync; keep waiting while it is
// empty unless this is the final (timed-out) look.
if (decision.action === 'none' && memberships.length === 0 && !final) return;
done = true;
if (timer !== undefined) window.clearTimeout(timer);
session.off(MatrixRTCSessionEvent.MembershipsChanged, onChange);
if (decision.action === 'none') {
clearCallSession();
return;
}
if (decision.action === 'auto') {
rejoin();
return;
}
setToast({
id: TOAST_ID,
iconSrc: Icons.Phone,
displayName: 'Rejoin voice?',
body: `You were in the call in ${room.name ?? 'a room'} before the restart. Tap to rejoin.`,
roomName: room.name ?? '',
roomId: room.roomId,
sticky: true,
onClick: rejoin,
onDismiss: clearCallSession,
});
};
const onChange = () => evaluate(false);
session.on(MatrixRTCSessionEvent.MembershipsChanged, onChange);
timer = window.setTimeout(() => evaluate(true), MEMBERSHIP_WAIT_MS);
evaluate(false);
return () => {
session.off(MatrixRTCSessionEvent.MembershipsChanged, onChange);
if (timer !== undefined) window.clearTimeout(timer);
};
}, [embed, synced, mx, setToast, dismissToast]);
}
+2
View File
@@ -6,6 +6,7 @@ import { clearRecentGifs } from './recentGifs';
import { clearRecentStickers } from './recentStickers';
import { clearNavToActivePathStore } from './navToActivePath';
import { DRAFT_MSG_KEY_PREFIX } from '../utils/draft';
import { clearCallSession } from '../utils/callRejoin';
/**
* [Gitea #41] Wipe every persisted composer draft (`draft-msg-<roomId>`). Drafts
@@ -92,6 +93,7 @@ export const clearPlaintextCaches = (userId?: string): void => {
clearRecentGifs();
clearRecentStickers();
clearMsgDrafts();
clearCallSession();
clearStatusMessage();
if (userId) clearNavToActivePathStore(userId);
};
+3
View File
@@ -271,6 +271,8 @@ export interface Settings {
// [Gitea #109] Drop EXIF/XMP/IPTC (GPS, camera, timestamp) from JPEG/PNG/WebP
// uploads without re-encoding. Default on.
stripImageMetadata: boolean;
// [Gitea #118] After a crash/update/reload while in a voice room: ask, rejoin, or nothing.
callRejoinAfterRestart: 'ask' | 'auto' | 'off';
// [Gitea #104] Mirror user preferences to `io.lotus.settings` account data
// so other devices pick them up. Device-local itself (utils/settingsSync).
@@ -394,6 +396,7 @@ const defaultSettings: Settings = {
stripTrackingParams: true,
stripImageMetadata: true,
callRejoinAfterRestart: 'ask',
settingsSync: true,
+56
View File
@@ -0,0 +1,56 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { CALL_REJOIN_MAX_AGE_MS, decideRejoin, RejoinContext } from './callRejoin';
const base: RejoinContext = {
record: {
roomId: '!r',
deviceId: 'DEV',
joinedAt: 900_000,
lastSeen: 1_000_000,
microphone: true,
video: false,
},
mode: 'ask',
now: 1_060_000,
myDeviceId: 'DEV',
roomJoined: true,
ownMemberDevices: [],
memberCount: 2,
};
describe('decideRejoin', () => {
it('asks by default when the call is still going', () => {
assert.deepEqual(decideRejoin(base), { action: 'ask', roomId: '!r' });
assert.deepEqual(decideRejoin({ ...base, mode: 'auto' }), { action: 'auto', roomId: '!r' });
});
it('does nothing when off, stale, another device, or no record', () => {
assert.deepEqual(decideRejoin({ ...base, mode: 'off' }), { action: 'none' });
assert.deepEqual(decideRejoin({ ...base, record: undefined }), { action: 'none' });
assert.deepEqual(decideRejoin({ ...base, now: 1_000_000 + CALL_REJOIN_MAX_AGE_MS + 1 }), {
action: 'none',
});
assert.deepEqual(decideRejoin({ ...base, myDeviceId: 'OTHER' }), { action: 'none' });
assert.deepEqual(decideRejoin({ ...base, roomJoined: false }), { action: 'none' });
});
it('skips when we already rejoined from another device', () => {
assert.deepEqual(decideRejoin({ ...base, ownMemberDevices: ['PHONE'], memberCount: 2 }), {
action: 'none',
});
});
it('skips when only our own stale membership is left', () => {
assert.deepEqual(decideRejoin({ ...base, ownMemberDevices: ['DEV'], memberCount: 1 }), {
action: 'none',
});
assert.deepEqual(decideRejoin({ ...base, ownMemberDevices: [], memberCount: 0 }), {
action: 'none',
});
assert.deepEqual(decideRejoin({ ...base, ownMemberDevices: ['DEV'], memberCount: 2 }), {
action: 'ask',
roomId: '!r',
});
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* [Gitea #118] Remember the voice room you were in so a crash, update or
* reload can put you back. Device-local (localStorage), never synced;
* cleared on a deliberate hangup and on logout.
*/
export const CALL_SESSION_KEY = 'lotus-call-session';
/** A record older than this (by last heartbeat) is stale — the call is over. */
export const CALL_REJOIN_MAX_AGE_MS = 10 * 60_000;
export const CALL_SESSION_HEARTBEAT_MS = 30_000;
export type CallSessionRecord = {
roomId: string;
deviceId: string;
joinedAt: number;
/** Refreshed while joined so a crash leaves a recent timestamp behind. */
lastSeen: number;
microphone: boolean;
video: boolean;
};
export type CallRejoinMode = 'ask' | 'auto' | 'off';
export const readCallSession = (): CallSessionRecord | undefined => {
try {
const raw = localStorage.getItem(CALL_SESSION_KEY);
if (!raw) return undefined;
const r = JSON.parse(raw) as Partial<CallSessionRecord>;
if (typeof r.roomId !== 'string' || typeof r.deviceId !== 'string') return undefined;
return {
roomId: r.roomId,
deviceId: r.deviceId,
joinedAt: Number(r.joinedAt) || 0,
lastSeen: Number(r.lastSeen) || 0,
microphone: r.microphone !== false,
video: r.video === true,
};
} catch {
return undefined;
}
};
export const writeCallSession = (record: CallSessionRecord): void => {
try {
localStorage.setItem(CALL_SESSION_KEY, JSON.stringify(record));
} catch {
/* quota / private mode */
}
};
export const clearCallSession = (): void => {
try {
localStorage.removeItem(CALL_SESSION_KEY);
} catch {
/* ignore */
}
};
export type RejoinContext = {
record: CallSessionRecord | undefined;
mode: CallRejoinMode;
now: number;
myDeviceId: string;
/** Whether the room still exists and we are joined to it. */
roomJoined: boolean;
/** Device ids of our own live call memberships in that room (other tabs/devices). */
ownMemberDevices: string[];
/** Number of call memberships in the room, ours included. */
memberCount: number;
};
export type RejoinDecision = { action: 'none' } | { action: 'ask' | 'auto'; roomId: string };
/** Pure: whether startup should offer (or perform) a rejoin. */
export function decideRejoin(ctx: RejoinContext): RejoinDecision {
const { record, mode, now, myDeviceId } = ctx;
if (!record || mode === 'off') return { action: 'none' };
if (record.deviceId !== myDeviceId) return { action: 'none' };
if (now - record.lastSeen > CALL_REJOIN_MAX_AGE_MS) return { action: 'none' };
if (!ctx.roomJoined) return { action: 'none' };
// Already back in from another device/tab — don't double-join.
if (ctx.ownMemberDevices.some((d) => d !== myDeviceId)) return { action: 'none' };
// Nobody there any more (our own stale membership doesn't count).
const others = ctx.memberCount - ctx.ownMemberDevices.length;
if (others <= 0) return { action: 'none' };
return { action: mode, roomId: record.roomId };
}
+55
View File
@@ -0,0 +1,55 @@
/**
* [Gitea #120] What this device stores for Lotus, from the APIs that are cheap
* to ask. `estimate()` is the total the browser accounts for; Chromium adds a
* per-bucket breakdown (`usageDetails`), Firefox/Safari don't. Media files are
* not in here at all: they live in the browser's own HTTP cache, which manages
* and evicts them itself.
*/
export type StorageUsage = {
usage: number;
quota: number;
/** Chromium only: IndexedDB (sync store, crypto store, search index) bytes. */
indexedDB?: number;
/** Chromium only: Cache Storage (offline app shell) bytes. */
caches?: number;
/** Whether the browser granted persistent storage (won't evict under pressure). */
persisted: boolean | undefined;
};
type EstimateWithDetails = StorageEstimate & {
usageDetails?: { indexedDB?: number; caches?: number; serviceWorkerRegistrations?: number };
};
export const storageUsageSupported = (): boolean =>
typeof navigator !== 'undefined' && !!navigator.storage && !!navigator.storage.estimate;
export async function readStorageUsage(): Promise<StorageUsage | undefined> {
if (!storageUsageSupported()) return undefined;
const est = (await navigator.storage.estimate()) as EstimateWithDetails;
let persisted: boolean | undefined;
try {
persisted = navigator.storage.persisted ? await navigator.storage.persisted() : undefined;
} catch {
persisted = undefined;
}
return {
usage: est.usage ?? 0,
quota: est.quota ?? 0,
indexedDB: est.usageDetails?.indexedDB,
caches: est.usageDetails?.caches,
persisted,
};
}
/** Ask the browser to keep this origin's storage; returns the new state. */
export async function requestPersistentStorage(): Promise<boolean | undefined> {
if (!navigator.storage?.persist) return undefined;
try {
return await navigator.storage.persist();
} catch {
return undefined;
}
}
export const percentOf = (usage: number, quota: number): number =>
quota > 0 ? Math.min(100, Math.round((usage / quota) * 100)) : 0;