mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: implement caching for cryptographic keys to improve performance and reduce overhead
This commit is contained in:
+80
-37
@@ -90,6 +90,39 @@ type SessionTimeoutAction = 'lock' | 'logout';
|
|||||||
const LOCK_TIMEOUT_STORAGE_KEY = 'nodewarden.lock.timeout-minutes.v1';
|
const LOCK_TIMEOUT_STORAGE_KEY = 'nodewarden.lock.timeout-minutes.v1';
|
||||||
const SESSION_TIMEOUT_ACTION_STORAGE_KEY = 'nodewarden.session.timeout-action.v1';
|
const SESSION_TIMEOUT_ACTION_STORAGE_KEY = 'nodewarden.session.timeout-action.v1';
|
||||||
const LOCK_TIMEOUT_VALUES = new Set<LockTimeoutMinutes>([0, 1, 5, 15, 30]);
|
const LOCK_TIMEOUT_VALUES = new Set<LockTimeoutMinutes>([0, 1, 5, 15, 30]);
|
||||||
|
const DECRYPT_BATCH_SIZE = 16;
|
||||||
|
|
||||||
|
function yieldToMainThread(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (typeof window !== 'undefined' && typeof window.setTimeout === 'function') {
|
||||||
|
window.setTimeout(resolve, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(resolve, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mapAsyncInBatches<T, R>(
|
||||||
|
items: readonly T[],
|
||||||
|
mapper: (item: T, index: number) => Promise<R>,
|
||||||
|
options?: { batchSize?: number; shouldContinue?: () => boolean }
|
||||||
|
): Promise<R[]> {
|
||||||
|
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 {
|
function readThemePreference(): ThemePreference {
|
||||||
if (typeof window === 'undefined') return 'system';
|
if (typeof window === 'undefined') return 'system';
|
||||||
@@ -817,7 +850,8 @@ export default function App() {
|
|||||||
const decryptFieldWithSource = async (
|
const decryptFieldWithSource = async (
|
||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
itemEnc: Uint8Array,
|
itemEnc: Uint8Array,
|
||||||
itemMac: Uint8Array
|
itemMac: Uint8Array,
|
||||||
|
canFallbackToUserKey: boolean
|
||||||
): Promise<{ text: string; source: 'item' | 'user' | 'plain' }> => {
|
): Promise<{ text: string; source: 'item' | 'user' | 'plain' }> => {
|
||||||
const raw = String(value || '').trim();
|
const raw = String(value || '').trim();
|
||||||
if (!raw) return { text: '', source: 'plain' };
|
if (!raw) return { text: '', source: 'plain' };
|
||||||
@@ -826,7 +860,7 @@ export default function App() {
|
|||||||
} catch {
|
} catch {
|
||||||
// 继续尝试旧 user key 数据。
|
// 继续尝试旧 user key 数据。
|
||||||
}
|
}
|
||||||
if (!sameBytes(itemEnc, encKey) || !sameBytes(itemMac, macKey)) {
|
if (canFallbackToUserKey) {
|
||||||
try {
|
try {
|
||||||
return { text: await decryptStr(raw, encKey, macKey), source: 'user' };
|
return { text: await decryptStr(raw, encKey, macKey), source: 'user' };
|
||||||
} catch {
|
} catch {
|
||||||
@@ -836,15 +870,18 @@ export default function App() {
|
|||||||
return { text: raw, source: 'plain' };
|
return { text: raw, source: 'plain' };
|
||||||
};
|
};
|
||||||
|
|
||||||
const folders = await Promise.all(
|
const folders = await mapAsyncInBatches(
|
||||||
foldersQuery.data.map(async (folder) => ({
|
foldersQuery.data,
|
||||||
|
async (folder) => ({
|
||||||
...folder,
|
...folder,
|
||||||
decName: await decryptField(folder.name, encKey, macKey),
|
decName: await decryptField(folder.name, encKey, macKey),
|
||||||
}))
|
}),
|
||||||
|
{ shouldContinue: () => active }
|
||||||
);
|
);
|
||||||
|
|
||||||
const ciphers = await Promise.all(
|
const ciphers = await mapAsyncInBatches(
|
||||||
ciphersQuery.data.map(async (cipher) => {
|
ciphersQuery.data,
|
||||||
|
async (cipher) => {
|
||||||
let itemEnc = encKey;
|
let itemEnc = encKey;
|
||||||
let itemMac = macKey;
|
let itemMac = macKey;
|
||||||
if (cipher.key) {
|
if (cipher.key) {
|
||||||
@@ -856,6 +893,7 @@ export default function App() {
|
|||||||
// keep user key when item key decrypt fails
|
// keep user key when item key decrypt fails
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const itemUsesUserKey = sameBytes(itemEnc, encKey) && sameBytes(itemMac, macKey);
|
||||||
|
|
||||||
const nextCipher: Cipher = {
|
const nextCipher: Cipher = {
|
||||||
...cipher,
|
...cipher,
|
||||||
@@ -942,7 +980,7 @@ export default function App() {
|
|||||||
nextCipher.attachments = await Promise.all(
|
nextCipher.attachments = await Promise.all(
|
||||||
cipher.attachments.map(async (attachment) => {
|
cipher.attachments.map(async (attachment) => {
|
||||||
const attachmentId = String(attachment?.id || '').trim();
|
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 } = {};
|
const metadata: { fileName?: string; key?: string | null } = {};
|
||||||
|
|
||||||
if (attachmentId && fileNameResult.source === 'user') {
|
if (attachmentId && fileNameResult.source === 'user') {
|
||||||
@@ -954,7 +992,7 @@ export default function App() {
|
|||||||
attachmentId &&
|
attachmentId &&
|
||||||
attachmentKey &&
|
attachmentKey &&
|
||||||
looksLikeCipherString(attachmentKey) &&
|
looksLikeCipherString(attachmentKey) &&
|
||||||
(!sameBytes(itemEnc, encKey) || !sameBytes(itemMac, macKey))
|
!itemUsesUserKey
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
await decryptBw(attachmentKey, itemEnc, itemMac);
|
await decryptBw(attachmentKey, itemEnc, itemMac);
|
||||||
@@ -982,7 +1020,8 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return nextCipher;
|
return nextCipher;
|
||||||
})
|
},
|
||||||
|
{ shouldContinue: () => active }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
@@ -1024,35 +1063,39 @@ export default function App() {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const sends = await Promise.all(sendsQuery.data.map(async (send) => {
|
const sends = await mapAsyncInBatches(
|
||||||
const nextSend: Send = { ...send };
|
sendsQuery.data,
|
||||||
try {
|
async (send) => {
|
||||||
if (send.key) {
|
const nextSend: Send = { ...send };
|
||||||
const sendKeyRaw = await decryptBw(send.key, encKey, macKey);
|
try {
|
||||||
const derived = await deriveSendKeyParts(sendKeyRaw);
|
if (send.key) {
|
||||||
nextSend.decName = await decryptField(send.name || '', derived.enc, derived.mac);
|
const sendKeyRaw = await decryptBw(send.key, encKey, macKey);
|
||||||
nextSend.decNotes = await decryptField(send.notes || '', derived.enc, derived.mac);
|
const derived = await deriveSendKeyParts(sendKeyRaw);
|
||||||
nextSend.decText = await decryptField(send.text?.text || '', derived.enc, derived.mac);
|
nextSend.decName = await decryptField(send.name || '', derived.enc, derived.mac);
|
||||||
if (send.file?.fileName) {
|
nextSend.decNotes = await decryptField(send.notes || '', derived.enc, derived.mac);
|
||||||
const decFileName = await decryptField(send.file.fileName, derived.enc, derived.mac);
|
nextSend.decText = await decryptField(send.text?.text || '', derived.enc, derived.mac);
|
||||||
nextSend.file = {
|
if (send.file?.fileName) {
|
||||||
...(send.file || {}),
|
const decFileName = await decryptField(send.file.fileName, derived.enc, derived.mac);
|
||||||
fileName: decFileName || send.file.fileName,
|
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!);
|
} catch {
|
||||||
nextSend.decShareKey = shareKey;
|
nextSend.decName = t('txt_decrypt_failed');
|
||||||
nextSend.shareUrl = buildPublicSendUrl(window.location.origin, send.accessId, shareKey);
|
|
||||||
} else {
|
|
||||||
nextSend.decName = '';
|
|
||||||
nextSend.decNotes = '';
|
|
||||||
nextSend.decText = '';
|
|
||||||
}
|
}
|
||||||
} catch {
|
return nextSend;
|
||||||
nextSend.decName = t('txt_decrypt_failed');
|
},
|
||||||
}
|
{ shouldContinue: () => active }
|
||||||
return nextSend;
|
);
|
||||||
}));
|
|
||||||
|
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setDecryptedSends(sends);
|
setDecryptedSends(sends);
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ const TOTP_PERIOD_SECONDS = 30;
|
|||||||
const TOTP_RING_RADIUS = 14;
|
const TOTP_RING_RADIUS = 14;
|
||||||
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||||
const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order';
|
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<string>();
|
const failedIconHosts = new Set<string>();
|
||||||
|
|
||||||
function getTotpTimeState(): { windowId: number; remain: number } {
|
function getTotpTimeState(): { windowId: number; remain: number } {
|
||||||
@@ -71,41 +73,80 @@ function hostFromUri(uri: string): string {
|
|||||||
|
|
||||||
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
||||||
const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
|
const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
|
||||||
|
const iconStackRef = useRef<HTMLSpanElement | null>(null);
|
||||||
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
|
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [shouldLoad, setShouldLoad] = useState(() => !host);
|
||||||
const markIconError = () => {
|
const markIconError = () => {
|
||||||
if (host) failedIconHosts.add(host);
|
if (host) failedIconHosts.add(host);
|
||||||
setErrored(true);
|
setErrored(true);
|
||||||
};
|
};
|
||||||
const syncCachedIconState = (img: HTMLImageElement | null) => {
|
const hideFallback = () => {
|
||||||
if (!img || !img.complete) return;
|
const stack = iconStackRef.current;
|
||||||
if (img.naturalWidth > 0) {
|
if (stack) {
|
||||||
setLoaded(true);
|
const fallback = stack.querySelector('.list-icon-fallback') as HTMLElement | null;
|
||||||
return;
|
if (fallback) fallback.style.display = 'none';
|
||||||
}
|
}
|
||||||
markIconError();
|
|
||||||
};
|
};
|
||||||
|
const handleImgRef = (img: HTMLImageElement | null) => {
|
||||||
|
if (!img || !img.complete) return;
|
||||||
|
if (img.naturalWidth > 0) hideFallback();
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setErrored(host ? failedIconHosts.has(host) : false);
|
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]);
|
}, [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) {
|
if (host && !errored) {
|
||||||
return (
|
return (
|
||||||
<span className="list-icon-stack">
|
<span className="list-icon-stack" ref={iconStackRef}>
|
||||||
<span className={`list-icon-fallback ${loaded ? 'hidden' : ''}`}>
|
<span className="list-icon-fallback">
|
||||||
<Globe size={18} />
|
<Globe size={18} />
|
||||||
</span>
|
</span>
|
||||||
<img
|
{shouldLoad && (
|
||||||
className={`list-icon ${loaded ? 'loaded' : ''}`}
|
<img
|
||||||
src={websiteIconUrl(host)}
|
className="list-icon loaded"
|
||||||
alt=""
|
src={websiteIconUrl(host)}
|
||||||
loading="lazy"
|
alt=""
|
||||||
referrerPolicy="no-referrer"
|
loading="lazy"
|
||||||
ref={syncCachedIconState}
|
decoding="async"
|
||||||
onLoad={() => setLoaded(true)}
|
referrerPolicy="no-referrer"
|
||||||
onError={markIconError}
|
ref={handleImgRef}
|
||||||
/>
|
onLoad={hideFallback}
|
||||||
|
onError={markIconError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -294,17 +335,41 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
|
|
||||||
const refreshCodes = async () => {
|
const refreshCodes = async () => {
|
||||||
const runId = ++activeRun;
|
const runId = ++activeRun;
|
||||||
const entries = await Promise.all(
|
const nextCodes: Record<string, string | null> = {};
|
||||||
totpItems.map(async (cipher) => {
|
for (let start = 0; start < totpItems.length; start += TOTP_REFRESH_BATCH_SIZE) {
|
||||||
try {
|
if (stopped || runId !== activeRun) return;
|
||||||
const next = await calcTotpNow(cipher.login?.decTotp || '');
|
const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE);
|
||||||
return [cipher.id, next?.code || null] as const;
|
const entries = await Promise.all(
|
||||||
} catch {
|
batch.map(async (cipher) => {
|
||||||
return [cipher.id, null] as const;
|
try {
|
||||||
}
|
const next = await calcTotpNow(cipher.login?.decTotp || '');
|
||||||
})
|
return [cipher.id, next?.code || null] as const;
|
||||||
);
|
} catch {
|
||||||
if (!stopped && runId === activeRun) setTotpCodes(Object.fromEntries(entries));
|
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<void>((resolve) => window.setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stopped || runId !== activeRun) return;
|
||||||
|
setTotpCodes((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next: Record<string, string | null> = { ...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 = () => {
|
const tick = () => {
|
||||||
|
|||||||
@@ -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 VaultDialogs from '@/components/vault/VaultDialogs';
|
||||||
import VaultDetailView from '@/components/vault/VaultDetailView';
|
import VaultDetailView from '@/components/vault/VaultDetailView';
|
||||||
import VaultEditor from '@/components/vault/VaultEditor';
|
import VaultEditor from '@/components/vault/VaultEditor';
|
||||||
@@ -474,7 +474,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
!props.loading &&
|
!props.loading &&
|
||||||
!busy;
|
!busy;
|
||||||
|
|
||||||
function handleReorderVaultCipher(activeId: string, overId: string): void {
|
const handleReorderVaultCipher = useCallback((activeId: string, overId: string): void => {
|
||||||
if (!canReorderVaultList || activeId === overId) return;
|
if (!canReorderVaultList || activeId === overId) return;
|
||||||
const currentIds = filteredCiphers.map((cipher) => cipher.id);
|
const currentIds = filteredCiphers.map((cipher) => cipher.id);
|
||||||
const fromIndex = currentIds.indexOf(activeId);
|
const fromIndex = currentIds.indexOf(activeId);
|
||||||
@@ -498,7 +498,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
suppressNextSortScrollRef.current = true;
|
suppressNextSortScrollRef.current = true;
|
||||||
setSortMode('manual');
|
setSortMode('manual');
|
||||||
}
|
}
|
||||||
}
|
}, [canReorderVaultList, filteredCiphers, props.ciphers, sortMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCreating) return;
|
if (isCreating) return;
|
||||||
@@ -575,27 +575,27 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
);
|
);
|
||||||
const totalCipherCount = filteredCiphers.length;
|
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');
|
if (!id) return t('txt_no_folder');
|
||||||
const folder = folderById.get(id);
|
const folder = folderById.get(id);
|
||||||
return folder?.decName || folder?.name || 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) {
|
if (Number(cipher.type || 1) === 1) {
|
||||||
return cipher.login?.decUsername || cipherMetaById.get(cipher.id)?.firstUri || '';
|
return cipher.login?.decUsername || cipherMetaById.get(cipher.id)?.firstUri || '';
|
||||||
}
|
}
|
||||||
return cipherTypeLabel(Number(cipher.type || 1));
|
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);
|
const bucket = Math.floor(Math.max(0, top) / VAULT_LIST_ROW_HEIGHT);
|
||||||
if (bucket === listScrollBucketRef.current) return;
|
if (bucket === listScrollBucketRef.current) return;
|
||||||
listScrollBucketRef.current = bucket;
|
listScrollBucketRef.current = bucket;
|
||||||
setListScrollTop(top);
|
setListScrollTop(top);
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
function startCreate(type: number): void {
|
const startCreate = useCallback((type: number): void => {
|
||||||
setDraft(createEmptyDraft(type));
|
setDraft(createEmptyDraft(type));
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
@@ -608,9 +608,9 @@ function folderName(id: string | null | undefined): string {
|
|||||||
if (isMobileLayout) setMobilePanel('edit');
|
if (isMobileLayout) setMobilePanel('edit');
|
||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
if (type === 5) void seedSshDefaults();
|
if (type === 5) void seedSshDefaults();
|
||||||
}
|
}, [isMobileLayout]);
|
||||||
|
|
||||||
function startEdit(): void {
|
const startEdit = useCallback((): void => {
|
||||||
if (!selectedCipher) return;
|
if (!selectedCipher) return;
|
||||||
setDraft(draftFromCipher(selectedCipher));
|
setDraft(draftFromCipher(selectedCipher));
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
@@ -621,9 +621,9 @@ function folderName(id: string | null | undefined): string {
|
|||||||
setRemovedAttachmentIds({});
|
setRemovedAttachmentIds({});
|
||||||
if (isMobileLayout) setMobilePanel('edit');
|
if (isMobileLayout) setMobilePanel('edit');
|
||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
}
|
}, [selectedCipher, isMobileLayout]);
|
||||||
|
|
||||||
function cancelEdit(): void {
|
const cancelEdit = useCallback((): void => {
|
||||||
const returnToDetail = isMobileLayout && !isCreating && !!selectedCipher;
|
const returnToDetail = isMobileLayout && !isCreating && !!selectedCipher;
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
@@ -633,11 +633,11 @@ function folderName(id: string | null | undefined): string {
|
|||||||
setRemovedAttachmentIds({});
|
setRemovedAttachmentIds({});
|
||||||
setPendingDeletePasskeyIndex(null);
|
setPendingDeletePasskeyIndex(null);
|
||||||
if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list');
|
if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list');
|
||||||
}
|
}, [isMobileLayout, isCreating, selectedCipher]);
|
||||||
|
|
||||||
function updateDraft(patch: Partial<VaultDraft>): void {
|
const updateDraft = useCallback((patch: Partial<VaultDraft>): void => {
|
||||||
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
|
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
function confirmDeleteLoginPasskey(): void {
|
function confirmDeleteLoginPasskey(): void {
|
||||||
if (pendingDeletePasskeyIndex == null) return;
|
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<string, boolean> = {};
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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<string, boolean> = {};
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
|
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
|
||||||
{isMobileLayout && (
|
{isMobileLayout && (
|
||||||
<div
|
<div
|
||||||
className={`mobile-sidebar-mask ${mobileSidebarOpen ? 'open' : ''}`}
|
className={`mobile-sidebar-mask ${mobileSidebarOpen ? 'open' : ''}`}
|
||||||
onClick={() => {
|
onClick={handleMobileSidebarMaskClick}
|
||||||
if (!mobileSidebarOpen) return;
|
|
||||||
setMobileSidebarOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<VaultSidebar
|
<VaultSidebar
|
||||||
@@ -1023,20 +1095,14 @@ function folderName(id: string | null | undefined): string {
|
|||||||
folderSortMode={folderSortMode}
|
folderSortMode={folderSortMode}
|
||||||
folderSortMenuOpen={folderSortMenuOpen}
|
folderSortMenuOpen={folderSortMenuOpen}
|
||||||
folderSortMenuRef={folderSortMenuRef}
|
folderSortMenuRef={folderSortMenuRef}
|
||||||
onCloseMobileSidebar={() => setMobileSidebarOpen(false)}
|
onCloseMobileSidebar={handleCloseMobileSidebar}
|
||||||
onChangeFilter={setSidebarFilter}
|
onChangeFilter={setSidebarFilter}
|
||||||
onOpenDeleteAllFolders={() => setDeleteAllFoldersOpen(true)}
|
onOpenDeleteAllFolders={handleOpenDeleteAllFolders}
|
||||||
onOpenCreateFolder={() => setCreateFolderOpen(true)}
|
onOpenCreateFolder={handleOpenCreateFolder}
|
||||||
onOpenRenameFolder={(folder) => {
|
onOpenRenameFolder={handleOpenRenameFolder}
|
||||||
setPendingRenameFolder(folder);
|
|
||||||
setRenameFolderName(folder.decName || folder.name || '');
|
|
||||||
}}
|
|
||||||
onOpenDeleteFolder={setPendingDeleteFolder}
|
onOpenDeleteFolder={setPendingDeleteFolder}
|
||||||
onToggleFolderSortMenu={() => setFolderSortMenuOpen((open) => !open)}
|
onToggleFolderSortMenu={handleToggleFolderSortMenu}
|
||||||
onSelectFolderSortMode={(value) => {
|
onSelectFolderSortMode={handleSelectFolderSortMode}
|
||||||
setFolderSortMode(value);
|
|
||||||
setFolderSortMenuOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VaultListPanel
|
<VaultListPanel
|
||||||
@@ -1061,67 +1127,26 @@ function folderName(id: string | null | undefined): string {
|
|||||||
sortMenuRef={sortMenuRef}
|
sortMenuRef={sortMenuRef}
|
||||||
listPanelRef={listPanelRef}
|
listPanelRef={listPanelRef}
|
||||||
onSearchInput={setSearchInput}
|
onSearchInput={setSearchInput}
|
||||||
onClearSearch={() => setSearchInput('')}
|
onClearSearch={handleClearSearch}
|
||||||
onSearchCompositionStart={() => setSearchComposing(true)}
|
onSearchCompositionStart={handleSearchCompositionStart}
|
||||||
onSearchCompositionEnd={(value) => {
|
onSearchCompositionEnd={handleSearchCompositionEnd}
|
||||||
setSearchComposing(false);
|
onToggleSortMenu={handleToggleSortMenu}
|
||||||
setSearchInput(value);
|
onSelectSortMode={handleSelectSortMode}
|
||||||
}}
|
onSyncVault={handleSyncVault}
|
||||||
onToggleSortMenu={() => setSortMenuOpen((open) => !open)}
|
onOpenBulkDelete={handleOpenBulkDelete}
|
||||||
onSelectSortMode={(value) => {
|
onSelectDuplicates={handleSelectDuplicates}
|
||||||
setSortMode(value);
|
onSelectAll={handleSelectAll}
|
||||||
setSortMenuOpen(false);
|
onToggleCreateMenu={handleToggleCreateMenu}
|
||||||
}}
|
|
||||||
onSyncVault={() => void syncVault()}
|
|
||||||
onOpenBulkDelete={() => setBulkDeleteOpen(true)}
|
|
||||||
onSelectDuplicates={() => {
|
|
||||||
const map: Record<string, boolean> = {};
|
|
||||||
const seen = new Set<string>();
|
|
||||||
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<string, boolean> = {};
|
|
||||||
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
|
||||||
setSelectedMap(map);
|
|
||||||
}}
|
|
||||||
onToggleCreateMenu={() => setCreateMenuOpen((open) => !open)}
|
|
||||||
onStartCreate={startCreate}
|
onStartCreate={startCreate}
|
||||||
onBulkRestore={() => void confirmBulkRestore()}
|
onBulkRestore={handleBulkRestore}
|
||||||
onBulkArchive={() => setBulkArchiveOpen(true)}
|
onBulkArchive={handleBulkArchive}
|
||||||
onBulkUnarchive={() => void confirmBulkUnarchive()}
|
onBulkUnarchive={handleBulkUnarchive}
|
||||||
onOpenMove={() => {
|
onOpenMove={handleOpenMove}
|
||||||
setMoveFolderId('__none__');
|
onClearSelection={handleClearSelection}
|
||||||
setMoveOpen(true);
|
|
||||||
}}
|
|
||||||
onClearSelection={() => setSelectedMap({})}
|
|
||||||
onReorderCipher={handleReorderVaultCipher}
|
onReorderCipher={handleReorderVaultCipher}
|
||||||
onScroll={handleListScroll}
|
onScroll={handleListScroll}
|
||||||
onToggleSelected={(cipherId, checked) =>
|
onToggleSelected={handleToggleSelected}
|
||||||
setSelectedMap((prev) => {
|
onSelectCipher={handleSelectCipher}
|
||||||
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);
|
|
||||||
}}
|
|
||||||
listSubtitle={listSubtitle}
|
listSubtitle={listSubtitle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { JSX, RefObject } from 'preact';
|
import type { JSX, RefObject } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
import { createPortal } from 'preact/compat';
|
import { createPortal } from 'preact/compat';
|
||||||
import { useMemo, useState } from 'preact/hooks';
|
import { useMemo, useState } from 'preact/hooks';
|
||||||
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, GripVertical, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
|
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;
|
onSelectCipher?: (cipherId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CipherListItemBody(props: CipherListItemBodyProps) {
|
const CipherListItemBody = memo(function CipherListItemBody(props: CipherListItemBodyProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
@@ -143,12 +144,12 @@ function CipherListItemBody(props: CipherListItemBodyProps) {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
const animateLayoutChanges: AnimateLayoutChanges = (args) =>
|
const animateLayoutChanges: AnimateLayoutChanges = (args) =>
|
||||||
args.isSorting || args.wasDragging ? defaultAnimateLayoutChanges(args) : false;
|
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({
|
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: props.cipher.id,
|
id: props.cipher.id,
|
||||||
disabled: !props.canReorder,
|
disabled: !props.canReorder,
|
||||||
@@ -184,9 +185,9 @@ function SortableCipherListItem(props: SortableCipherListItemProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
function PlainCipherListItem(props: SortableCipherListItemProps) {
|
const PlainCipherListItem = memo(function PlainCipherListItem(props: SortableCipherListItemProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`list-item ${props.selected ? 'active' : ''}`}
|
className={`list-item ${props.selected ? 'active' : ''}`}
|
||||||
@@ -206,7 +207,7 @@ function PlainCipherListItem(props: SortableCipherListItemProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export default function VaultListPanel(props: VaultListPanelProps) {
|
export default function VaultListPanel(props: VaultListPanelProps) {
|
||||||
const [activeDragId, setActiveDragId] = useState('');
|
const [activeDragId, setActiveDragId] = useState('');
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ interface VaultSidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function VaultSidebar(props: VaultSidebarProps) {
|
export default function VaultSidebar(props: VaultSidebarProps) {
|
||||||
|
const nameCollator = useMemo(
|
||||||
|
() => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
|
||||||
|
[]
|
||||||
|
);
|
||||||
const sortedFolders = useMemo(() => {
|
const sortedFolders = useMemo(() => {
|
||||||
const sorted = [...props.folders];
|
const sorted = [...props.folders];
|
||||||
sorted.sort((a, b) => {
|
sorted.sort((a, b) => {
|
||||||
@@ -67,14 +71,14 @@ export default function VaultSidebar(props: VaultSidebarProps) {
|
|||||||
}
|
}
|
||||||
if (aValid !== bValid) return aValid ? -1 : 1;
|
if (aValid !== bValid) return aValid ? -1 : 1;
|
||||||
}
|
}
|
||||||
const nameDiff = String(a.decName || a.name || '').localeCompare(
|
const nameDiff = nameCollator.compare(
|
||||||
String(b.decName || b.name || ''), undefined, { sensitivity: 'base', numeric: true }
|
String(a.decName || a.name || ''), String(b.decName || b.name || '')
|
||||||
);
|
);
|
||||||
if (nameDiff !== 0) return nameDiff;
|
if (nameDiff !== 0) return nameDiff;
|
||||||
return String(a.id || '').localeCompare(String(b.id || ''));
|
return String(a.id || '').localeCompare(String(b.id || ''));
|
||||||
});
|
});
|
||||||
return sorted;
|
return sorted;
|
||||||
}, [props.folders, props.folderSortMode]);
|
}, [props.folders, props.folderSortMode, nameCollator]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`sidebar ${props.isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${props.isMobileLayout && props.mobileSidebarOpen ? 'open' : ''}`}>
|
<aside className={`sidebar ${props.isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${props.isMobileLayout && props.mobileSidebarOpen ? 'open' : ''}`}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import {
|
import {
|
||||||
CreditCard,
|
CreditCard,
|
||||||
FileKey2,
|
FileKey2,
|
||||||
@@ -436,44 +436,85 @@ export function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const failedIconHosts = new Set<string>();
|
const failedIconHosts = new Set<string>();
|
||||||
|
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
||||||
|
|
||||||
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
||||||
const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
|
const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
|
||||||
|
const iconStackRef = useRef<HTMLSpanElement | null>(null);
|
||||||
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
|
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [shouldLoad, setShouldLoad] = useState(() => !host);
|
||||||
const markIconError = () => {
|
const markIconError = () => {
|
||||||
if (host) failedIconHosts.add(host);
|
if (host) failedIconHosts.add(host);
|
||||||
setErrored(true);
|
setErrored(true);
|
||||||
};
|
};
|
||||||
const syncCachedIconState = (img: HTMLImageElement | null) => {
|
const hideFallback = () => {
|
||||||
if (!img || !img.complete) return;
|
const stack = iconStackRef.current;
|
||||||
if (img.naturalWidth > 0) {
|
if (stack) {
|
||||||
setLoaded(true);
|
const fallback = stack.querySelector('.list-icon-fallback') as HTMLElement | null;
|
||||||
return;
|
if (fallback) fallback.style.display = 'none';
|
||||||
}
|
}
|
||||||
markIconError();
|
|
||||||
};
|
};
|
||||||
|
const handleImgRef = (img: HTMLImageElement | null) => {
|
||||||
|
if (!img || !img.complete) return;
|
||||||
|
if (img.naturalWidth > 0) hideFallback();
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setErrored(host ? failedIconHosts.has(host) : false);
|
setErrored(host ? failedIconHosts.has(host) : false);
|
||||||
setLoaded(false);
|
setShouldLoad(!host);
|
||||||
|
// Reset fallback visibility so it shows while loading the new icon
|
||||||
|
const fallback = iconStackRef.current?.querySelector('.list-icon-fallback') as HTMLElement | null;
|
||||||
|
if (fallback) fallback.style.display = '';
|
||||||
}, [host]);
|
}, [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) {
|
if (host && !errored) {
|
||||||
return (
|
return (
|
||||||
<span className="list-icon-stack">
|
<span className="list-icon-stack" ref={iconStackRef}>
|
||||||
<span className={`list-icon-fallback ${loaded ? 'hidden' : ''}`}>
|
<span className="list-icon-fallback">
|
||||||
<Globe size={18} />
|
<Globe size={18} />
|
||||||
</span>
|
</span>
|
||||||
<img
|
{shouldLoad && (
|
||||||
className={`list-icon ${loaded ? 'loaded' : ''}`}
|
<img
|
||||||
src={websiteIconUrl(host)}
|
className="list-icon loaded"
|
||||||
alt=""
|
src={websiteIconUrl(host)}
|
||||||
loading="lazy"
|
alt=""
|
||||||
referrerPolicy="no-referrer"
|
loading="lazy"
|
||||||
ref={syncCachedIconState}
|
decoding="async"
|
||||||
onLoad={() => setLoaded(true)}
|
referrerPolicy="no-referrer"
|
||||||
onError={markIconError}
|
ref={handleImgRef}
|
||||||
/>
|
onLoad={hideFallback}
|
||||||
|
onError={markIconError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,49 @@ export function toBufferSource(bytes: Uint8Array): ArrayBuffer {
|
|||||||
return new Uint8Array(bytes).buffer;
|
return new Uint8Array(bytes).buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hmacSha256KeyCache = new WeakMap<Uint8Array, Promise<CryptoKey>>();
|
||||||
|
const aesCbcEncryptKeyCache = new WeakMap<Uint8Array, Promise<CryptoKey>>();
|
||||||
|
const aesCbcDecryptKeyCache = new WeakMap<Uint8Array, Promise<CryptoKey>>();
|
||||||
|
|
||||||
|
function getCachedCryptoKey(
|
||||||
|
cache: WeakMap<Uint8Array, Promise<CryptoKey>>,
|
||||||
|
keyBytes: Uint8Array,
|
||||||
|
create: () => Promise<CryptoKey>
|
||||||
|
): Promise<CryptoKey> {
|
||||||
|
const cached = cache.get(keyBytes);
|
||||||
|
if (cached) return cached;
|
||||||
|
const pending = create().catch((error) => {
|
||||||
|
cache.delete(keyBytes);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
cache.set(keyBytes, pending);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHmacSha256Key(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||||
|
return getCachedCryptoKey(
|
||||||
|
hmacSha256KeyCache,
|
||||||
|
keyBytes,
|
||||||
|
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAesCbcEncryptKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||||
|
return getCachedCryptoKey(
|
||||||
|
aesCbcEncryptKeyCache,
|
||||||
|
keyBytes,
|
||||||
|
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['encrypt'])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAesCbcDecryptKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||||
|
return getCachedCryptoKey(
|
||||||
|
aesCbcDecryptKeyCache,
|
||||||
|
keyBytes,
|
||||||
|
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['decrypt'])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
if (a.length !== b.length) return false;
|
if (a.length !== b.length) return false;
|
||||||
let diff = 0;
|
let diff = 0;
|
||||||
@@ -91,17 +134,17 @@ export async function hkdf(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise<Uint8Array> {
|
async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise<Uint8Array> {
|
||||||
const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
const key = await getHmacSha256Key(keyBytes);
|
||||||
return new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(dataBytes)));
|
return new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(dataBytes)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function encryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
|
async function encryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
|
||||||
const cryptoKey = await crypto.subtle.importKey('raw', toBufferSource(key), { name: 'AES-CBC' }, false, ['encrypt']);
|
const cryptoKey = await getAesCbcEncryptKey(key);
|
||||||
return new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
|
return new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function decryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
|
async function decryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
|
||||||
const cryptoKey = await crypto.subtle.importKey('raw', toBufferSource(key), { name: 'AES-CBC' }, false, ['decrypt']);
|
const cryptoKey = await getAesCbcDecryptKey(key);
|
||||||
return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
|
return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user