atagen

malice

public
Star0
Watch0
f93c7e88ea050ba02b9dbe7a4beb0d60d99f6f37
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">
ba90983cec2eatagen2026-10-25 10:51 UTC2
  import { 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';
ba90983cec2eatagen2026-10-25 10:51 UTC5
  import { searchMessages } from './api';
ba90983cec2eatagen2026-10-25 10:51 UTC6
  import { formatDay } from './format';
ba90983cec2eatagen2026-10-25 10:51 UTC7
  import { groupByRoom } from './search-model';
ba90983cec2eatagen2026-10-25 10:51 UTC8
  import type { ScopeOption, SearchCoverage, SearchHit } 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[] = [];
ba90983cec2eatagen2026-10-25 10:51 UTC20
  let coverage: SearchCoverage[] = [];
ba90983cec2eatagen2026-10-25 10:51 UTC21
  let next: string | null = null;
ba90983cec2eatagen2026-10-25 10:51 UTC22
  let searched = '';
ba90983cec2eatagen2026-10-25 10:51 UTC23
  let busy = false;
ba90983cec2eatagen2026-10-25 10:51 UTC24
  let error = '';
ba90983cec2eatagen2026-10-25 10:51 UTC25
  let generation = 0;
ba90983cec2eatagen2026-10-25 10:51 UTC26
ba90983cec2eatagen2026-10-25 10:51 UTC27
  $: groups = groupByRoom(hits);
ba90983cec2eatagen2026-10-25 10:51 UTC28
  $: partial = coverage.filter((c) => !c.reached_start);
ba90983cec2eatagen2026-10-25 10:51 UTC29
  $: 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 UTC30
ba90983cec2eatagen2026-10-25 10:51 UTC31
  onMount(() => input?.focus());
ba90983cec2eatagen2026-10-25 10:51 UTC32
ba90983cec2eatagen2026-10-25 10:51 UTC33
  async function run(more = false): Promise<void> {
ba90983cec2eatagen2026-10-25 10:51 UTC34
    const text = query.trim();
ba90983cec2eatagen2026-10-25 10:51 UTC35
    if (!text) return;
ba90983cec2eatagen2026-10-25 10:51 UTC36
    const run = ++generation;
ba90983cec2eatagen2026-10-25 10:51 UTC37
    busy = true;
ba90983cec2eatagen2026-10-25 10:51 UTC38
    error = '';
ba90983cec2eatagen2026-10-25 10:51 UTC39
    try {
ba90983cec2eatagen2026-10-25 10:51 UTC40
      const page = await searchMessages(text, scopes[scopeIndex].scope, more ? next : null);
ba90983cec2eatagen2026-10-25 10:51 UTC41
      if (run !== generation) return;
ba90983cec2eatagen2026-10-25 10:51 UTC42
      hits = more ? [...hits, ...page.hits] : page.hits;
ba90983cec2eatagen2026-10-25 10:51 UTC43
      coverage = page.coverage;
ba90983cec2eatagen2026-10-25 10:51 UTC44
      next = page.next;
ba90983cec2eatagen2026-10-25 10:51 UTC45
      searched = text;
ba90983cec2eatagen2026-10-25 10:51 UTC46
    } catch (e) {
ba90983cec2eatagen2026-10-25 10:51 UTC47
      if (run !== generation) return;
ba90983cec2eatagen2026-10-25 10:51 UTC48
      if (!more) hits = [];
ba90983cec2eatagen2026-10-25 10:51 UTC49
      error = e instanceof Error ? e.message : 'Search failed';
ba90983cec2eatagen2026-10-25 10:51 UTC50
    } finally {
ba90983cec2eatagen2026-10-25 10:51 UTC51
      if (run === generation) busy = false;
ba90983cec2eatagen2026-10-25 10:51 UTC52
    }
ba90983cec2eatagen2026-10-25 10:51 UTC53
  }
ba90983cec2eatagen2026-10-25 10:51 UTC54
ba90983cec2eatagen2026-10-25 10:51 UTC55
  function pickScope(index: number): void {
ba90983cec2eatagen2026-10-25 10:51 UTC56
    scopeIndex = index;
ba90983cec2eatagen2026-10-25 10:51 UTC57
    if (searched) void run();
ba90983cec2eatagen2026-10-25 10:51 UTC58
  }
ba90983cec2eatagen2026-10-25 10:51 UTC59
</script>
ba90983cec2eatagen2026-10-25 10:51 UTC60
ba90983cec2eatagen2026-10-25 10:51 UTC61
<section class="search-view" aria-label="Search messages">
ba90983cec2eatagen2026-10-25 10:51 UTC62
  <form class="search-bar" on:submit|preventDefault={() => void run()}>
ba90983cec2eatagen2026-10-25 10:51 UTC63
    <input bind:this={input} type="search" bind:value={query} placeholder="Search messages" aria-label="Search messages" />
ba90983cec2eatagen2026-10-25 10:51 UTC64
    <button type="submit" class="primary-btn" disabled={busy || !query.trim()}>{busy && !hits.length ? 'Searching…' : 'Search'}</button>
ba90983cec2eatagen2026-10-25 10:51 UTC65
  </form>
ba90983cec2eatagen2026-10-25 10:51 UTC66
  <div class="scopes" role="radiogroup" aria-label="Search in">
ba90983cec2eatagen2026-10-25 10:51 UTC67
    {#each scopes as option, i (option.label)}
ba90983cec2eatagen2026-10-25 10:51 UTC68
      <button type="button" role="radio" aria-checked={i === scopeIndex} class:active={i === scopeIndex} on:click={() => pickScope(i)}>
ba90983cec2eatagen2026-10-25 10:51 UTC69
        <EmojiText text={option.label} />
ba90983cec2eatagen2026-10-25 10:51 UTC70
      </button>
ba90983cec2eatagen2026-10-25 10:51 UTC71
    {/each}
ba90983cec2eatagen2026-10-25 10:51 UTC72
  </div>
ba90983cec2eatagen2026-10-25 10:51 UTC73
  {#if error}
ba90983cec2eatagen2026-10-25 10:51 UTC74
    <p class="search-error" role="alert"><EmojiText text={error} /></p>
ba90983cec2eatagen2026-10-25 10:51 UTC75
  {/if}
ba90983cec2eatagen2026-10-25 10:51 UTC76
  <div class="results">
ba90983cec2eatagen2026-10-25 10:51 UTC77
    {#if searched && !busy && !error && hits.length === 0}
ba90983cec2eatagen2026-10-25 10:51 UTC78
      <p class="side-note">No messages match “<EmojiText text={searched} />”</p>
ba90983cec2eatagen2026-10-25 10:51 UTC79
    {/if}
ba90983cec2eatagen2026-10-25 10:51 UTC80
    {#each groups as group (group.room_id)}
ba90983cec2eatagen2026-10-25 10:51 UTC81
      <h3 class="result-room"><EmojiText text={roomName(group.room_id)} /></h3>
ba90983cec2eatagen2026-10-25 10:51 UTC82
      {#each group.hits as hit (hit.event_id)}
ba90983cec2eatagen2026-10-25 10:51 UTC83
        <div
ba90983cec2eatagen2026-10-25 10:51 UTC84
          class="result"
ba90983cec2eatagen2026-10-25 10:51 UTC85
          role="link"
ba90983cec2eatagen2026-10-25 10:51 UTC86
          tabindex="0"
ba90983cec2eatagen2026-10-25 10:51 UTC87
          aria-label={`Open message in ${roomName(hit.room_id)}`}
ba90983cec2eatagen2026-10-25 10:51 UTC88
          on:click={() => onOpen(hit.room_id, hit.event_id, hit.event.thread_root)}
ba90983cec2eatagen2026-10-25 10:51 UTC89
          on:keydown={(e) => e.key === 'Enter' && onOpen(hit.room_id, hit.event_id, hit.event.thread_root)}
ba90983cec2eatagen2026-10-25 10:51 UTC90
        >
ba90983cec2eatagen2026-10-25 10:51 UTC91
          <MessageRow itemId={`search-${hit.event_id}`} event={hit.event} showSender={true} reacting={false} onReact={() => {}} readOnly={true} showSent={false} {self} />
ba90983cec2eatagen2026-10-25 10:51 UTC92
        </div>
ba90983cec2eatagen2026-10-25 10:51 UTC93
      {/each}
ba90983cec2eatagen2026-10-25 10:51 UTC94
    {/each}
ba90983cec2eatagen2026-10-25 10:51 UTC95
    {#if next}
ba90983cec2eatagen2026-10-25 10:51 UTC96
      <button type="button" class="ghost-btn more" disabled={busy} on:click={() => void run(true)}>{busy ? 'Loading…' : 'More results'}</button>
ba90983cec2eatagen2026-10-25 10:51 UTC97
    {/if}
ba90983cec2eatagen2026-10-25 10:51 UTC98
    {#if searched && partial.length}
ba90983cec2eatagen2026-10-25 10:51 UTC99
      <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 UTC100
    {/if}
ba90983cec2eatagen2026-10-25 10:51 UTC101
  </div>
ba90983cec2eatagen2026-10-25 10:51 UTC102
</section>
ba90983cec2eatagen2026-10-25 10:51 UTC103
ba90983cec2eatagen2026-10-25 10:51 UTC104
<style>
ba90983cec2eatagen2026-10-25 10:51 UTC105
  .search-view { display: flex; flex-direction: column; min-height: 0; height: 100%; }
ba90983cec2eatagen2026-10-25 10:51 UTC106
  .search-bar { display: flex; gap: 8px; padding: 12px 16px 8px; }
ba90983cec2eatagen2026-10-25 10:51 UTC107
  .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 UTC108
  .search-bar input:focus { outline: none; border-color: var(--accent); }
ba90983cec2eatagen2026-10-25 10:51 UTC109
  .scopes { display: flex; gap: 6px; padding: 0 16px 10px; border-bottom: 1px solid var(--border); }
ba90983cec2eatagen2026-10-25 10:51 UTC110
  .scopes button { 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 UTC111
  .scopes button:hover { color: var(--text); }
ba90983cec2eatagen2026-10-25 10:51 UTC112
  .scopes button.active { color: var(--text); border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
ba90983cec2eatagen2026-10-25 10:51 UTC113
  .search-error { margin: 10px 16px 0; color: var(--danger); font-size: 13px; }
ba90983cec2eatagen2026-10-25 10:51 UTC114
  .results { flex: 1; min-height: 0; overflow-y: auto; padding: 8px 16px 16px; }
ba90983cec2eatagen2026-10-25 10:51 UTC115
  .result-room { margin: 14px 0 4px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
ba90983cec2eatagen2026-10-25 10:51 UTC116
  .result { border-radius: var(--r-md); cursor: pointer; }
ba90983cec2eatagen2026-10-25 10:51 UTC117
  .result:hover { background: var(--hover-faint); }
ba90983cec2eatagen2026-10-25 10:51 UTC118
  .result:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
ba90983cec2eatagen2026-10-25 10:51 UTC119
  .more { display: block; margin: 12px auto 0; }
ba90983cec2eatagen2026-10-25 10:51 UTC120
  .coverage { margin: 14px 0 0; text-align: center; font-size: 12px; color: var(--muted); }
ba90983cec2eatagen2026-10-25 10:51 UTC121
</style>