atagen

malice

public
Star0
Watch0
afba1feb41cebde51e0dd74fa483474fbf4d06ac
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git

Blame: web/src/lib/SearchView.svelte

ba90983cec2eatagen2026-10-25 10:51 UTC1
<script lang="ts">
249e439c27e5atagen2026-10-25 13:26 UTC2
  import { onDestroy, onMount } from 'svelte';
ba90983cec2eatagen2026-10-25 10:51 UTC3
  import EmojiText from './EmojiText.svelte';
ba90983cec2eatagen2026-10-25 10:51 UTC4
  import MessageRow from './MessageRow.svelte';
249e439c27e5atagen2026-10-25 13:26 UTC5
  import { closeSearch, searchMessages, searchMore } from './api';
ba90983cec2eatagen2026-10-25 10:51 UTC6
  import { formatDay } from './format';
ba90983cec2eatagen2026-10-25 10:51 UTC7
  import { groupByRoom } from './search-model';
249e439c27e5atagen2026-10-25 13:26 UTC8
  import type { ScopeOption, SearchCoverage, SearchHit, SearchPage } from './search-model';
ba90983cec2eatagen2026-10-25 10:51 UTC9
ba90983cec2eatagen2026-10-25 10:51 UTC10
  export let scopes: ScopeOption[];
ba90983cec2eatagen2026-10-25 10:51 UTC11
  export let initialScope = 0;
ba90983cec2eatagen2026-10-25 10:51 UTC12
  export let self: string | null = null;
ba90983cec2eatagen2026-10-25 10:51 UTC13
  export let roomName: (roomId: string) => string;
ba90983cec2eatagen2026-10-25 10:51 UTC14
  export let onOpen: (roomId: string, eventId: string, threadRoot: string | null) => void;
ba90983cec2eatagen2026-10-25 10:51 UTC15
ba90983cec2eatagen2026-10-25 10:51 UTC16
  let input: HTMLInputElement | null = null;
ba90983cec2eatagen2026-10-25 10:51 UTC17
  let query = '';
ba90983cec2eatagen2026-10-25 10:51 UTC18
  let scopeIndex = Math.min(initialScope, scopes.length - 1);
ba90983cec2eatagen2026-10-25 10:51 UTC19
  let hits: SearchHit[] = [];
249e439c27e5atagen2026-10-25 13:26 UTC20
  let coverage: SearchCoverage | null = null;
ba90983cec2eatagen2026-10-25 10:51 UTC21
  let next: string | null = null;
249e439c27e5atagen2026-10-25 13:26 UTC22
  let searchId: string | null = null;
ba90983cec2eatagen2026-10-25 10:51 UTC23
  let searched = '';
ba90983cec2eatagen2026-10-25 10:51 UTC24
  let busy = false;
ba90983cec2eatagen2026-10-25 10:51 UTC25
  let error = '';
ba90983cec2eatagen2026-10-25 10:51 UTC26
  let generation = 0;
ba90983cec2eatagen2026-10-25 10:51 UTC27
ba90983cec2eatagen2026-10-25 10:51 UTC28
  $: groups = groupByRoom(hits);
249e439c27e5atagen2026-10-25 13:26 UTC29
  $: partial = coverage?.rooms.filter((c) => !c.start_reached) ?? [];
ba90983cec2eatagen2026-10-25 10:51 UTC30
  $: oldest = partial.reduce<number | null>((min, c) => (c.oldest_indexed_ms === null ? min : min === null ? c.oldest_indexed_ms : Math.max(min, c.oldest_indexed_ms)), null);
ba90983cec2eatagen2026-10-25 10:51 UTC31
ba90983cec2eatagen2026-10-25 10:51 UTC32
  onMount(() => input?.focus());
249e439c27e5atagen2026-10-25 13:26 UTC33
  onDestroy(() => dropSearch());
