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;