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
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:
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user