From c6c2e88df5aa7f56319d4fa138a18a8eb85a985e Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 20 Sep 2026 14:53:42 -0400 Subject: [PATCH] feat(settings): storage usage tile with persistence status (#120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/features/settings/about/About.tsx | 2 +- src/app/features/settings/general/General.tsx | 2 + .../settings/general/StorageUsage.tsx | 103 ++++++++++++++++++ src/app/utils/storageUsage.ts | 55 ++++++++++ 4 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 src/app/features/settings/general/StorageUsage.tsx create mode 100644 src/app/utils/storageUsage.ts diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index 3c5fdf357..0ac7b51e5 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -159,7 +159,7 @@ export function About({ requestClose }: AboutProps) { > clearCacheAndReload(mx)} diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index d42448adb..3f8faa82f 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -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) { + diff --git a/src/app/features/settings/general/StorageUsage.tsx b/src/app/features/settings/general/StorageUsage.tsx new file mode 100644 index 000000000..6e088b93f --- /dev/null +++ b/src/app/features/settings/general/StorageUsage.tsx @@ -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(); + 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 ( + + Storage + + + + {percentOf(usage.usage, usage.quota)}% of the {bytesToSize(usage.quota)} the browser + allows this site. + {breakdown.length > 0 && ` ${breakdown.join(' · ')}.`} + + + Images and videos you have viewed are kept in the browser'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. + + + } + after={ + usage.persisted === undefined ? undefined : ( + + + {usage.persisted ? 'Protected' : 'May be evicted'} + + {!usage.persisted && ( + + )} + + ) + } + /> + + + ); +} diff --git a/src/app/utils/storageUsage.ts b/src/app/utils/storageUsage.ts new file mode 100644 index 000000000..8c32a20fe --- /dev/null +++ b/src/app/utils/storageUsage.ts @@ -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 { + 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 { + 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;