import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; import type { VaultEntry } from "../../shared/types"; import { extractDomain, getDisplayDomain } from "../../shared/domain"; import { faviconUrl } from "../../shared/favicon"; import { popupCopy } from "../copy"; 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 && !props.isPasskey ? faviconUrl(props.domain) : null; // Note/passkey entries keep their semantic glyphs. Login entries get a // colored-initials chip when the real favicon hasn't loaded — better than // a black square on freshly-imported entries the browser hasn't visited // yet (Chrome's `_favicon/` cache is empty for unvisited domains). const fallback = props.isPasskey ? : props.isNote ? : ; const [failed, setFailed] = createSignal(false); return ( setFailed(true)} /> ); } /** * Title → "PM"-style initials. First letters of the first two words, * falling back to the first two characters of a single-word title. Empty * titles get a small dot. Deterministic so the same entry always renders * the same chip. */ function titleInitials(source: string): string { const cleaned = source.replace(/^https?:\/\//, "").replace(/^www\./, "").trim(); if (!cleaned) return "·"; const words = cleaned.split(/[\s.\-_/]+/).filter(Boolean); if (words.length >= 2 && words[0]![0] && words[1]![0]) { return (words[0]![0]! + words[1]![0]!).toUpperCase(); } const single = words[0] ?? cleaned; return single.slice(0, 2).toUpperCase(); } /** * Hash a string to an HSL background. Fixed saturation + lightness so the * palette stays muted and dark enough that white initials read clearly on * top, while still giving each entry a recognisable colour signature. */ function titleBackground(source: string): string { let hash = 0; for (let i = 0; i < source.length; i++) { hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0; } const hue = ((hash % 360) + 360) % 360; return `hsl(${hue}, 32%, 26%)`; } function InitialsChip(props: { title: string }) { return ( {titleInitials(props.title)} ); } const PasskeyIcon = () => ( ); const NoteIcon = () => ( ); export type SortOrder = "url-first" | "az" | "za" | "recent"; 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; vibrate(20); setContextEntry(entry); 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); } function fuzzyMatch(query: string, target: string): boolean { query = query.toLowerCase(); target = target.toLowerCase(); let qi = 0; for (let i = 0; i < target.length && qi < query.length; i++) { if (target[i] === query[qi]) qi++; } return qi === query.length; } function isExactPrefix(query: string, target: string): boolean { return target.toLowerCase().startsWith(query.toLowerCase()); } function entryMatchesDomain(entry: VaultEntry, domain: string): boolean { if (!domain) return false; if (entry.passkey?.rpId === domain) return true; if (entry.type !== "login") return false; // Tab domain is the eTLD+1 from extractDomain — compare against eTLD+1 of // each entry URL too so subdomain entries also match. if (entry.url && extractDomain(entry.url) === domain) return true; for (const extra of entry.additionalUrls ?? []) { if (extractDomain(extra) === domain) return true; } return false; } const filteredEntries = createMemo(() => { let result = props.entries; if (notesOnly()) { result = result.filter(entry => entry.type === "note"); } if (selectedTags().size > 0) { result = result.filter(entry => Array.from(selectedTags()).every(tag => entry.tags?.includes(tag)) ); } const q = search().trim(); if (q) { const searchableFor = (e: VaultEntry): string => { if (e.type === "note") return `${e.title} ${e.notes ?? ""}`; const passkeyText = e.passkey ? ` ${e.passkey.rpId} ${e.passkey.userName} ${e.passkey.userDisplayName}` : ""; return `${e.title} ${e.url ?? ""} ${e.username ?? ""}${passkeyText}`; }; result = result .filter((entry) => fuzzyMatch(q, searchableFor(entry))) .sort((a, b) => { const aSearchable = searchableFor(a).toLowerCase(); const bSearchable = searchableFor(b).toLowerCase(); const qLower = q.toLowerCase(); const aExact = isExactPrefix(qLower, aSearchable); const bExact = isExactPrefix(qLower, bSearchable); if (aExact && !bExact) return -1; if (!aExact && bExact) return 1; return 0; }); return result; } const tabDomain = props.currentTabDomain ?? ""; const order = props.sortOrder; if (order === "az") { return [...result].sort((a, b) => a.title.localeCompare(b.title)); } if (order === "za") { return [...result].sort((a, b) => b.title.localeCompare(a.title)); } if (order === "recent") { return [...result].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)); } if (tabDomain) { return [...result].sort((a, b) => { const aMatch = entryMatchesDomain(a, tabDomain) ? 0 : 1; const bMatch = entryMatchesDomain(b, tabDomain) ? 0 : 1; return aMatch - bMatch; }); } return result; }); // 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(); } } const sortLabels: { key: SortOrder; label: string }[] = [ { key: "url-first", label: "smart" }, { key: "az", label: "A–Z" }, { key: "za", label: "Z–A" }, { key: "recent", label: "newest" }, ]; 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(); 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 ); }