249e439c27e5atagen2026-10-25 13:26 UTC34
249e439c27e5atagen2026-10-25 13:26 UTC35
  function dropSearch(): void {
249e439c27e5atagen2026-10-25 13:26 UTC36
    if (searchId) void closeSearch(searchId).catch(() => undefined);
249e439c27e5atagen2026-10-25 13:26 UTC37
    searchId = null;
249e439c27e5atagen2026-10-25 13:26 UTC38
  }
ba90983cec2eatagen2026-10-25 10:51 UTC39
ba90983cec2eatagen2026-10-25 10:51 UTC40
  async function run(more = false): Promise<void> {
ba90983cec2eatagen2026-10-25 10:51 UTC41
    const text = query.trim();
ba90983cec2eatagen2026-10-25 10:51 UTC42
    if (!text) return;
ba90983cec2eatagen2026-10-25 10:51 UTC43
    const run = ++generation;
ba90983cec2eatagen2026-10-25 10:51 UTC44
    busy = true;
ba90983cec2eatagen2026-10-25 10:51 UTC45
    error = '';
ba90983cec2eatagen2026-10-25 10:51 UTC46
    try {
249e439c27e5atagen2026-10-25 13:26 UTC47
      let page: SearchPage;
249e439c27e5atagen2026-10-25 13:26 UTC48
      if (more && searchId && next) {
249e439c27e5atagen2026-10-25 13:26 UTC49
        page = await searchMore(searchId, next);
249e439c27e5atagen2026-10-25 13:26 UTC50
      } else {
249e439c27e5atagen2026-10-25 13:26 UTC51
        const session = await searchMessages(text, scopes[scopeIndex].scope);
249e439c27e5atagen2026-10-25 13:26 UTC52
        if (run !== generation) {
249e439c27e5atagen2026-10-25 13:26 UTC53
          void closeSearch(session.search_id).catch(() => undefined);
249e439c27e5atagen2026-10-25 13:26 UTC54
          return;
249e439c27e5atagen2026-10-25 13:26 UTC55
        }
249e439c27e5atagen2026-10-25 13:26 UTC56
        dropSearch();
249e439c27e5atagen2026-10-25 13:26 UTC57
        searchId = session.search_id;
249e439c27e5atagen2026-10-25 13:26 UTC58
        page = session.page;
249e439c27e5atagen2026-10-25 13:26 UTC59
      }
ba90983cec2eatagen2026-10-25 10:51 UTC60
      if (run !== generation) return;
ba90983cec2eatagen2026-10-25 10:51 UTC61
      hits = more ? [...hits, ...page.hits] : page.hits;
ba90983cec2eatagen2026-10-25 10:51 UTC62
      coverage = page.coverage;
ba90983cec2eatagen2026-10-25 10:51 UTC63
      next = page.next;
ba90983cec2eatagen2026-10-25 10:51 UTC64
      searched = text;
ba90983cec2eatagen2026-10-25 10:51 UTC65
    } catch (e) {
ba90983cec2eatagen2026-10-25 10:51 UTC66
      if (run !== generation) return;
ba90983cec2eatagen2026-10-25 10:51 UTC67
      if (!more) hits = [];
ba90983cec2eatagen2026-10-25 10:51 UTC68
      error = e instanceof Error ? e.message : 'Search failed';
ba90983cec2eatagen2026-10-25 10:51 UTC69
    } finally {
ba90983cec2eatagen2026-10-25 10:51 UTC70
      if (run === generation) busy = false;
ba90983cec2eatagen2026-10-25 10:51 UTC71
    }
ba90983cec2eatagen2026-10-25 10:51 UTC72
  }
ba90983cec2eatagen2026-10-25 10:51 UTC73
ba90983cec2eatagen2026-10-25 10:51 UTC74
  function pickScope(index: number): void {
ba90983cec2eatagen2026-10-25 10:51 UTC75
    scopeIndex = index;
ba90983cec2eatagen2026-10-25 10:51 UTC76
    if (searched) void run();
ba90983cec2eatagen2026-10-25 10:51 UTC77
  }
