Vault.tsx

26.1 KB · raw

1import { createEffect, createMemo, createSignal, For, Show } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { getDisplayDomain } from "../../shared/domain";4import { faviconUrl, faviconImgSig, globeFaviconSig, titleInitials, titleBackground } from "../../shared/favicon";5import { popupCopy } from "../copy";6import {7  filterAndSortVaultEntries,8  entryMatchesDomain,9  VAULT_SORT_OPTIONS,10  type SortOrder,11} from "./vault-search";1213export type { SortOrder } from "./vault-search";1415function vibrate(ms: number) {16  try { navigator.vibrate?.(ms); } catch { /* unsupported */ }17}1819function FaviconIcon(props: { domain: string; title?: string; isNote: boolean; isPasskey: boolean }) {20  const src = props.domain && !props.isNote21    ? faviconUrl(props.domain)22    : null;23  const [isReal, setIsReal] = createSignal(false);2425  // Show InitialsChip (or note icon) first — no globe flash. Passkeys use the26  // same coloured initials / real favicon as logins; the row's "passkey" text27  // badge still distinguishes them.28  // Load the favicon invisibly in the background; switch to it only after29  // confirming it differs from Chrome's default "no favicon" globe image.30  return (31    <Show32      when={isReal()}33      fallback={34        <>35          {props.isNote36            ? <NoteIcon />37            : <InitialsChip title={props.title ?? props.domain ?? ""} />}38          {src && (39            <img40              src={src}41              style="display:none"42              onError={() => {}}43              onLoad={async (e) => {44                const img = e.currentTarget as HTMLImageElement;45                // Old Chrome returns the globe at native 16×16 even when46                // size=32 is requested — treat as no favicon.47                if (img.naturalWidth <= 16) return;48                // New Chrome returns the globe scaled to the requested size.49                // Compare pixel data against the known "no favicon" reference.50                const sig = faviconImgSig(img);51                if (sig !== null) {52                  const gSig = await globeFaviconSig();53                  if (gSig !== null && sig === gSig) return;54                }55                setIsReal(true);56              }}57            />58          )}59        </>60      }61    >62      <img src={src!} width="16" height="16" alt="" style="border-radius:3px;" />63    </Show>64  );65}666768function InitialsChip(props: { title: string }) {69  return (70    <span71      class="peach-favicon-initials"72      style={{ background: titleBackground(props.title) }}73      aria-hidden="true"74    >75      {titleInitials(props.title)}76    </span>77  );78}798081const NoteIcon = () => (82  <svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" aria-hidden="true">83    <rect x="1.5" y="1.5" width="8" height="9" rx="1.5" />84    <path d="M3.5 4.5h4M3.5 6.5h4M3.5 8.5h2" />85  </svg>86);8788interface VaultProps {89  entries: VaultEntry[];90  selectedEntryId?: string;91  currentTabDomain?: string;92  sortOrder: SortOrder;93  searchSeed?: string;94  searchSeedNonce?: number;95  onSelectEntry: (entry: VaultEntry) => void;96  onSortOrderChange: (order: SortOrder) => void;97  onAdd: () => void;98  onClose: () => void;99  onOpenTrash?: () => void;100  onDeleteMany?: (ids: string[]) => Promise<void>;101}102103export function VaultView(props: VaultProps) {104  const [search, setSearch] = createSignal("");105  const [selectedTags, setSelectedTags] = createSignal<Set<string>>(new Set());106  const [notesOnly, setNotesOnly] = createSignal(false);107  const [selecting, setSelecting] = createSignal(false);108  const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());109  const [deleting, setDeleting] = createSignal(false);110  const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);111  const [flashId, setFlashId] = createSignal<string | null>(null);112  const [scrollTop, setScrollTop] = createSignal(0);113  const [viewportHeight, setViewportHeight] = createSignal(600);114  let longPressTimer: number | null = null;115  let touchStart: { x: number; y: number; t: number } | null = null;116  let suppressNextClick = false;117118  function clearLongPressTimer() {119    if (longPressTimer !== null) {120      window.clearTimeout(longPressTimer);121      longPressTimer = null;122    }123  }124125  function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {126    if (selecting()) return;127    const t = e.touches[0];128    if (!t) return;129    touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };130    clearLongPressTimer();131    longPressTimer = window.setTimeout(() => {132      suppressNextClick = true;133      // Touch-and-hold = multi-select. Pre-select the row so the action bar134      // lights up immediately. The context menu still opens on desktop135      // right-click (and on mobile if the browser synthesises its own long-136      // press contextmenu event — that path is filtered by `touchStart` so137      // both don't fire on a single gesture).138      enterSelectMode();139      setSelectedIds(new Set([entry.id]));140      vibrate(20);141      longPressTimer = null;142    }, 500);143  }144145  function handleRowTouchMove(e: TouchEvent) {146    if (!touchStart) return;147    const t = e.touches[0];148    if (!t) return;149    const dx = Math.abs(t.clientX - touchStart.x);150    const dy = Math.abs(t.clientY - touchStart.y);151    if (dx > 8 || dy > 8) clearLongPressTimer();152  }153154  async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {155    clearLongPressTimer();156    if (!touchStart) return;157    const t = e.changedTouches[0];158    if (!t) { touchStart = null; return; }159    const dx = t.clientX - touchStart.x;160    const dy = Math.abs(t.clientY - touchStart.y);161    const dt = Date.now() - touchStart.t;162    touchStart = null;163    // Right-swipe-to-copy: only on compact + login entries with a password164    const isCompact = !document.body.classList.contains("layout-medium")165      && !document.body.classList.contains("layout-expanded");166    if (167      isCompact168      && !selecting()169      && dx > 80170      && dy < 30171      && dt < 400172      && entry.type === "login"173      && entry.password174    ) {175      suppressNextClick = true;176      vibrate(15);177      setFlashId(entry.id);178      window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);179      await popupCopy(entry.password, "password");180    }181  }182183  let searchRef: HTMLInputElement | undefined;184  let listRef: HTMLDivElement | undefined;185186  const focusSearch = (el: HTMLInputElement) => {187    searchRef = el;188    setTimeout(() => el.focus(), 50);189  };190191  createEffect(() => {192    void props.searchSeedNonce;193    const seed = props.searchSeed;194    if (!seed) return;195    setSearch(seed);196    requestAnimationFrame(() => {197      searchRef?.focus();198      searchRef?.setSelectionRange(seed.length, seed.length);199    });200  });201202  function enterSelectMode() {203    setSelecting(true);204    setSelectedIds(new Set<string>());205  }206207  function exitSelectMode() {208    setSelecting(false);209    setSelectedIds(new Set<string>());210  }211212  function toggleId(id: string) {213    const s = new Set<string>(selectedIds());214    if (s.has(id)) s.delete(id); else s.add(id);215    setSelectedIds(s);216  }217218  const allTags = createMemo(() => {219    const tagSet = new Set<string>();220    for (const entry of props.entries) {221      for (const tag of entry.tags ?? []) {222        tagSet.add(tag);223      }224    }225    return Array.from(tagSet).sort();226  });227228  const hasTags = createMemo(() => allTags().length > 0);229230  function toggleTag(tag: string) {231    const current = new Set(selectedTags());232    if (current.has(tag)) {233      current.delete(tag);234    } else {235      current.add(tag);236    }237    setSelectedTags(current);238  }239240  const filteredEntries = createMemo(() => {241    return filterAndSortVaultEntries(props.entries, {242      notesOnly: notesOnly(),243      selectedTags: selectedTags(),244      query: search(),245      searchScope: "both",246      sortOrder: props.sortOrder,247      currentTabDomain: props.currentTabDomain,248    });249  });250251  // Declaration order matters: `allVisibleIds` and `allSelected` read252  // `filteredEntries` eagerly on component mount via createMemo. Moving253  // `filteredEntries` below them throws ReferenceError under the production254  // minifier and freezes the vault list on mount (v1.4.34 regression).255  const allVisibleIds = createMemo(() => filteredEntries().map(e => e.id));256  const allSelected = createMemo(() =>257    allVisibleIds().length > 0 && allVisibleIds().every(id => selectedIds().has(id))258  );259260  function toggleSelectAll() {261    if (allSelected()) {262      setSelectedIds(new Set<string>());263    } else {264      setSelectedIds(new Set<string>(allVisibleIds()));265    }266  }267268  async function handleDeleteSelected() {269    const ids = Array.from(selectedIds());270    if (!ids.length || !props.onDeleteMany) return;271    setDeleting(true);272    await props.onDeleteMany(ids);273    setDeleting(false);274    exitSelectMode();275  }276277  // Virtualization: under VIRTUAL_THRESHOLD entries we render the full list.278  // Above it, we render only a window around the visible viewport plus a279  // BUFFER_ROWS-tall buffer above and below so scroll never reveals empty280  // space. Row height is approximate (variable padding for badges) but281  // bounded — the buffer absorbs over/undershoot.282  const VIRTUAL_THRESHOLD = 100;283  const ROW_HEIGHT = 56;284  const BUFFER_ROWS = 20;285286  const isVirtualized = createMemo(() => filteredEntries().length > VIRTUAL_THRESHOLD);287288  const visibleRange = createMemo(() => {289    const total = filteredEntries().length;290    if (!isVirtualized()) return { start: 0, end: total };291    const start = Math.max(0, Math.floor(scrollTop() / ROW_HEIGHT) - BUFFER_ROWS);292    const end = Math.min(total, Math.ceil((scrollTop() + viewportHeight()) / ROW_HEIGHT) + BUFFER_ROWS);293    return { start, end };294  });295296  const windowedEntries = createMemo(() => {297    const { start, end } = visibleRange();298    return filteredEntries().slice(start, end);299  });300301  function handleListScroll(e: Event) {302    if (!isVirtualized()) return;303    const target = e.currentTarget as HTMLDivElement;304    setScrollTop(target.scrollTop);305    setViewportHeight(target.clientHeight);306  }307308  function handleSearchKeydown(e: KeyboardEvent) {309    if (e.key === "ArrowDown") {310      e.preventDefault();311      const firstBtn = listRef?.querySelector<HTMLButtonElement>(".peach-entry-row");312      firstBtn?.focus();313    } else if (e.key === "ArrowUp") {314      e.preventDefault();315      const allBtns = listRef?.querySelectorAll<HTMLButtonElement>(".peach-entry-row");316      allBtns?.[allBtns.length - 1]?.focus();317    } else if (e.key === "Enter") {318      e.preventDefault();319      const firstEntry = filteredEntries()[0];320      if (firstEntry) props.onSelectEntry(firstEntry);321    } else if (e.key === "Escape" && selecting()) {322      exitSelectMode();323    }324  }325326  function handleListKeydown(e: KeyboardEvent) {327    if (!listRef) return;328    const btns = Array.from(listRef.querySelectorAll<HTMLButtonElement>(".peach-entry-row"));329    const idx = btns.indexOf(document.activeElement as HTMLButtonElement);330    if (e.key === "ArrowDown") {331      e.preventDefault();332      if (idx < btns.length - 1) btns[idx + 1]?.focus();333    } else if (e.key === "ArrowUp") {334      e.preventDefault();335      if (idx === 0) { searchRef?.focus(); }336      else btns[idx - 1]?.focus();337    } else if (e.key === "Escape") {338      if (selecting()) exitSelectMode();339      else props.onClose();340    }341  }342343  return (344    <div class="peach-overlay-view ov-enter-up" style="display:flex">345      <div class="peach-list-top">346        <button347          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"348          onClick={props.onClose}349          aria-label="Back"350        >351          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">352            <path d="M6 1L1 6L6 11" />353          </svg>354        </button>355        <input356          class="peach-search-input"357          placeholder="search vault..."358          autocomplete="off"359          style="font-size:14px"360          ref={focusSearch}361          value={search()}362          onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}363          onKeyDown={handleSearchKeydown}364        />365        <span class="peach-search-counter peach-search-counter-list">366          {search() || selectedTags().size > 0367            ? `${filteredEntries().length} of ${props.entries.length}`368            : `${props.entries.length} entries`}369        </span>370      </div>371372      {/* Sort bar — morphs into select-mode controls */}373      <div class="peach-sort-bar">374        <Show375          when={selecting()}376          fallback={377            <>378              <For each={VAULT_SORT_OPTIONS}>379                {({ key, label }) => (380                  <button381                    class={`peach-sort-btn${props.sortOrder === key ? " is-active" : ""}`}382                    onClick={() => props.onSortOrderChange(key)}383                  >384                    {label}385                  </button>386                )}387              </For>388              <span style="flex:1" />389              <button390                class={`peach-sort-trash-btn${notesOnly() ? " is-active" : ""}`}391                onClick={() => setNotesOnly(v => !v)}392                aria-label="Notes only"393                title="Show notes only"394              >395                <NoteIcon />396              </button>397              <Show when={props.onOpenTrash}>398                <button399                  class="peach-sort-trash-btn"400                  onClick={props.onOpenTrash}401                  aria-label="View trash"402                  title="View deleted entries"403                >404                  <svg width="11" height="12" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">405                    <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />406                  </svg>407                </button>408              </Show>409              <Show when={props.onDeleteMany}>410                <button411                  class="peach-sort-trash-btn"412                  onClick={enterSelectMode}413                  aria-label="Select entries"414                  title="Select entries"415                >416                  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">417                    <circle cx="4" cy="4" r="2.5" />418                    <circle cx="9" cy="4" r="2.5" />419                    <circle cx="4" cy="9" r="2.5" />420                    <circle cx="9" cy="9" r="2.5" />421                  </svg>422                </button>423              </Show>424            </>425          }426        >427          {/* Select mode sort bar */}428          <button429            class="peach-select-cancel-btn"430            onClick={exitSelectMode}431          >432            cancel433          </button>434          <span class="peach-select-count-label">435            {selectedIds().size > 0 ? `${selectedIds().size} selected` : "select entries"}436          </span>437          <button438            class="peach-select-all-btn"439            onClick={toggleSelectAll}440          >441            {allSelected() ? "none" : "all"}442          </button>443        </Show>444      </div>445446      <Show when={hasTags() && !selecting()}>447        <div class="peach-tag-filter-strip" style="display:flex;gap:6px;padding:4px 12px 6px;overflow-x:auto;flex-wrap:nowrap;scrollbar-width:none;">448          <For each={allTags()}>449            {(tag) => (450              <button451                class={`peach-tag-filter-chip ${selectedTags().has(tag) ? "is-selected" : ""}`}452                onClick={() => toggleTag(tag)}453              >454                {tag}455              </button>456            )}457          </For>458        </div>459      </Show>460461      <div462        class={`peach-vault-list${selecting() ? " is-selecting" : ""}`}463        ref={(el) => { listRef = el; setViewportHeight(el.clientHeight); }}464        onKeyDown={handleListKeydown}465        onScroll={handleListScroll}466      >467        <Show468          when={filteredEntries().length > 0}469          fallback={470            <div class="peach-list-empty">471              <span>no entries yet</span>472            </div>473          }474        >475          <Show when={isVirtualized()}>476            <div style={{ height: `${visibleRange().start * ROW_HEIGHT}px` }} aria-hidden="true" />477          </Show>478          <For each={windowedEntries()}>479            {(entry, localIndex) => {480              const index = () => visibleRange().start + localIndex();481              const isNote = entry.type === "note";482              const isPasskey = entry.passkey !== undefined;483              const domain = entry.type === "note"484                ? (entry.notes?.slice(0, 40) ?? "note")485                : entry.url486                  ? getDisplayDomain(entry.url)487                  : entry.passkey?.rpId488                    ? entry.passkey.rpId489                  : "saved entry";490              const isTabMatch = () =>491                props.currentTabDomain !== undefined &&492                props.currentTabDomain !== "" &&493                entryMatchesDomain(entry, props.currentTabDomain);494              const isSelected = () => selectedIds().has(entry.id);495496              const ariaLabel = (() => {497                const tag = isPasskey ? "passkey" : isNote ? "note" : "login";498                const sub = entry.username || domain || "";499                return sub500                  ? `${entry.title} — ${sub} (${tag})`501                  : `${entry.title} (${tag})`;502              })();503              return (504                <button505                  class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}${flashId() === entry.id ? " is-copy-flash" : ""}`}506                  style={isVirtualized()507                    ? { animation: "none" }508                    : { "animation-delay": `${Math.min(index(), 24) * 20}ms` }509                  }510                  aria-label={ariaLabel}511                  aria-pressed={selecting() ? isSelected() : undefined}512                  onTouchStart={(e) => handleRowTouchStart(entry, e)}513                  onTouchMove={handleRowTouchMove}514                  onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}515                  onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}516                  onContextMenu={(e) => {517                    e.preventDefault();518                    // A real touch long-press already entered multi-select519                    // via the 500ms timer; the browser's synthesised520                    // contextmenu event would otherwise reopen the menu and521                    // undo the gesture. Desktop right-click has no `touchStart`522                    // to consume, so it still opens the menu.523                    if (touchStart !== null) { touchStart = null; return; }524                    if (!selecting()) setContextEntry(entry);525                  }}526                  onClick={() => {527                    if (suppressNextClick) { suppressNextClick = false; return; }528                    if (selecting()) {529                      toggleId(entry.id);530                    } else {531                      props.onSelectEntry(entry);532                    }533                  }}534                >535                  <div class={`peach-entry-row-icon${!selecting() && entry.breach ? " is-breach" : ""}${selecting() ? " is-select-icon" : ""}`}>536                    <Show537                      when={selecting()}538                      fallback={539                        <FaviconIcon540                          domain={entry.type === "note" ? "" : (entry.url ? getDisplayDomain(entry.url) : entry.passkey?.rpId ?? "")}541                          title={entry.title}542                          isNote={isNote}543                          isPasskey={isPasskey}544                        />545                      }546                    >547                      {/* Check indicator */}548                      <svg width="16" height="16" viewBox="0 0 16 16" fill="none">549                        <circle550                          cx="8" cy="8" r="7"551                          stroke="currentColor"552                          stroke-width="1"553                          fill={isSelected() ? "var(--peach-accent)" : "transparent"}554                          opacity={isSelected() ? "1" : "0.3"}555                        />556                        <Show when={isSelected()}>557                          <path558                            d="M5 8.5L7 10.5L11 6"559                            stroke="white"560                            stroke-width="1.2"561                            stroke-linecap="round"562                            stroke-linejoin="round"563                          />564                        </Show>565                      </svg>566                    </Show>567                  </div>568                  <div class="peach-entry-row-info">569                    <span class="peach-entry-row-name">570                      {entry.title}571                      <Show when={isPasskey && !selecting()}>572                        <span class="peach-passkey-badge">passkey</span>573                      </Show>574                      <Show when={isTabMatch() && !selecting()}>575                        <span class="peach-tab-match-badge" title="Matches current tab">●</span>576                      </Show>577                    </span>578                    <span class="peach-entry-row-meta">{domain}</span>579                  </div>580                  <Show when={entry.breach && !selecting()}>581                    <span class="peach-entry-row-dot" aria-label="breach"></span>582                  </Show>583                  <Show when={entry.canary && !selecting()}>584                    <span class="peach-entry-row-canary" title={`Canary: ${entry.canary?.label ?? "canary entry"}`}>🪤</span>585                  </Show>586                </button>587              );588            }}589          </For>590          <Show when={isVirtualized()}>591            <div592              style={{ height: `${(filteredEntries().length - visibleRange().end) * ROW_HEIGHT}px` }}593              aria-hidden="true"594            />595          </Show>596        </Show>597      </div>598599      {/* Bottom bar — new entry normally, delete action in select mode */}600      <Show601        when={selecting()}602        fallback={603          <button class="peach-new-entry-row" onClick={props.onAdd}>604            <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round">605              <path d="M5 1v8M1 5h8" />606            </svg>607            new entry608          </button>609        }610      >611        <button612          class={`peach-select-action-bar${selectedIds().size === 0 ? " is-empty" : ""}`}613          disabled={selectedIds().size === 0 || deleting()}614          onClick={() => void handleDeleteSelected()}615        >616          <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">617            <path d="M1 3h10M4 3V2h4v1M2 3l.7 8.5a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9L10 3" />618          </svg>619          <Show620            when={selectedIds().size > 0}621            fallback={<span>select entries to delete</span>}622          >623            <span>624              {deleting()625                ? "moving to trash…"626                : `move ${selectedIds().size} to trash`}627            </span>628          </Show>629        </button>630      </Show>631632      <Show when={contextEntry() !== null}>633        <div634          class="peach-context-menu-backdrop"635          onClick={() => setContextEntry(null)}636          role="presentation"637        >638          <div639            class="peach-context-menu"640            role="menu"641            aria-label="Entry actions"642            onClick={(e) => e.stopPropagation()}643          >644            <Show when={contextEntry()!.username}>645              <button646                role="menuitem"647                class="peach-context-menu-item"648                onClick={async () => {649                  const e = contextEntry();650                  if (!e?.username) return;651                  await popupCopy(e.username, "username");652                  setContextEntry(null);653                }}654              >655                Copy username656              </button>657            </Show>658            <Show when={contextEntry()!.password}>659              <button660                role="menuitem"661                class="peach-context-menu-item"662                onClick={async () => {663                  const e = contextEntry();664                  if (!e?.password) return;665                  await popupCopy(e.password, "password");666                  setContextEntry(null);667                }}668              >669                Copy password670              </button>671            </Show>672            <Show when={contextEntry()!.totp}>673              <button674                role="menuitem"675                class="peach-context-menu-item"676                onClick={async () => {677                  const e = contextEntry();678                  if (!e?.totp) return;679                  await popupCopy(e.totp, "TOTP secret");680                  setContextEntry(null);681                }}682              >683                Copy TOTP secret684              </button>685            </Show>686            <button687              role="menuitem"688              class="peach-context-menu-item"689              onClick={() => {690                const e = contextEntry();691                if (e) props.onSelectEntry(e);692                setContextEntry(null);693              }}694            >695              Open696            </button>697            <Show when={props.onDeleteMany}>698              <button699                role="menuitem"700                class="peach-context-menu-item peach-context-menu-item--danger"701                onClick={async () => {702                  const e = contextEntry();703                  if (!e) return;704                  setContextEntry(null);705                  await props.onDeleteMany?.([e.id]);706                }}707              >708                Move to trash709              </button>710            </Show>711            <button712              role="menuitem"713              class="peach-context-menu-item peach-context-menu-item--cancel"714              onClick={() => setContextEntry(null)}715            >716              Cancel717            </button>718          </div>719        </div>720      </Show>721    </div>722  );723}