import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; import type { VaultEntry } from "../../shared/types"; import { getDisplayDomain } from "../../shared/domain"; import { faviconUrl, faviconImgSig, globeFaviconSig, titleInitials, titleBackground } from "../../shared/favicon"; import { popupCopy } from "../copy"; import { filterAndSortVaultEntries, entryMatchesDomain, VAULT_SORT_OPTIONS, type SortOrder, } from "./vault-search"; export type { SortOrder } from "./vault-search"; function vibrate(ms: number) { try { navigator.vibrate?.(ms); } catch { /* unsupported */ } } function FaviconIcon(props: { domain: string; title?: string; isNote: boolean; isPasskey: boolean }) { const src = props.domain && !props.isNote ? faviconUrl(props.domain) : null; const [isReal, setIsReal] = createSignal(false); // Show InitialsChip (or note icon) first — no globe flash. Passkeys use the // same coloured initials / real favicon as logins; the row's "passkey" text // badge still distinguishes them. // Load the favicon invisibly in the background; switch to it only after // confirming it differs from Chrome's default "no favicon" globe image. return ( {props.isNote ? : } {src && ( {}} onLoad={async (e) => { const img = e.currentTarget as HTMLImageElement; // Old Chrome returns the globe at native 16×16 even when // size=32 is requested — treat as no favicon. if (img.naturalWidth <= 16) return; // New Chrome returns the globe scaled to the requested size. // Compare pixel data against the known "no favicon" reference. const sig = faviconImgSig(img); if (sig !== null) { const gSig = await globeFaviconSig(); if (gSig !== null && sig === gSig) return; } setIsReal(true); }} /> )} > } > ); } function InitialsChip(props: { title: string }) { return ( {titleInitials(props.title)} ); } const NoteIcon = () => ( ); interface VaultProps { entries: VaultEntry[]; selectedEntryId?: string; currentTabDomain?: string; sortOrder: SortOrder; searchSeed?: string; searchSeedNonce?: number; onSelectEntry: (entry: VaultEntry) => void; onSortOrderChange: (order: SortOrder) => void; onAdd: () => void; onClose: () => void; onOpenTrash?: () => void; onDeleteMany?: (ids: string[]) => Promise; } export function VaultView(props: VaultProps) { const [search, setSearch] = createSignal(""); const [selectedTags, setSelectedTags] = createSignal>(new Set()); const [notesOnly, setNotesOnly] = createSignal(false); const [selecting, setSelecting] = createSignal(false); const [selectedIds, setSelectedIds] = createSignal>(new Set()); const [deleting, setDeleting] = createSignal(false); const [contextEntry, setContextEntry] = createSignal(null); const [flashId, setFlashId] = createSignal(null); const [scrollTop, setScrollTop] = createSignal(0); const [viewportHeight, setViewportHeight] = createSignal(600); let longPressTimer: number | null = null; let touchStart: { x: number; y: number; t: number } | null = null; let suppressNextClick = false; function clearLongPressTimer() { if (longPressTimer !== null) { window.clearTimeout(longPressTimer); longPressTimer = null; } } function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) { if (selecting()) return; const t = e.touches[0]; if (!t) return; touchStart = { x: t.clientX, y: t.clientY, t: Date.now() }; clearLongPressTimer(); longPressTimer = window.setTimeout(() => { suppressNextClick = true; // Touch-and-hold = multi-select. Pre-select the row so the action bar // lights up immediately. The context menu still opens on desktop // right-click (and on mobile if the browser synthesises its own long- // press contextmenu event — that path is filtered by `touchStart` so // both don't fire on a single gesture). enterSelectMode(); setSelectedIds(new Set([entry.id])); vibrate(20); longPressTimer = null; }, 500); } function handleRowTouchMove(e: TouchEvent) { if (!touchStart) return; const t = e.touches[0]; if (!t) return; const dx = Math.abs(t.clientX - touchStart.x); const dy = Math.abs(t.clientY - touchStart.y); if (dx > 8 || dy > 8) clearLongPressTimer(); } async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) { clearLongPressTimer(); if (!touchStart) return; const t = e.changedTouches[0]; if (!t) { touchStart = null; return; } const dx = t.clientX - touchStart.x; const dy = Math.abs(t.clientY - touchStart.y); const dt = Date.now() - touchStart.t; touchStart = null; // Right-swipe-to-copy: only on compact + login entries with a password const isCompact = !document.body.classList.contains("layout-medium") && !document.body.classList.contains("layout-expanded"); if ( isCompact && !selecting() && dx > 80 && dy < 30 && dt < 400 && entry.type === "login" && entry.password ) { suppressNextClick = true; vibrate(15); setFlashId(entry.id); window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600); await popupCopy( entry.password, "password", entry.canary?.webhookUrl ? { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title } : undefined, ); } } let searchRef: HTMLInputElement | undefined; let listRef: HTMLDivElement | undefined; const focusSearch = (el: HTMLInputElement) => { searchRef = el; setTimeout(() => el.focus(), 50); }; createEffect(() => { void props.searchSeedNonce; const seed = props.searchSeed; if (!seed) return; setSearch(seed); requestAnimationFrame(() => { searchRef?.focus(); searchRef?.setSelectionRange(seed.length, seed.length); }); }); function enterSelectMode() { setSelecting(true); setSelectedIds(new Set()); } function exitSelectMode() { setSelecting(false); setSelectedIds(new Set()); } function toggleId(id: string) { const s = new Set(selectedIds()); if (s.has(id)) s.delete(id); else s.add(id); setSelectedIds(s); } const allTags = createMemo(() => { const tagSet = new Set(); for (const entry of props.entries) { for (const tag of entry.tags ?? []) { tagSet.add(tag); } } return Array.from(tagSet).sort(); }); const hasTags = createMemo(() => allTags().length > 0); function toggleTag(tag: string) { const current = new Set(selectedTags()); if (current.has(tag)) { current.delete(tag); } else { current.add(tag); } setSelectedTags(current); } const filteredEntries = createMemo(() => { return filterAndSortVaultEntries(props.entries, { notesOnly: notesOnly(), selectedTags: selectedTags(), query: search(), searchScope: "both", sortOrder: props.sortOrder, currentTabDomain: props.currentTabDomain, }); }); // Declaration order matters: `allVisibleIds` and `allSelected` read // `filteredEntries` eagerly on component mount via createMemo. Moving // `filteredEntries` below them throws ReferenceError under the production // minifier and freezes the vault list on mount (v1.4.34 regression). const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id)); const allSelected = createMemo(() => allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id)) ); function toggleSelectAll() { if (allSelected()) { setSelectedIds(new Set()); } else { setSelectedIds(new Set(allVisibleIds())); } } async function handleDeleteSelected() { const ids = Array.from(selectedIds()); if (!ids.length || !props.onDeleteMany) return; setDeleting(true); await props.onDeleteMany(ids); setDeleting(false); exitSelectMode(); } // Virtualization: under VIRTUAL_THRESHOLD entries we render the full list. // Above it, we render only a window around the visible viewport plus a // BUFFER_ROWS-tall buffer above and below so scroll never reveals empty // space. Row height is approximate (variable padding for badges) but // bounded — the buffer absorbs over/undershoot. const VIRTUAL_THRESHOLD = 100; const ROW_HEIGHT = 56; const BUFFER_ROWS = 20; const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD); const visibleRange = createMemo(() => { const total = filteredEntries().length; if (!isVirtualized()) return { start: 0, end: total }; const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS); const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS); return { start, end }; }); const windowedEntries = createMemo(() => { const { start, end } = visibleRange(); return filteredEntries().slice(start, end); }); function handleListScroll(e: Event) { if (!isVirtualized()) return; const target = e.currentTarget as HTMLDivElement; setScrollTop(target.scrollTop); setViewportHeight(target.clientHeight); } function handleSearchKeydown(e: KeyboardEvent) { if (e.key === "ArrowDown") { e.preventDefault(); const firstBtn = listRef?.querySelector(".peach-entry-row"); firstBtn?.focus(); } else if (e.key === "ArrowUp") { e.preventDefault(); const allBtns = listRef?.querySelectorAll(".peach-entry-row"); allBtns?.[allBtns.length - 1]?.focus(); } else if (e.key === "Enter") { e.preventDefault(); const firstEntry = filteredEntries()[0]; if (firstEntry) props.onSelectEntry(firstEntry); } else if (e.key === "Escape" && selecting()) { exitSelectMode(); } } function handleListKeydown(e: KeyboardEvent) { if (!listRef) return; const btns = Array.from(listRef.querySelectorAll(".peach-entry-row")); const idx = btns.indexOf(document.activeElement as HTMLButtonElement); if (e.key === "ArrowDown") { e.preventDefault(); if (idx < btns.length - 1) btns[idx + 1]?.focus(); } else if (e.key === "ArrowUp") { e.preventDefault(); if (idx === 0) { searchRef?.focus(); } else btns[idx - 1]?.focus(); } else if (e.key === "Escape") { if (selecting()) exitSelectMode(); else props.onClose(); } } return ( setSearch((e.currentTarget as HTMLInputElement).value)} onKeyDown={handleSearchKeydown} /> {search() || selectedTags().size > 0 ? `${filteredEntries().length} of ${props.entries.length}` : `${props.entries.length} entries`} {/* Sort bar — morphs into select-mode controls */} {({ key, label }) => ( props.onSortOrderChange(key)} > {label} )} setNotesOnly(v => !v)} aria-label="Notes only" title="Show notes only" > > } > {/* Select mode sort bar */} cancel {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"} {allSelected() ? "none" : "all"} {(tag) => ( toggleTag(tag)} > {tag} )} { listRef = el; setViewportHeight(el.clientHeight); }} onKeyDown={handleListKeydown} onScroll={handleListScroll} > 0} fallback={ no entries yet } > {(entry, localIndex) => { const index = () => visibleRange().start + localIndex(); const isNote = entry.type === "note"; const isPasskey = entry.passkey !== undefined; const domain = entry.type === "note" ? (entry.notes?.slice(0, 40) ?? "note") : entry.url ? getDisplayDomain(entry.url) : entry.passkey?.rpId ? entry.passkey.rpId : "saved entry"; const isTabMatch = () => props.currentTabDomain !== undefined && props.currentTabDomain !== "" && entryMatchesDomain(entry, props.currentTabDomain); const isSelected = () => selectedIds().has(entry.id); const ariaLabel = (() => { const tag = isPasskey ? "passkey" : isNote ? "note" : "login"; const sub = entry.username || domain || ""; return sub ? `${entry.title} — ${sub} (${tag})` : `${entry.title} (${tag})`; })(); return ( handleRowTouchStart(entry, e)} onTouchMove={handleRowTouchMove} onTouchEnd={(e) => void handleRowTouchEnd(entry, e)} onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }} onContextMenu={(e) => { e.preventDefault(); // A real touch long-press already entered multi-select // via the 500ms timer; the browser's synthesised // contextmenu event would otherwise reopen the menu and // undo the gesture. Desktop right-click has no `touchStart` // to consume, so it still opens the menu. if (touchStart !== null) { touchStart = null; return; } if (!selecting()) setContextEntry(entry); }} onClick={() => { if (suppressNextClick) { suppressNextClick = false; return; } if (selecting()) { toggleId(entry.id); } else { props.onSelectEntry(entry); } }} > } > {/* Check indicator */} {entry.title} passkey ● {domain} 🪤 ); }} {/* Bottom bar — new entry normally, delete action in select mode */} new entry } > void handleDeleteSelected()} > 0} fallback={select entries to delete} > {deleting() ? "moving to trash…" : `move ${selectedIds().size} to trash`} setContextEntry(null)} role="presentation" > e.stopPropagation()} > { const e = contextEntry(); if (!e?.username) return; await popupCopy(e.username, "username"); setContextEntry(null); }} > Copy username { const e = contextEntry(); if (!e?.password) return; await popupCopy(e.password, "password"); setContextEntry(null); }} > Copy password { const e = contextEntry(); if (!e?.totp) return; await popupCopy(e.totp, "TOTP secret"); setContextEntry(null); }} > Copy TOTP secret { const e = contextEntry(); if (e) props.onSelectEntry(e); setContextEntry(null); }} > Open { const e = contextEntry(); if (!e) return; setContextEntry(null); await props.onDeleteMany?.([e.id]); }} > Move to trash setContextEntry(null)} > Cancel ); }