diff --git a/.changeset/developer-tools.md b/.changeset/developer-tools.md new file mode 100644 index 000000000..b2bcf5229 --- /dev/null +++ b/.changeset/developer-tools.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add build-time experiment flag injection, typed deterministic bucketing helpers, and a DevTools panel to force-rotate Megolm encryption sessions per room. diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 9b4c9acbb..d9a365eeb 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -34,6 +34,36 @@ runs: env: INPUTS_INSTALL_COMMAND: ${{ inputs.install-command }} + - name: Inject runtime config overrides + if: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON != '' }} + shell: bash + working-directory: ${{ github.workspace }} + run: node scripts/inject-client-config.js + env: + CLIENT_CONFIG_OVERRIDES_JSON: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON }} + CLIENT_CONFIG_OVERRIDES_STRICT: ${{ env.CLIENT_CONFIG_OVERRIDES_STRICT }} + + - name: Display injected config + if: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON != '' }} + shell: bash + working-directory: ${{ github.workspace }} + run: | + summary_file="${GITHUB_STEP_SUMMARY:-}" + echo "::group::Injected Client Config" + experiments_json="$(jq -c '.experiments // "No experiments configured"' config.json 2>/dev/null || echo 'config.json not readable')" + echo "$experiments_json" + echo "::endgroup::" + + if [[ -n "$summary_file" ]]; then + { + echo "### Injected client config" + echo + echo "\`\`\`json" + echo "$experiments_json" + echo "\`\`\`" + } >> "$summary_file" + fi + - name: Build app if: ${{ inputs.build == 'true' }} shell: bash diff --git a/.github/workflows/cloudflare-web-deploy.yml b/.github/workflows/cloudflare-web-deploy.yml index d3d2c4461..e32dbf68e 100644 --- a/.github/workflows/cloudflare-web-deploy.yml +++ b/.github/workflows/cloudflare-web-deploy.yml @@ -40,6 +40,10 @@ jobs: plan: if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest + environment: preview + env: + CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }} + CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }} permissions: contents: read pull-requests: write @@ -73,6 +77,10 @@ jobs: apply: if: (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + environment: production + env: + CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }} + CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }} permissions: contents: read defaults: diff --git a/.github/workflows/cloudflare-web-preview.yml b/.github/workflows/cloudflare-web-preview.yml index 8b93a4bb9..82046559c 100644 --- a/.github/workflows/cloudflare-web-preview.yml +++ b/.github/workflows/cloudflare-web-preview.yml @@ -32,9 +32,13 @@ jobs: deploy: if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push' runs-on: ubuntu-latest + environment: preview permissions: contents: read pull-requests: write + env: + CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }} + CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }} steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/knip.json b/knip.json index 6cc8c8581..83f45fc19 100644 --- a/knip.json +++ b/knip.json @@ -1,6 +1,6 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", - "entry": ["src/sw.ts", "scripts/normalize-imports.js"], + "entry": ["src/sw.ts", "scripts/normalize-imports.js", "scripts/inject-client-config.js"], "ignore": ["oxlint.config.ts", "oxfmt.config.ts"], "ignoreExportsUsedInFile": { "interface": true, diff --git a/scripts/inject-client-config.js b/scripts/inject-client-config.js new file mode 100644 index 000000000..0b5fcd3ad --- /dev/null +++ b/scripts/inject-client-config.js @@ -0,0 +1,75 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import process from 'node:process'; +import { PrefixedLogger } from './utils/console-style.js'; + +const CONFIG_PATH = 'config.json'; +const OVERRIDES_ENV = 'CLIENT_CONFIG_OVERRIDES_JSON'; +const STRICT_ENV = 'CLIENT_CONFIG_OVERRIDES_STRICT'; +const logger = new PrefixedLogger('[config-inject]'); + +const formatError = (error) => { + if (error instanceof Error) return error.stack ?? error.message; + return String(error); +}; + +const isPlainObject = (value) => + typeof value === 'object' && value !== null && !Array.isArray(value); + +// Keys that could trigger prototype pollution via bracket assignment. +const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +const deepMerge = (target, source) => { + if (!isPlainObject(target) || !isPlainObject(source)) return source; + + const merged = { ...target }; + Object.entries(source).forEach(([key, value]) => { + if (UNSAFE_KEYS.has(key)) return; + const targetValue = merged[key]; + merged[key] = + isPlainObject(targetValue) && isPlainObject(value) ? deepMerge(targetValue, value) : value; + }); + return merged; +}; + +const failOnError = process.env[STRICT_ENV] === 'true'; +const overridesRaw = process.env[OVERRIDES_ENV]; + +if (!overridesRaw) { + logger.info(`No ${OVERRIDES_ENV} provided; leaving ${CONFIG_PATH} unchanged.`); + process.exit(0); +} + +let fileConfig; +let overrides; + +try { + const file = await readFile(CONFIG_PATH, 'utf8'); + fileConfig = JSON.parse(file); +} catch (error) { + logger.error(`Failed reading ${CONFIG_PATH}: ${formatError(error)}`); + process.exit(1); +} + +try { + overrides = JSON.parse(overridesRaw); + if (!isPlainObject(overrides)) { + throw new Error(`${OVERRIDES_ENV} must be a JSON object.`); + } +} catch (error) { + const message = `[config-inject] Invalid ${OVERRIDES_ENV}; ${ + failOnError ? 'failing build' : 'skipping overrides' + }.`; + if (failOnError) { + logger.error(`${message} ${formatError(error)}`); + process.exit(1); + } + logger.info(`[warning] ${message} ${formatError(error)}`); + process.exit(0); +} + +const mergedConfig = deepMerge(fileConfig, overrides); + +await writeFile(CONFIG_PATH, `${JSON.stringify(mergedConfig, null, 2)}\n`, 'utf8'); +logger.info( + `Applied overrides to ${CONFIG_PATH}. Top-level keys: ${Object.keys(overrides).join(', ')}` +); diff --git a/src/app/components/room-avatar/AvatarImage.tsx b/src/app/components/room-avatar/AvatarImage.tsx index f322bce0e..dd8079055 100644 --- a/src/app/components/room-avatar/AvatarImage.tsx +++ b/src/app/components/room-avatar/AvatarImage.tsx @@ -6,6 +6,17 @@ import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; import * as css from './RoomAvatar.css'; +// Module-level cache: maps a Matrix media URL → processed blob URL so that +// SVG processing only runs once per unique image, even as virtual-list items +// unmount and remount. MXC URLs are content-addressed and never change, so +// the mapping is stable for the lifetime of the page. +const svgBlobCache = new Map(); + +/** Number of SVG blob URLs currently held in the module-level cache. */ +export function getSvgCacheSize(): number { + return svgBlobCache.size; +} + type AvatarImageProps = { src: string; alt?: string; @@ -23,9 +34,15 @@ export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProp useEffect(() => { let isMounted = true; - let objectUrl: string | null = null; const processImage = async () => { + // Return the cached blob URL immediately — no network round-trip needed. + const cachedBlobUrl = svgBlobCache.get(src); + if (cachedBlobUrl) { + setProcessedSrc(cachedBlobUrl); + return; + } + try { const res = await fetch(src, { mode: 'cors' }); const contentType = res.headers.get('content-type'); @@ -46,8 +63,10 @@ export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProp const newSvgString = serializer.serializeToString(doc); const blob = new Blob([newSvgString], { type: 'image/svg+xml' }); - objectUrl = URL.createObjectURL(blob); - if (isMounted) setProcessedSrc(objectUrl); + const blobUrl = URL.createObjectURL(blob); + // Store in module cache so future remounts skip processing. + svgBlobCache.set(src, blobUrl); + if (isMounted) setProcessedSrc(blobUrl); } else if (isMounted) setProcessedSrc(src); } catch { if (isMounted) setProcessedSrc(src); @@ -58,9 +77,8 @@ export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProp return () => { isMounted = false; - if (objectUrl) { - URL.revokeObjectURL(objectUrl); - } + // Blob URLs are retained in svgBlobCache — do not revoke them here so + // that subsequent remounts can use the cached result without re-fetching. }; }, [src]); diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index 100119726..b9b996c6f 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -1,5 +1,6 @@ -import { useCallback, useState } from 'react'; -import { Box, Text, Scroll, Switch, Button } from 'folds'; +import { useCallback, useEffect, useState } from 'react'; +import { Box, Text, Scroll, Switch, Button, Spinner, color } from 'folds'; +import { KnownMembership } from '$types/matrix-sdk'; import { PageContent } from '$components/page'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; @@ -8,14 +9,25 @@ import { settingsAtom } from '$state/settings'; import { useMatrixClient } from '$hooks/useMatrixClient'; import type { AccountDataSubmitCallback } from '$components/AccountDataEditor'; import { AccountDataEditor } from '$components/AccountDataEditor'; +import { + clearMediaCache, + clearInMemoryBlobCache, + getBlobCacheStats, + getBlobCacheStatsAsync, +} from '$hooks/useBlobCache'; import { copyToClipboard } from '$utils/dom'; import { SequenceCardStyle } from '$features/settings/styles.css'; +import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; +import { getSvgCacheSize } from '$components/room-avatar/AvatarImage'; import { SettingsSectionPage } from '../SettingsSectionPage'; import { AccountData } from './AccountData'; import { SyncDiagnostics } from './SyncDiagnostics'; +import { ExperimentsPanel } from './ExperimentsPanel'; import { DebugLogViewer } from './DebugLogViewer'; import { SentrySettings } from './SentrySettings'; +const JOIN_MEMBERSHIP: string = KnownMembership.Join; + type DeveloperToolsProps = { requestBack?: () => void; requestClose: () => void; @@ -25,6 +37,92 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools'); const [expand, setExpend] = useState(false); const [accountDataType, setAccountDataType] = useState(); + const [cacheStats, setCacheStats] = useState(() => getBlobCacheStats()); + const [svgCacheSize, setSvgCacheSize] = useState(0); + const [swCacheStats, setSwCacheStats] = useState({ count: 0, sizeMB: 0 }); + + useEffect(() => { + // Async-load persistent cache metadata (requires Cache API) and SVG cache size + getBlobCacheStatsAsync() + .then(setCacheStats) + .catch(() => undefined); + setSvgCacheSize(getSvgCacheSize()); + // Read SW media cache from page context (same origin, shared with the SW) + caches + .open('sable-media-sw-v1') + .then(async (cache) => { + const requests = await cache.keys(); + const responses = await Promise.all(requests.map((req) => cache.match(req))); + const totalBytes = responses.reduce((sum, resp) => { + if (!resp) return sum; + const cl = resp.headers.get('content-length'); + return cl ? sum + parseInt(cl, 10) : sum; + }, 0); + setSwCacheStats({ count: requests.length, sizeMB: totalBytes / (1024 * 1024) }); + }) + .catch(() => undefined); + }, []); + + const [clearCacheState, clearMediaCacheAction] = useAsyncCallback( + useCallback(async () => { + await clearMediaCache(); + setCacheStats(getBlobCacheStats()); + }, []) + ); + + const clearInMemoryAction = useCallback(() => { + clearInMemoryBlobCache(); + setCacheStats(getBlobCacheStats()); + }, []); + + const [clearSwCacheState, clearSwCacheAction] = useAsyncCallback( + useCallback(async () => { + await caches.delete('sable-media-sw-v1'); + setSwCacheStats({ count: 0, sizeMB: 0 }); + }, []) + ); + + const [rotateState, rotateAllSessions] = useAsyncCallback< + { rotated: number; total: number }, + Error, + [] + >( + useCallback(async () => { + if ( + !window.confirm( + 'This will discard all current Megolm encryption sessions and start new ones. Continue?' + ) + ) { + throw new Error('Cancelled'); + } + + const crypto = mx.getCrypto(); + if (!crypto) throw new Error('Crypto module not available'); + + const encryptedRooms = mx + .getRooms() + .filter( + (room) => room.getMyMembership() === JOIN_MEMBERSHIP && mx.isRoomEncrypted(room.roomId) + ); + + const results = await Promise.allSettled( + encryptedRooms.map((room) => crypto.forceDiscardSession(room.roomId)) + ); + const rotated = results.filter((r) => r.status === 'fulfilled').length; + + // Proactively start session creation + key sharing with all devices + // (including bridge bots). fire-and-forget per room, but surface failures. + encryptedRooms.forEach((room) => { + Promise.resolve() + .then(() => crypto.prepareToEncrypt(room)) + .catch((error) => { + console.error('Failed to prepare room encryption', room.roomId, error); + }); + }); + + return { rotated, total: encryptedRooms.length }; + }, [mx]) + ); const submitAccountData: AccountDataSubmitCallback = useCallback( async (type, content) => { @@ -34,6 +132,20 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp [mx] ); + const deleteAccountData = useCallback( + (type: string) => { + if ( + !window.confirm( + `Delete account data '${type}'?\n\nNote: Matrix does not support deleting account data events. This will overwrite the content with an empty object {}. The event type key will remain.` + ) + ) + return; + // as never: developer tools delete arbitrary account data types beyond the typed enum. + mx.setAccountData(type as never, {} as never).then(() => setAccountDataType(undefined)); + }, + [mx] + ); + if (accountDataType !== undefined) { return ( deleteAccountData(accountDataType) : undefined} requestClose={() => setAccountDataType(undefined)} /> ); @@ -110,6 +223,171 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp )} {developerTools && } + {developerTools && } + {developerTools && ( + + Encryption + + + ) + } + > + + {rotateState.status === AsyncStatus.Loading ? 'Rotating…' : 'Rotate'} + + + } + > + {rotateState.status === AsyncStatus.Success && ( + + Sessions discarded for {rotateState.data.rotated} of{' '} + {rotateState.data.total} encrypted rooms. Key sharing is starting in the + background — send a message in an affected room to confirm delivery to + bridges. + + )} + {rotateState.status === AsyncStatus.Error && ( + + {rotateState.error.message} + + )} + + + + )} + {developerTools && ( + + Caches + + + + Clear + + } + /> + + ) + } + > + + {clearSwCacheState.status === AsyncStatus.Loading + ? 'Clearing…' + : 'Clear'} + + + } + > + {clearSwCacheState.status === AsyncStatus.Success && ( + + Service worker cache cleared. + + )} + {clearSwCacheState.status === AsyncStatus.Error && ( + + {clearSwCacheState.error.message} + + )} + + + ) + } + > + + {clearCacheState.status === AsyncStatus.Loading ? 'Clearing…' : 'Clear'} + + + } + > + {clearCacheState.status === AsyncStatus.Success && ( + + Persistent cache cleared. + + )} + {clearCacheState.status === AsyncStatus.Error && ( + + {clearCacheState.error.message} + + )} + + + + + )} {developerTools && ( ): string[] { + const fromConfig = Object.keys(configExperiments ?? {}); + const fromBuild = Object.keys(INJECTED_EXPERIMENT_FLAGS); + const fromStorage = Object.keys(localStorage) + .filter((k) => k.startsWith(EXPERIMENT_OVERRIDE_PREFIX)) + .map((k) => k.slice(EXPERIMENT_OVERRIDE_PREFIX.length)); + + return Array.from(new Set([...fromConfig, ...fromBuild, ...fromStorage])).toSorted(); +} + +function getEffectiveValue( + key: string, + configExperiments?: Record +): { value: boolean; source: 'override' | 'config' | 'build' | 'default' } { + const lsValue = localStorage.getItem(`${EXPERIMENT_OVERRIDE_PREFIX}${key}`); + if (lsValue !== null) return { value: lsValue === 'true', source: 'override' }; + if (configExperiments && key in configExperiments) + return { value: configExperiments[key]?.enabled ?? false, source: 'config' }; + if (key in INJECTED_EXPERIMENT_FLAGS) + return { value: INJECTED_EXPERIMENT_FLAGS[key] ?? false, source: 'build' }; + return { value: false, source: 'default' }; +} + +export function ExperimentsPanel() { + const config = useClientConfig(); + const [, forceUpdate] = useState(0); + const refresh = useCallback(() => forceUpdate((n) => n + 1), []); + + const keys = getActiveExperimentKeys(config.experiments); + + if (keys.length === 0) { + return ( + + Experiments + + No experiment flags are defined. Set VITE_FEATURE_* env vars at build time or + add an experiments field to config.json. + + + ); + } + + return ( + + Experiments + + Override experiment flags for this session. Changes are stored in localStorage and take + effect immediately on next render. + + + {keys.map((key) => { + const { value, source } = getEffectiveValue(key, config.experiments); + const hasOverride = source === 'override'; + return ( + + {hasOverride && ( + + )} + { + setExperimentOverride(key, v); + refresh(); + }} + /> + + } + /> + ); + })} + + + ); +} diff --git a/src/app/hooks/useBlobCache.ts b/src/app/hooks/useBlobCache.ts index 96ac18f74..381e5537c 100644 --- a/src/app/hooks/useBlobCache.ts +++ b/src/app/hooks/useBlobCache.ts @@ -1,12 +1,207 @@ import { useState, useEffect } from 'react'; +const CACHE_NAME = 'sable-media-v1'; +const MAX_CACHE_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +const MAX_CACHE_SIZE_MB = 500; // Configurable limit + const imageBlobCache = new Map(); const inflightRequests = new Map>(); -export function getBlobCacheStats(): { cacheSize: number; inflightCount: number } { - return { cacheSize: imageBlobCache.size, inflightCount: inflightRequests.size }; +type CacheMetadata = { + url: string; + size: number; + cachedAt: number; +}; + +let cacheMetadata: CacheMetadata[] = []; +let metadataLoaded = false; + +/** + * Open the Cache API storage for media blobs. + * Persistent across page reloads and shared between tabs. + */ +async function openMediaCache(): Promise { + return await caches.open(CACHE_NAME); +} + +/** + * Load cache metadata from Cache API headers. + * This tracks size and age for eviction logic. + */ +async function loadCacheMetadata(): Promise { + if (metadataLoaded) return; + + try { + const cache = await openMediaCache(); + const requests = await cache.keys(); + + const metadataPromises = requests.map(async (request) => { + const response = await cache.match(request); + if (!response) return null; + + const cachedAt = parseInt(response.headers.get('X-Cached-At') ?? '0', 10); + const size = parseInt(response.headers.get('X-Size') ?? '0', 10); + + return { + url: request.url, + size, + cachedAt, + }; + }); + + const metadata = (await Promise.all(metadataPromises)).filter( + (m): m is CacheMetadata => m !== null + ); + + cacheMetadata = metadata.toSorted((a, b) => a.cachedAt - b.cachedAt); // LRU order + metadataLoaded = true; + } catch { + // Cache API unavailable — metadata stays empty + } +} + +/** + * Store media blob in Cache API with metadata headers. + * Runs cache size check and eviction if needed. + */ +async function cacheMedia(url: string, blob: Blob): Promise { + try { + await loadCacheMetadata(); + + const cache = await openMediaCache(); + const response = new Response(blob, { + headers: { + 'Content-Type': blob.type, + 'X-Cached-At': Date.now().toString(), + 'X-Size': blob.size.toString(), + }, + }); + + await cache.put(url, response); + + // Update metadata + cacheMetadata.push({ + url, + size: blob.size, + cachedAt: Date.now(), + }); + + // Check size and evict if needed + await evictIfNeeded(); + } catch { + // Cache write failed — continue without persistent cache + } +} + +/** + * Retrieve media blob from Cache API. + * Returns undefined if not cached or expired. + */ +async function getCachedMedia(url: string): Promise { + try { + const cache = await openMediaCache(); + const response = await cache.match(url); + if (!response) return undefined; + + // Check expiry + const cachedAt = parseInt(response.headers.get('X-Cached-At') ?? '0', 10); + if (Date.now() - cachedAt > MAX_CACHE_AGE_MS) { + cache.delete(url); // Expired + cacheMetadata = cacheMetadata.filter((m) => m.url !== url); + return undefined; + } + + return await response.blob(); + } catch { + return undefined; + } +} + +/** + * Evict oldest entries if cache exceeds size limit. + * Uses LRU (Least Recently Used) eviction strategy. + */ +async function evictIfNeeded(): Promise { + const totalSizeBytes = cacheMetadata.reduce((sum, m) => sum + m.size, 0); + const totalSizeMB = totalSizeBytes / (1024 * 1024); + + if (totalSizeMB <= MAX_CACHE_SIZE_MB) return; + + try { + const cache = await openMediaCache(); + const toEvict = Math.ceil(cacheMetadata.length * 0.1); // Evict 10% of entries + + const toDelete: CacheMetadata[] = []; + for (let i = 0; i < toEvict && cacheMetadata.length > 0; i++) { + const oldest = cacheMetadata.shift(); + if (oldest) { + toDelete.push(oldest); + } + } + + // Delete all in parallel + await Promise.all(toDelete.map((m) => cache.delete(m.url))); + } catch { + // Eviction failed — continue anyway + } +} + +/** + * Clear the in-memory blob cache and any in-flight fetch requests. + * Does not affect persistent Cache API storage. + */ +export function clearInMemoryBlobCache(): void { + imageBlobCache.clear(); + inflightRequests.clear(); +} + +/** + * Clear all media from persistent cache. + * Also clears the in-memory cache. + * Useful for "Clear Cache" settings option. + */ +export async function clearMediaCache(): Promise { + try { + await caches.delete(CACHE_NAME); + cacheMetadata = []; + metadataLoaded = false; + clearInMemoryBlobCache(); + } catch { + // Cache clear failed — silent ignore + } +} + +/** + * Get cache statistics for metrics/debugging. + */ +export function getBlobCacheStats(): { + cacheSize: number; + inflightCount: number; + persistentCacheSizeMB: number; + persistentCacheCount: number; +} { + const totalSizeBytes = cacheMetadata.reduce((sum, m) => sum + m.size, 0); + return { + cacheSize: imageBlobCache.size, + inflightCount: inflightRequests.size, + persistentCacheSizeMB: totalSizeBytes / (1024 * 1024), + persistentCacheCount: cacheMetadata.length, + }; } +/** + * Async version of getBlobCacheStats that first ensures cache metadata is + * loaded from the Cache API. Use this in settings/diagnostics panels. + */ +export async function getBlobCacheStatsAsync(): Promise> { + await loadCacheMetadata(); + return getBlobCacheStats(); +} + +/** + * Hook to fetch and cache media blobs with persistent storage. + * Checks in-memory cache first, then Cache API, then fetches from network. + */ export function useBlobCache(url?: string): string | undefined { const [cacheState, setCacheState] = useState<{ sourceUrl?: string; blobUrl?: string }>({ sourceUrl: url, @@ -21,23 +216,38 @@ export function useBlobCache(url?: string): string | undefined { } useEffect(() => { - if (!url || imageBlobCache.has(url)) return undefined; + if (!url) return undefined; + + // Check memory cache first (instant) + if (imageBlobCache.has(url)) { + return undefined; + } let isMounted = true; const fetchBlob = async () => { + // Check if another component is already fetching this URL if (inflightRequests.has(url)) { try { const existingBlobUrl = await inflightRequests.get(url); if (isMounted) setCacheState({ sourceUrl: url, blobUrl: existingBlobUrl }); } catch { - // Inflight request failed, silently ignore (consistent with fetchBlob behavior) + // Inflight request failed, silently ignore } return; } const requestPromise = (async () => { try { + // Check persistent cache (fast, survives reloads) + const cachedBlob = await getCachedMedia(url); + if (cachedBlob) { + const objectUrl = URL.createObjectURL(cachedBlob); + imageBlobCache.set(url, objectUrl); + return objectUrl; + } + + // Fetch from network (slow) const res = await fetch(url, { mode: 'cors' }); if (!res.ok) { throw new Error(`Failed to fetch blob: ${res.status} ${res.statusText}`); @@ -45,7 +255,10 @@ export function useBlobCache(url?: string): string | undefined { const blob = await res.blob(); const objectUrl = URL.createObjectURL(blob); + // Store in both caches imageBlobCache.set(url, objectUrl); + cacheMedia(url, blob); // Non-blocking persistent storage + return objectUrl; } catch (e) { inflightRequests.delete(url); diff --git a/src/app/hooks/useClientConfig.test.ts b/src/app/hooks/useClientConfig.test.ts new file mode 100644 index 000000000..5071c5f7c --- /dev/null +++ b/src/app/hooks/useClientConfig.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; +import { selectExperimentVariant, type ExperimentConfig } from './useClientConfig'; + +const baseExperiment: ExperimentConfig = { + enabled: true, + rolloutPercentage: 100, + controlVariant: 'control', + variants: ['alpha', 'beta'], +}; + +describe('selectExperimentVariant', () => { + it('returns control when experiment is disabled', () => { + const result = selectExperimentVariant( + 'threadUI', + { ...baseExperiment, enabled: false }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(false); + expect(result.variant).toBe('control'); + }); + + it('returns control when subject id is missing', () => { + const result = selectExperimentVariant('threadUI', baseExperiment, undefined); + + expect(result.inExperiment).toBe(false); + expect(result.variant).toBe('control'); + }); + + it('returns control when rollout is 0', () => { + const result = selectExperimentVariant( + 'threadUI', + { ...baseExperiment, rolloutPercentage: 0 }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(false); + expect(result.variant).toBe('control'); + expect(result.rolloutPercentage).toBe(0); + }); + + it('normalizes rollout less than 0 to 0', () => { + const result = selectExperimentVariant( + 'threadUI', + { ...baseExperiment, rolloutPercentage: -10 }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(false); + expect(result.variant).toBe('control'); + expect(result.rolloutPercentage).toBe(0); + }); + + it('normalizes rollout greater than 100 to 100', () => { + const result = selectExperimentVariant( + 'threadUI', + { ...baseExperiment, rolloutPercentage: 999 }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(true); + expect(result.rolloutPercentage).toBe(100); + expect(['alpha', 'beta']).toContain(result.variant); + }); + + it('falls back to control when variants are missing after filtering', () => { + const result = selectExperimentVariant( + 'threadUI', + { + ...baseExperiment, + variants: ['', 'control'], + }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(false); + expect(result.variant).toBe('control'); + }); + + it('is deterministic for the same key and subject', () => { + const first = selectExperimentVariant('threadUI', baseExperiment, '@alice:example.org'); + const second = selectExperimentVariant('threadUI', baseExperiment, '@alice:example.org'); + + expect(second).toEqual(first); + }); + + it('uses default control variant when none is provided', () => { + const result = selectExperimentVariant( + 'threadUI', + { + enabled: true, + rolloutPercentage: 100, + variants: ['alpha'], + }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(true); + expect(result.variant).toBe('alpha'); + }); +}); diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 6cb2a9ad3..f942ad2bc 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -7,6 +7,21 @@ export type HashRouterConfig = { basename?: string; }; +export type ExperimentConfig = { + enabled?: boolean; + rolloutPercentage?: number; + variants?: string[]; + controlVariant?: string; +}; + +export type ExperimentSelection = { + key: string; + enabled: boolean; + rolloutPercentage: number; + variant: string; + inExperiment: boolean; +}; + export type ClientConfig = { defaultHomeserver?: number; homeserverList?: string[]; @@ -16,6 +31,8 @@ export type ClientConfig = { disableAccountSwitcher?: boolean; hideUsernamePasswordFields?: boolean; + experiments?: Record; + pushNotificationDetails?: { pushNotifyUrl?: string; vapidPublicKey?: string; @@ -66,6 +83,84 @@ export function useOptionalClientConfig(): ClientConfig | null { return useContext(ClientConfigContext); } +const DEFAULT_CONTROL_VARIANT = 'control'; + +const normalizeRolloutPercentage = (value?: number): number => { + if (typeof value !== 'number' || Number.isNaN(value)) return 100; + if (value < 0) return 0; + if (value > 100) return 100; + return value; +}; + +const hashToUInt32 = (input: string): number => { + let hash = 0; + for (let index = 0; index < input.length; index += 1) { + hash = (hash * 131 + input.charCodeAt(index)) % 4294967291; + } + return hash; +}; + +export const selectExperimentVariant = ( + key: string, + experiment: ExperimentConfig | undefined, + subjectId: string | undefined +): ExperimentSelection => { + const controlVariant = experiment?.controlVariant ?? DEFAULT_CONTROL_VARIANT; + const variants = (experiment?.variants?.filter((variant) => variant.length > 0) ?? []).filter( + (variant) => variant !== controlVariant + ); + + const enabled = Boolean(experiment?.enabled); + const rolloutPercentage = normalizeRolloutPercentage(experiment?.rolloutPercentage); + + if (!enabled || !subjectId || variants.length === 0 || rolloutPercentage === 0) { + return { + key, + enabled, + rolloutPercentage, + variant: controlVariant, + inExperiment: false, + }; + } + + // Two independent hashes keep rollout and variant assignment stable but decorrelated. + const rolloutBucket = hashToUInt32(`${key}:rollout:${subjectId}`) % 10000; + const rolloutCutoff = Math.floor(rolloutPercentage * 100); + if (rolloutBucket >= rolloutCutoff) { + return { + key, + enabled, + rolloutPercentage, + variant: controlVariant, + inExperiment: false, + }; + } + + const variantIndex = hashToUInt32(`${key}:variant:${subjectId}`) % variants.length; + return { + key, + enabled, + rolloutPercentage, + variant: variants[variantIndex]!, + inExperiment: true, + }; +}; + +export const useExperimentVariant = (key: string, subjectId?: string): ExperimentSelection => { + const clientConfig = useClientConfig(); + return selectExperimentVariant(key, clientConfig.experiments?.[key], subjectId); +}; + +const EXPERIMENT_OVERRIDE_PREFIX = 'sable_exp_'; + +export const setExperimentOverride = (key: string, value: boolean | null): void => { + if (value === null) { + localStorage.removeItem(`${EXPERIMENT_OVERRIDE_PREFIX}${key}`); + } else { + localStorage.setItem(`${EXPERIMENT_OVERRIDE_PREFIX}${key}`, String(value)); + } +}; + export const clientDefaultServer = (clientConfig: ClientConfig): string => clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org'; diff --git a/src/ext.d.ts b/src/ext.d.ts index 7ee0a20a8..cbded3fb2 100644 --- a/src/ext.d.ts +++ b/src/ext.d.ts @@ -3,6 +3,7 @@ declare const APP_VERSION: string; declare const BUILD_HASH: string; declare const IS_RELEASE_TAG: boolean; +declare const INJECTED_EXPERIMENT_FLAGS: Record; declare module 'browser-encrypt-attachment' { export interface EncryptedAttachmentInfo { diff --git a/vite.config.ts b/vite.config.ts index bfa79f67c..7faf3c439 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -50,6 +50,15 @@ const resolveBuildHash = (): string | undefined => { const appVersion = packageJson.version; const buildHash = resolveBuildHash(); +const injectedExperimentFlags: Record = Object.fromEntries( + Object.entries(process.env) + .filter(([k]) => k.startsWith('VITE_FEATURE_')) + .map(([k, v]) => [ + k.slice('VITE_FEATURE_'.length).toLowerCase().replace(/_/g, '-'), + v === 'true' || v === '1', + ]) +); + const isReleaseTag = (() => { const envVal = process.env.VITE_IS_RELEASE_TAG; if (envVal !== undefined && envVal !== '') return envVal === 'true'; @@ -131,6 +140,7 @@ export default defineConfig(({ command }) => ({ APP_VERSION: JSON.stringify(appVersion), BUILD_HASH: JSON.stringify(buildHash ?? ''), IS_RELEASE_TAG: JSON.stringify(isReleaseTag), + INJECTED_EXPERIMENT_FLAGS: JSON.stringify(injectedExperimentFlags), }, resolve: { alias: {