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
This commit is contained in:
2026-09-20 14:53:42 -04:00
co-authored by Claude Opus 5
parent dc0d524989
commit c6c2e88df5
4 changed files with 161 additions and 1 deletions
+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
@@ -2741,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>
);
}
+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;