ba90983cec2eatagen2026-10-25 10:51 UTC78
</script>
ba90983cec2eatagen2026-10-25 10:51 UTC79
ba90983cec2eatagen2026-10-25 10:51 UTC80
<section class="search-view" aria-label="Search messages">
ba90983cec2eatagen2026-10-25 10:51 UTC81
  <form class="search-bar" on:submit|preventDefault={() => void run()}>
ba90983cec2eatagen2026-10-25 10:51 UTC82
    <input bind:this={input} type="search" bind:value={query} placeholder="Search messages" aria-label="Search messages" />
ba90983cec2eatagen2026-10-25 10:51 UTC83
    <button type="submit" class="primary-btn" disabled={busy || !query.trim()}>{busy && !hits.length ? 'Searching…' : 'Search'}</button>
ba90983cec2eatagen2026-10-25 10:51 UTC84
  </form>
ba90983cec2eatagen2026-10-25 10:51 UTC85
  <div class="scopes" role="radiogroup" aria-label="Search in">
ba90983cec2eatagen2026-10-25 10:51 UTC86
    {#each scopes as option, i (option.label)}
ba90983cec2eatagen2026-10-25 10:51 UTC87
      <button type="button" role="radio" aria-checked={i === scopeIndex} class:active={i === scopeIndex} on:click={() => pickScope(i)}>
ba90983cec2eatagen2026-10-25 10:51 UTC88
        <EmojiText text={option.label} />
ba90983cec2eatagen2026-10-25 10:51 UTC89
      </button>
ba90983cec2eatagen2026-10-25 10:51 UTC90
    {/each}
ba90983cec2eatagen2026-10-25 10:51 UTC91
  </div>
ba90983cec2eatagen2026-10-25 10:51 UTC92
  {#if error}
ba90983cec2eatagen2026-10-25 10:51 UTC93
    <p class="search-error" role="alert"><EmojiText text={error} /></p>
ba90983cec2eatagen2026-10-25 10:51 UTC94
  {/if}
ba90983cec2eatagen2026-10-25 10:51 UTC95
  <div class="results">
ba90983cec2eatagen2026-10-25 10:51 UTC96
    {#if searched && !busy && !error && hits.length === 0}
ba90983cec2eatagen2026-10-25 10:51 UTC97
      <p class="side-note">No messages match “<EmojiText text={searched} />”</p>
ba90983cec2eatagen2026-10-25 10:51 UTC98
    {/if}
ba90983cec2eatagen2026-10-25 10:51 UTC99
    {#each groups as group (group.room_id)}
ba90983cec2eatagen2026-10-25 10:51 UTC100
      <h3 class="result-room"><EmojiText text={roomName(group.room_id)} /></h3>
ba90983cec2eatagen2026-10-25 10:51 UTC101
      {#each group.hits as hit (hit.event_id)}
ba90983cec2eatagen2026-10-25 10:51 UTC102
        <div
ba90983cec2eatagen2026-10-25 10:51 UTC103
          class="result"
ba90983cec2eatagen2026-10-25 10:51 UTC104
          role="link"
ba90983cec2eatagen2026-10-25 10:51 UTC105
          tabindex="0"
ba90983cec2eatagen2026-10-25 10:51 UTC106
          aria-label={`Open message in ${roomName(hit.room_id)}`}
ba90983cec2eatagen2026-10-25 10:51 UTC107
          on:click={() => onOpen(hit.room_id, hit.event_id, hit.event.thread_root)}
ba90983cec2eatagen2026-10-25 10:51 UTC108
          on:keydown={(e) => e.key === 'Enter' && onOpen(hit.room_id, hit.event_id, hit.event.thread_root)}
ba90983cec2eatagen2026-10-25 10:51 UTC109
        >
ba90983cec2eatagen2026-10-25 10:51 UTC110
          <MessageRow itemId={`search-${hit.event_id}`} event={hit.event} showSender={true} reacting={false} onReact={() => {}} readOnly={true} showSent={false} {self} />
ba90983cec2eatagen2026-10-25 10:51 UTC111
        </div>
ba90983cec2eatagen2026-10-25 10:51 UTC112
      {/each}
ba90983cec2eatagen2026-10-25 10:51 UTC113
    {/each}
ba90983cec2eatagen2026-10-25 10:51 UTC114
    {#if next}
ba90983cec2eatagen2026-10-25 10:51 UTC115
      <button type="button" class="ghost-btn more" disabled={busy} on:click={() => void run(true)}>{busy ? 'Loading…' : 'More results'}</button>
ba90983cec2eatagen2026-10-25 10:51 UTC116
    {/if}
ba90983cec2eatagen2026-10-25 10:51 UTC117
    {#if searched && partial.length}
ba90983cec2eatagen2026-10-25 10:51 UTC118
      <p class="coverage">Searched back to {oldest ? formatDay(oldest) : 'recent history'} in {partial.length === 1 ? 'one room' : `${partial.length} rooms`}</p>
ba90983cec2eatagen2026-10-25 10:51 UTC119
    {/if}
ba90983cec2eatagen2026-10-25 10:51 UTC120
  </div>
ba90983cec2eatagen2026-10-25 10:51 UTC121
</section>
ba90983cec2eatagen2026-10-25 10:51 UTC122
ba90983cec2eatagen2026-10-25 10:51 UTC123
<style>
ba90983cec2eatagen2026-10-25 10:51 UTC124
  .search-view { display: flex; flex-direction: column; min-height: 0; height: 100%; }
ba90983cec2eatagen2026-10-25 10:51 UTC125
  .search-bar { display: flex; gap: 8px; padding: 12px 16px 8px; }
ba90983cec2eatagen2026-10-25 10:51 UTC126
  .search-bar input { flex: 1; min-width: 0; background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--r-md); color: var(--text); padding: 8px 10px; font: inherit; }
ba90983cec2eatagen2026-10-25 10:51 UTC127
  .search-bar input:focus { outline: none; border-color: var(--accent); }
ba90983cec2eatagen2026-10-25 10:51 UTC128
  .scopes { display: flex; gap: 6px; padding: 0 16px 10px; border-bottom: 1px solid var(--border); }
20fe182f3204atagen2026-10-26 05:50 UTC129
  .scopes button { text-align: left; border: 1px solid var(--border); background: transparent; color: var(--muted); border-radius: var(--r-sm); padding: 3px 10px; font: inherit; font-size: 12.5px; cursor: pointer; transition: color var(--pop), border-color var(--pop), background var(--pop); }
ba90983cec2eatagen2026-10-25 10:51 UTC130
  .scopes button:hover { color: var(--text); }
ba90983cec2eatagen2026-10-25 10:51 UTC131
  .scopes button.active { color: var(--text); border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
ba90983cec2eatagen2026-10-25 10:51 UTC132
  .search-error { margin: 10px 16px 0; color: var(--danger); font-size: 13px; }
ba90983cec2eatagen2026-10-25 10:51 UTC133
  .results { flex: 1; min-height: 0; overflow-y: auto; padding: 8px 16px 16px; }
ba90983cec2eatagen2026-10-25 10:51 UTC134
  .result-room { margin: 14px 0 4px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
ba90983cec2eatagen2026-10-25 10:51 UTC135
  .result { border-radius: var(--r-md); cursor: pointer; }
ba90983cec2eatagen2026-10-25 10:51 UTC136
  .result:hover { background: var(--hover-faint); }
ba90983cec2eatagen2026-10-25 10:51 UTC137
  .result:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
ba90983cec2eatagen2026-10-25 10:51 UTC138
  .more { display: block; margin: 12px auto 0; }
ba90983cec2eatagen2026-10-25 10:51 UTC139
  .coverage { margin: 14px 0 0; text-align: center; font-size: 12px; color: var(--muted); }
ba90983cec2eatagen2026-10-25 10:51 UTC140
</style>