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 ( ); } 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"); } } 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 }) => ( )} } > {/* Select mode sort bar */} {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}
{(tag) => ( )}
{ listRef = el; setViewportHeight(el.clientHeight); }} onKeyDown={handleListKeydown} onScroll={handleListScroll} > 0} fallback={
no entries yet
} > ); }