diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 654e66c..86c2a01 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -90,6 +90,39 @@ type SessionTimeoutAction = 'lock' | 'logout'; const LOCK_TIMEOUT_STORAGE_KEY = 'nodewarden.lock.timeout-minutes.v1'; const SESSION_TIMEOUT_ACTION_STORAGE_KEY = 'nodewarden.session.timeout-action.v1'; const LOCK_TIMEOUT_VALUES = new Set([0, 1, 5, 15, 30]); +const DECRYPT_BATCH_SIZE = 16; + +function yieldToMainThread(): Promise { + return new Promise((resolve) => { + if (typeof window !== 'undefined' && typeof window.setTimeout === 'function') { + window.setTimeout(resolve, 0); + return; + } + setTimeout(resolve, 0); + }); +} + +async function mapAsyncInBatches( + items: readonly T[], + mapper: (item: T, index: number) => Promise, + options?: { batchSize?: number; shouldContinue?: () => boolean } +): Promise { + const batchSize = Math.max(1, options?.batchSize || DECRYPT_BATCH_SIZE); + const result: R[] = new Array(items.length); + for (let start = 0; start < items.length; start += batchSize) { + if (options?.shouldContinue && !options.shouldContinue()) break; + const end = Math.min(items.length, start + batchSize); + const chunk = items.slice(start, end); + const mapped = await Promise.all(chunk.map((item, offset) => mapper(item, start + offset))); + for (let i = 0; i < mapped.length; i += 1) { + result[start + i] = mapped[i]; + } + if (end < items.length) { + await yieldToMainThread(); + } + } + return result; +} function readThemePreference(): ThemePreference { if (typeof window === 'undefined') return 'system'; @@ -817,7 +850,8 @@ export default function App() { const decryptFieldWithSource = async ( value: string | null | undefined, itemEnc: Uint8Array, - itemMac: Uint8Array + itemMac: Uint8Array, + canFallbackToUserKey: boolean ): Promise<{ text: string; source: 'item' | 'user' | 'plain' }> => { const raw = String(value || '').trim(); if (!raw) return { text: '', source: 'plain' }; @@ -826,7 +860,7 @@ export default function App() { } catch { // 继续尝试旧 user key 数据。 } - if (!sameBytes(itemEnc, encKey) || !sameBytes(itemMac, macKey)) { + if (canFallbackToUserKey) { try { return { text: await decryptStr(raw, encKey, macKey), source: 'user' }; } catch { @@ -836,15 +870,18 @@ export default function App() { return { text: raw, source: 'plain' }; }; - const folders = await Promise.all( - foldersQuery.data.map(async (folder) => ({ + const folders = await mapAsyncInBatches( + foldersQuery.data, + async (folder) => ({ ...folder, decName: await decryptField(folder.name, encKey, macKey), - })) + }), + { shouldContinue: () => active } ); - const ciphers = await Promise.all( - ciphersQuery.data.map(async (cipher) => { + const ciphers = await mapAsyncInBatches( + ciphersQuery.data, + async (cipher) => { let itemEnc = encKey; let itemMac = macKey; if (cipher.key) { @@ -856,6 +893,7 @@ export default function App() { // keep user key when item key decrypt fails } } + const itemUsesUserKey = sameBytes(itemEnc, encKey) && sameBytes(itemMac, macKey); const nextCipher: Cipher = { ...cipher, @@ -942,7 +980,7 @@ export default function App() { nextCipher.attachments = await Promise.all( cipher.attachments.map(async (attachment) => { const attachmentId = String(attachment?.id || '').trim(); - const fileNameResult = await decryptFieldWithSource(attachment.fileName || '', itemEnc, itemMac); + const fileNameResult = await decryptFieldWithSource(attachment.fileName || '', itemEnc, itemMac, !itemUsesUserKey); const metadata: { fileName?: string; key?: string | null } = {}; if (attachmentId && fileNameResult.source === 'user') { @@ -954,7 +992,7 @@ export default function App() { attachmentId && attachmentKey && looksLikeCipherString(attachmentKey) && - (!sameBytes(itemEnc, encKey) || !sameBytes(itemMac, macKey)) + !itemUsesUserKey ) { try { await decryptBw(attachmentKey, itemEnc, itemMac); @@ -982,7 +1020,8 @@ export default function App() { ); } return nextCipher; - }) + }, + { shouldContinue: () => active } ); if (!active) return; @@ -1024,35 +1063,39 @@ export default function App() { return value; } }; - const sends = await Promise.all(sendsQuery.data.map(async (send) => { - const nextSend: Send = { ...send }; - try { - if (send.key) { - const sendKeyRaw = await decryptBw(send.key, encKey, macKey); - const derived = await deriveSendKeyParts(sendKeyRaw); - nextSend.decName = await decryptField(send.name || '', derived.enc, derived.mac); - nextSend.decNotes = await decryptField(send.notes || '', derived.enc, derived.mac); - nextSend.decText = await decryptField(send.text?.text || '', derived.enc, derived.mac); - if (send.file?.fileName) { - const decFileName = await decryptField(send.file.fileName, derived.enc, derived.mac); - nextSend.file = { - ...(send.file || {}), - fileName: decFileName || send.file.fileName, - }; + const sends = await mapAsyncInBatches( + sendsQuery.data, + async (send) => { + const nextSend: Send = { ...send }; + try { + if (send.key) { + const sendKeyRaw = await decryptBw(send.key, encKey, macKey); + const derived = await deriveSendKeyParts(sendKeyRaw); + nextSend.decName = await decryptField(send.name || '', derived.enc, derived.mac); + nextSend.decNotes = await decryptField(send.notes || '', derived.enc, derived.mac); + nextSend.decText = await decryptField(send.text?.text || '', derived.enc, derived.mac); + if (send.file?.fileName) { + const decFileName = await decryptField(send.file.fileName, derived.enc, derived.mac); + nextSend.file = { + ...(send.file || {}), + fileName: decFileName || send.file.fileName, + }; + } + const shareKey = await buildSendShareKey(send.key, session.symEncKey!, session.symMacKey!); + nextSend.decShareKey = shareKey; + nextSend.shareUrl = buildPublicSendUrl(window.location.origin, send.accessId, shareKey); + } else { + nextSend.decName = ''; + nextSend.decNotes = ''; + nextSend.decText = ''; } - const shareKey = await buildSendShareKey(send.key, session.symEncKey!, session.symMacKey!); - nextSend.decShareKey = shareKey; - nextSend.shareUrl = buildPublicSendUrl(window.location.origin, send.accessId, shareKey); - } else { - nextSend.decName = ''; - nextSend.decNotes = ''; - nextSend.decText = ''; + } catch { + nextSend.decName = t('txt_decrypt_failed'); } - } catch { - nextSend.decName = t('txt_decrypt_failed'); - } - return nextSend; - })); + return nextSend; + }, + { shouldContinue: () => active } + ); if (!active) return; setDecryptedSends(sends); diff --git a/webapp/src/components/TotpCodesPage.tsx b/webapp/src/components/TotpCodesPage.tsx index de3cd6d..440041e 100644 --- a/webapp/src/components/TotpCodesPage.tsx +++ b/webapp/src/components/TotpCodesPage.tsx @@ -33,6 +33,8 @@ const TOTP_PERIOD_SECONDS = 30; const TOTP_RING_RADIUS = 14; const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS; const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order'; +const TOTP_REFRESH_BATCH_SIZE = 16; +const ICON_LOAD_ROOT_MARGIN = '180px 0px'; const failedIconHosts = new Set(); function getTotpTimeState(): { windowId: number; remain: number } { @@ -71,41 +73,80 @@ function hostFromUri(uri: string): string { function TotpListIcon({ cipher }: { cipher: Cipher }) { const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]); + const iconStackRef = useRef(null); const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false)); - const [loaded, setLoaded] = useState(false); + const [shouldLoad, setShouldLoad] = useState(() => !host); const markIconError = () => { if (host) failedIconHosts.add(host); setErrored(true); }; - const syncCachedIconState = (img: HTMLImageElement | null) => { - if (!img || !img.complete) return; - if (img.naturalWidth > 0) { - setLoaded(true); - return; + const hideFallback = () => { + const stack = iconStackRef.current; + if (stack) { + const fallback = stack.querySelector('.list-icon-fallback') as HTMLElement | null; + if (fallback) fallback.style.display = 'none'; } - markIconError(); }; + const handleImgRef = (img: HTMLImageElement | null) => { + if (!img || !img.complete) return; + if (img.naturalWidth > 0) hideFallback(); + }; + useEffect(() => { setErrored(host ? failedIconHosts.has(host) : false); - setLoaded(false); + setShouldLoad(!host); + const fallback = iconStackRef.current?.querySelector('.list-icon-fallback') as HTMLElement | null; + if (fallback) fallback.style.display = ''; }, [host]); + useEffect(() => { + if (!host || errored || shouldLoad) return; + const node = iconStackRef.current; + if (!node) return; + if (typeof IntersectionObserver !== 'function') { + setShouldLoad(true); + return; + } + + let cancelled = false; + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting && entry.intersectionRatio <= 0) continue; + if (!cancelled) setShouldLoad(true); + observer.disconnect(); + break; + } + }, + { rootMargin: ICON_LOAD_ROOT_MARGIN } + ); + + observer.observe(node); + return () => { + cancelled = true; + observer.disconnect(); + }; + }, [host, errored, shouldLoad]); + if (host && !errored) { return ( - - + + - setLoaded(true)} - onError={markIconError} - /> + {shouldLoad && ( + + )} ); } @@ -294,17 +335,41 @@ export default function TotpCodesPage(props: TotpCodesPageProps) { const refreshCodes = async () => { const runId = ++activeRun; - const entries = await Promise.all( - totpItems.map(async (cipher) => { - try { - const next = await calcTotpNow(cipher.login?.decTotp || ''); - return [cipher.id, next?.code || null] as const; - } catch { - return [cipher.id, null] as const; - } - }) - ); - if (!stopped && runId === activeRun) setTotpCodes(Object.fromEntries(entries)); + const nextCodes: Record = {}; + for (let start = 0; start < totpItems.length; start += TOTP_REFRESH_BATCH_SIZE) { + if (stopped || runId !== activeRun) return; + const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE); + const entries = await Promise.all( + batch.map(async (cipher) => { + try { + const next = await calcTotpNow(cipher.login?.decTotp || ''); + return [cipher.id, next?.code || null] as const; + } catch { + return [cipher.id, null] as const; + } + }) + ); + for (const [id, code] of entries) nextCodes[id] = code; + if (start + TOTP_REFRESH_BATCH_SIZE < totpItems.length) { + await new Promise((resolve) => window.setTimeout(resolve, 0)); + } + } + if (stopped || runId !== activeRun) return; + setTotpCodes((prev) => { + let changed = false; + const next: Record = { ...prev }; + for (const id of Object.keys(next)) { + if (id in nextCodes) continue; + delete next[id]; + changed = true; + } + for (const [id, code] of Object.entries(nextCodes)) { + if (next[id] === code) continue; + next[id] = code; + changed = true; + } + return changed ? next : prev; + }); }; const tick = () => { diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index a2950f1..cfaa48e 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'; import VaultDialogs from '@/components/vault/VaultDialogs'; import VaultDetailView from '@/components/vault/VaultDetailView'; import VaultEditor from '@/components/vault/VaultEditor'; @@ -474,7 +474,7 @@ export default function VaultPage(props: VaultPageProps) { !props.loading && !busy; - function handleReorderVaultCipher(activeId: string, overId: string): void { + const handleReorderVaultCipher = useCallback((activeId: string, overId: string): void => { if (!canReorderVaultList || activeId === overId) return; const currentIds = filteredCiphers.map((cipher) => cipher.id); const fromIndex = currentIds.indexOf(activeId); @@ -498,7 +498,7 @@ export default function VaultPage(props: VaultPageProps) { suppressNextSortScrollRef.current = true; setSortMode('manual'); } - } + }, [canReorderVaultList, filteredCiphers, props.ciphers, sortMode]); useEffect(() => { if (isCreating) return; @@ -575,27 +575,27 @@ export default function VaultPage(props: VaultPageProps) { ); const totalCipherCount = filteredCiphers.length; -function folderName(id: string | null | undefined): string { +const folderName = useCallback((id: string | null | undefined): string => { if (!id) return t('txt_no_folder'); const folder = folderById.get(id); return folder?.decName || folder?.name || id; -} +}, [folderById]); - function listSubtitle(cipher: Cipher): string { + const listSubtitle = useCallback((cipher: Cipher): string => { if (Number(cipher.type || 1) === 1) { return cipher.login?.decUsername || cipherMetaById.get(cipher.id)?.firstUri || ''; } return cipherTypeLabel(Number(cipher.type || 1)); - } + }, [cipherMetaById]); - function handleListScroll(top: number): void { + const handleListScroll = useCallback((top: number): void => { const bucket = Math.floor(Math.max(0, top) / VAULT_LIST_ROW_HEIGHT); if (bucket === listScrollBucketRef.current) return; listScrollBucketRef.current = bucket; setListScrollTop(top); - } + }, []); - function startCreate(type: number): void { + const startCreate = useCallback((type: number): void => { setDraft(createEmptyDraft(type)); setIsCreating(true); setIsEditing(true); @@ -608,9 +608,9 @@ function folderName(id: string | null | undefined): string { if (isMobileLayout) setMobilePanel('edit'); setMobileSidebarOpen(false); if (type === 5) void seedSshDefaults(); - } + }, [isMobileLayout]); - function startEdit(): void { + const startEdit = useCallback((): void => { if (!selectedCipher) return; setDraft(draftFromCipher(selectedCipher)); setIsCreating(false); @@ -621,9 +621,9 @@ function folderName(id: string | null | undefined): string { setRemovedAttachmentIds({}); if (isMobileLayout) setMobilePanel('edit'); setMobileSidebarOpen(false); - } + }, [selectedCipher, isMobileLayout]); - function cancelEdit(): void { + const cancelEdit = useCallback((): void => { const returnToDetail = isMobileLayout && !isCreating && !!selectedCipher; setDraft(null); setIsEditing(false); @@ -633,11 +633,11 @@ function folderName(id: string | null | undefined): string { setRemovedAttachmentIds({}); setPendingDeletePasskeyIndex(null); if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list'); - } + }, [isMobileLayout, isCreating, selectedCipher]); - function updateDraft(patch: Partial): void { + const updateDraft = useCallback((patch: Partial): void => { setDraft((prev) => (prev ? { ...prev, ...patch } : prev)); - } + }, []); function confirmDeleteLoginPasskey(): void { if (pendingDeletePasskeyIndex == null) return; @@ -1002,16 +1002,88 @@ function folderName(id: string | null | undefined): string { } } + const handleClearSearch = useCallback(() => setSearchInput(''), []); + const handleSearchCompositionStart = useCallback(() => setSearchComposing(true), []); + const handleSearchCompositionEnd = useCallback((value: string) => { + setSearchComposing(false); + setSearchInput(value); + }, []); + const handleToggleSortMenu = useCallback(() => setSortMenuOpen((open) => !open), []); + const handleSelectSortMode = useCallback((value: VaultSortMode) => { + setSortMode(value); + setSortMenuOpen(false); + }, []); + const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]); + const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []); + const handleSelectDuplicates = useCallback(() => { + const map: Record = {}; + const seen = new Set(); + for (const cipher of filteredCiphers) { + const signature = duplicateSignatureInfo?.byId.get(cipher.id) || buildCipherDuplicateSignature(cipher); + if (seen.has(signature)) { + map[cipher.id] = true; + continue; + } + seen.add(signature); + } + setSelectedMap(map); + }, [filteredCiphers, duplicateSignatureInfo]); + const handleSelectAll = useCallback(() => { + const map: Record = {}; + for (const cipher of filteredCiphers) map[cipher.id] = true; + setSelectedMap(map); + }, [filteredCiphers]); + const handleToggleCreateMenu = useCallback(() => setCreateMenuOpen((open) => !open), []); + const handleBulkRestore = useCallback(() => { void confirmBulkRestore(); }, [selectedMap, props.onBulkRestore]); + const handleBulkArchive = useCallback(() => setBulkArchiveOpen(true), []); + const handleBulkUnarchive = useCallback(() => { void confirmBulkUnarchive(); }, [selectedMap, props.onBulkUnarchive]); + const handleOpenMove = useCallback(() => { + setMoveFolderId('__none__'); + setMoveOpen(true); + }, []); + const handleClearSelection = useCallback(() => setSelectedMap({}), []); + const handleToggleSelected = useCallback((cipherId: string, checked: boolean) => + setSelectedMap((prev) => { + if (checked) return { ...prev, [cipherId]: true }; + if (!prev[cipherId]) return prev; + const next = { ...prev }; + delete next[cipherId]; + return next; + }) + , []); + const handleSelectCipher = useCallback((cipherId: string) => { + if (isEditing || isCreating) { + cancelEdit(); + } + setSelectedCipherId(cipherId); + setRepromptApprovedCipherId(null); + if (isMobileLayout) setMobilePanel('detail'); + setMobileSidebarOpen(false); + }, [isEditing, isCreating, cancelEdit, isMobileLayout]); + const handleCloseMobileSidebar = useCallback(() => setMobileSidebarOpen(false), []); + const handleOpenDeleteAllFolders = useCallback(() => setDeleteAllFoldersOpen(true), []); + const handleOpenCreateFolder = useCallback(() => setCreateFolderOpen(true), []); + const handleOpenRenameFolder = useCallback((folder: Folder) => { + setPendingRenameFolder(folder); + setRenameFolderName(folder.decName || folder.name || ''); + }, []); + const handleToggleFolderSortMenu = useCallback(() => setFolderSortMenuOpen((open) => !open), []); + const handleSelectFolderSortMode = useCallback((value: VaultSortMode) => { + setFolderSortMode(value); + setFolderSortMenuOpen(false); + }, []); + const handleMobileSidebarMaskClick = useCallback(() => { + if (!mobileSidebarOpen) return; + setMobileSidebarOpen(false); + }, [mobileSidebarOpen]); + return ( <>
{isMobileLayout && (
{ - if (!mobileSidebarOpen) return; - setMobileSidebarOpen(false); - }} + onClick={handleMobileSidebarMaskClick} /> )} setMobileSidebarOpen(false)} + onCloseMobileSidebar={handleCloseMobileSidebar} onChangeFilter={setSidebarFilter} - onOpenDeleteAllFolders={() => setDeleteAllFoldersOpen(true)} - onOpenCreateFolder={() => setCreateFolderOpen(true)} - onOpenRenameFolder={(folder) => { - setPendingRenameFolder(folder); - setRenameFolderName(folder.decName || folder.name || ''); - }} + onOpenDeleteAllFolders={handleOpenDeleteAllFolders} + onOpenCreateFolder={handleOpenCreateFolder} + onOpenRenameFolder={handleOpenRenameFolder} onOpenDeleteFolder={setPendingDeleteFolder} - onToggleFolderSortMenu={() => setFolderSortMenuOpen((open) => !open)} - onSelectFolderSortMode={(value) => { - setFolderSortMode(value); - setFolderSortMenuOpen(false); - }} + onToggleFolderSortMenu={handleToggleFolderSortMenu} + onSelectFolderSortMode={handleSelectFolderSortMode} /> setSearchInput('')} - onSearchCompositionStart={() => setSearchComposing(true)} - onSearchCompositionEnd={(value) => { - setSearchComposing(false); - setSearchInput(value); - }} - onToggleSortMenu={() => setSortMenuOpen((open) => !open)} - onSelectSortMode={(value) => { - setSortMode(value); - setSortMenuOpen(false); - }} - onSyncVault={() => void syncVault()} - onOpenBulkDelete={() => setBulkDeleteOpen(true)} - onSelectDuplicates={() => { - const map: Record = {}; - const seen = new Set(); - for (const cipher of filteredCiphers) { - const signature = duplicateSignatureInfo?.byId.get(cipher.id) || buildCipherDuplicateSignature(cipher); - if (seen.has(signature)) { - map[cipher.id] = true; - continue; - } - seen.add(signature); - } - setSelectedMap(map); - }} - onSelectAll={() => { - const map: Record = {}; - for (const cipher of filteredCiphers) map[cipher.id] = true; - setSelectedMap(map); - }} - onToggleCreateMenu={() => setCreateMenuOpen((open) => !open)} + onClearSearch={handleClearSearch} + onSearchCompositionStart={handleSearchCompositionStart} + onSearchCompositionEnd={handleSearchCompositionEnd} + onToggleSortMenu={handleToggleSortMenu} + onSelectSortMode={handleSelectSortMode} + onSyncVault={handleSyncVault} + onOpenBulkDelete={handleOpenBulkDelete} + onSelectDuplicates={handleSelectDuplicates} + onSelectAll={handleSelectAll} + onToggleCreateMenu={handleToggleCreateMenu} onStartCreate={startCreate} - onBulkRestore={() => void confirmBulkRestore()} - onBulkArchive={() => setBulkArchiveOpen(true)} - onBulkUnarchive={() => void confirmBulkUnarchive()} - onOpenMove={() => { - setMoveFolderId('__none__'); - setMoveOpen(true); - }} - onClearSelection={() => setSelectedMap({})} + onBulkRestore={handleBulkRestore} + onBulkArchive={handleBulkArchive} + onBulkUnarchive={handleBulkUnarchive} + onOpenMove={handleOpenMove} + onClearSelection={handleClearSelection} onReorderCipher={handleReorderVaultCipher} onScroll={handleListScroll} - onToggleSelected={(cipherId, checked) => - setSelectedMap((prev) => { - if (checked) return { ...prev, [cipherId]: true }; - if (!prev[cipherId]) return prev; - const next = { ...prev }; - delete next[cipherId]; - return next; - }) - } - onSelectCipher={(cipherId) => { - if (isEditing || isCreating) { - cancelEdit(); - } - setSelectedCipherId(cipherId); - setRepromptApprovedCipherId(null); - if (isMobileLayout) setMobilePanel('detail'); - setMobileSidebarOpen(false); - }} + onToggleSelected={handleToggleSelected} + onSelectCipher={handleSelectCipher} listSubtitle={listSubtitle} /> diff --git a/webapp/src/components/vault/VaultListPanel.tsx b/webapp/src/components/vault/VaultListPanel.tsx index 02b2f34..840eb82 100644 --- a/webapp/src/components/vault/VaultListPanel.tsx +++ b/webapp/src/components/vault/VaultListPanel.tsx @@ -1,4 +1,5 @@ import type { JSX, RefObject } from 'preact'; +import { memo } from 'preact/compat'; import { createPortal } from 'preact/compat'; import { useMemo, useState } from 'preact/hooks'; import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, GripVertical, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact'; @@ -106,7 +107,7 @@ interface CipherListItemBodyProps { onSelectCipher?: (cipherId: string) => void; } -function CipherListItemBody(props: CipherListItemBodyProps) { +const CipherListItemBody = memo(function CipherListItemBody(props: CipherListItemBodyProps) { return ( <> ); -} +}); const animateLayoutChanges: AnimateLayoutChanges = (args) => args.isSorting || args.wasDragging ? defaultAnimateLayoutChanges(args) : false; -function SortableCipherListItem(props: SortableCipherListItemProps) { +const SortableCipherListItem = memo(function SortableCipherListItem(props: SortableCipherListItemProps) { const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({ id: props.cipher.id, disabled: !props.canReorder, @@ -184,9 +185,9 @@ function SortableCipherListItem(props: SortableCipherListItemProps) { />
); -} +}); -function PlainCipherListItem(props: SortableCipherListItemProps) { +const PlainCipherListItem = memo(function PlainCipherListItem(props: SortableCipherListItemProps) { return (
); -} +}); export default function VaultListPanel(props: VaultListPanelProps) { const [activeDragId, setActiveDragId] = useState(''); diff --git a/webapp/src/components/vault/VaultSidebar.tsx b/webapp/src/components/vault/VaultSidebar.tsx index 8c0669a..9e070fc 100644 --- a/webapp/src/components/vault/VaultSidebar.tsx +++ b/webapp/src/components/vault/VaultSidebar.tsx @@ -43,6 +43,10 @@ interface VaultSidebarProps { } export default function VaultSidebar(props: VaultSidebarProps) { + const nameCollator = useMemo( + () => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }), + [] + ); const sortedFolders = useMemo(() => { const sorted = [...props.folders]; sorted.sort((a, b) => { @@ -67,14 +71,14 @@ export default function VaultSidebar(props: VaultSidebarProps) { } if (aValid !== bValid) return aValid ? -1 : 1; } - const nameDiff = String(a.decName || a.name || '').localeCompare( - String(b.decName || b.name || ''), undefined, { sensitivity: 'base', numeric: true } + const nameDiff = nameCollator.compare( + String(a.decName || a.name || ''), String(b.decName || b.name || '') ); if (nameDiff !== 0) return nameDiff; return String(a.id || '').localeCompare(String(b.id || '')); }); return sorted; - }, [props.folders, props.folderSortMode]); + }, [props.folders, props.folderSortMode, nameCollator]); return (