Files
flotilla/src/app/components/RoomSearch.svelte
T
2026-06-24 14:44:26 -07:00

175 lines
4.8 KiB
Svelte

<script lang="ts">
import {onMount, tick} from "svelte"
import {get} from "svelte/store"
import {debounce} from "throttle-debounce"
import {load} from "@welshman/net"
import {groupBy, uniqBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
import type {TrustedEvent, Filter} from "@welshman/util"
import {MESSAGE, sortEventsDesc, displayRelayUrl} from "@welshman/util"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
import {CONTENT_KINDS} from "@app/content"
import {deriveEventsForUrlDesc} from "@app/repository"
import {popModal} from "@app/modal"
import {goToEvent} from "@app/routes"
type Props = {
url: string
h: string
}
const {url, h}: Props = $props()
let term = $state("")
let results = $state<TrustedEvent[]>([])
let loading = $state(false)
let input: HTMLInputElement | undefined = $state()
let controller: AbortController | undefined
const doSearch = debounce(300, async (searchTerm: string, controller: AbortController) => {
if (!searchTerm?.trim()) {
loading = false
results = []
return
}
const filter: Filter = {
kinds: [MESSAGE, ...CONTENT_KINDS],
"#h": [h],
search: searchTerm.trim(),
}
results = get(deriveEventsForUrlDesc(url, [filter]))
try {
const events = await load({
relays: [url],
signal: controller.signal,
filters: [filter],
})
results = sortEventsDesc(uniqBy((e: TrustedEvent) => e.id, [...events, ...results]))
} catch (error) {
// Ignore aborts from superseded searches; surface anything else
if (!(error instanceof DOMException && error.name === "AbortError")) {
throw error
}
} finally {
loading = false
}
})
const onInput = () => {
loading = true
controller?.abort()
controller = new AbortController()
doSearch(term, controller)
}
const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results))
const getAgeSection = (createdAt: number) => {
const age = now() - createdAt
if (age <= DAY) {
return "day"
}
if (age <= WEEK) {
return "week"
}
return "older"
}
const getAgeLabel = (createdAt: number) => {
const age = now() - createdAt
if (age < MINUTE) {
return "Just now"
}
if (age < HOUR) {
return `${Math.floor(age / MINUTE)}m ago`
}
if (age < DAY) {
return `${Math.floor(age / HOUR)}h ago`
}
return `${Math.floor(age / DAY)}d ago`
}
const onResultClick = (event: TrustedEvent) => {
popModal()
goToEvent(event, {keepFocus: true})
}
onMount(() => {
tick().then(() => input?.focus())
return () => controller?.abort()
})
</script>
<Modal class="col-2">
<ModalHeader>
<ModalTitle>Search Content</ModalTitle>
<ModalSubtitle>
on <span class="text-primary">{displayRelayUrl(url)}</span>
</ModalSubtitle>
</ModalHeader>
<ModalBody>
<label class="input input-sm input-bordered flex w-full items-center gap-2">
<Icon size={4} icon={Magnifier} />
<input
bind:this={input}
bind:value={term}
class="min-w-0 grow"
type="text"
placeholder="Search this room..."
oninput={onInput} />
</label>
{#if loading}
<Spinner {loading}>Searching...</Spinner>
{:else if eventsByAge.size === 0 && term}
<Spinner {loading}>No results found.</Spinner>
{:else}
{#each eventsByAge as [key, events] (key)}
<p class="text-xs uppercase tracking-wide opacity-60">
{#if key === "day"}
Last 24 Hours
{:else if key === "week"}
Last 7 Days
{:else}
Older
{/if}
</p>
{#each events as event (event.id)}
<Button
class="card2 card2-sm card2-interactive col-2"
onclick={() => onResultClick(event)}>
<NoteCard minimal {event}>
<NoteContentMinimal {event} />
</NoteCard>
<div class="row-2">
<div class="badge badge-sm badge-neutral">
{getAgeLabel(event.created_at)}
</div>
</div>
</Button>
{/each}
{/each}
{/if}
</ModalBody>
</Modal>