2026-02-16 17:03:00 +00:00
|
|
|
<script lang="ts">
|
2026-06-24 20:10:06 +00:00
|
|
|
import {onMount, tick} from "svelte"
|
|
|
|
|
import {get} from "svelte/store"
|
2026-04-02 18:49:18 +00:00
|
|
|
import {debounce} from "throttle-debounce"
|
2026-06-24 20:10:06 +00:00
|
|
|
import {load} from "@welshman/net"
|
|
|
|
|
import {groupBy, uniqBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
|
2026-04-02 18:49:18 +00:00
|
|
|
import type {TrustedEvent, Filter} from "@welshman/util"
|
2026-06-24 20:10:06 +00:00
|
|
|
import {MESSAGE, getTagValue, sortEventsDesc, displayRelayUrl} from "@welshman/util"
|
2026-02-16 17:03:00 +00:00
|
|
|
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
|
|
|
|
import Icon from "@lib/components/Icon.svelte"
|
2026-06-24 20:10:06 +00:00
|
|
|
import Spinner from "@lib/components/Spinner.svelte"
|
|
|
|
|
import Button from "@lib/components/Button.svelte"
|
2026-06-24 20:56:53 +00:00
|
|
|
import Badge from "@lib/components/Badge.svelte"
|
2026-06-24 20:10:06 +00:00
|
|
|
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 RoomName from "@app/components/RoomName.svelte"
|
|
|
|
|
import NoteCard from "@app/components/NoteCard.svelte"
|
|
|
|
|
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
2026-06-08 22:33:38 +00:00
|
|
|
import {CONTENT_KINDS} from "@app/content"
|
2026-06-24 20:10:06 +00:00
|
|
|
import {deriveEventsForUrlDesc} from "@app/repository"
|
|
|
|
|
import {popModal} from "@app/modal"
|
2026-06-08 21:14:40 +00:00
|
|
|
import {goToEvent} from "@app/routes"
|
2026-02-16 17:03:00 +00:00
|
|
|
|
|
|
|
|
type Props = {
|
|
|
|
|
url: string
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 20:10:06 +00:00
|
|
|
const {url}: Props = $props()
|
2026-02-16 17:03:00 +00:00
|
|
|
|
|
|
|
|
let term = $state("")
|
2026-04-02 18:49:18 +00:00
|
|
|
let results = $state<TrustedEvent[]>([])
|
|
|
|
|
let loading = $state(false)
|
2026-02-16 17:03:00 +00:00
|
|
|
let input: HTMLInputElement | undefined = $state()
|
2026-04-02 18:49:18 +00:00
|
|
|
let controller: AbortController | undefined
|
|
|
|
|
|
2026-06-24 20:10:06 +00:00
|
|
|
const doSearch = debounce(300, async (searchTerm: string, controller: AbortController) => {
|
|
|
|
|
if (!searchTerm?.trim()) {
|
2026-04-02 18:49:18 +00:00
|
|
|
loading = false
|
|
|
|
|
results = []
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 20:10:06 +00:00
|
|
|
const filter: Filter = {
|
|
|
|
|
kinds: [MESSAGE, ...CONTENT_KINDS],
|
|
|
|
|
search: searchTerm.trim(),
|
|
|
|
|
}
|
2026-04-28 21:29:33 +00:00
|
|
|
|
2026-06-24 20:10:06 +00:00
|
|
|
results = get(deriveEventsForUrlDesc(url, [filter]))
|
2026-04-28 21:29:33 +00:00
|
|
|
|
2026-04-02 18:49:18 +00:00
|
|
|
try {
|
2026-06-24 20:10:06 +00:00
|
|
|
const events = await load({
|
|
|
|
|
relays: [url],
|
2026-04-02 18:49:18 +00:00
|
|
|
signal: controller.signal,
|
2026-04-28 21:29:33 +00:00
|
|
|
filters: [filter],
|
2026-04-02 18:49:18 +00:00
|
|
|
})
|
|
|
|
|
|
2026-06-24 20:10:06 +00:00
|
|
|
results = sortEventsDesc(uniqBy((e: TrustedEvent) => e.id, [...events, ...results]))
|
2026-04-02 18:49:18 +00:00
|
|
|
} catch (error) {
|
2026-06-24 20:10:06 +00:00
|
|
|
// Ignore aborts from superseded searches; surface anything else
|
2026-04-02 18:49:18 +00:00
|
|
|
if (!(error instanceof DOMException && error.name === "AbortError")) {
|
2026-06-24 20:10:06 +00:00
|
|
|
throw error
|
2026-04-02 18:49:18 +00:00
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
loading = false
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const onInput = () => {
|
2026-06-24 20:10:06 +00:00
|
|
|
loading = true
|
|
|
|
|
controller?.abort()
|
|
|
|
|
controller = new AbortController()
|
|
|
|
|
doSearch(term, controller)
|
2026-04-02 18:49:18 +00:00
|
|
|
}
|
2026-02-16 17:03:00 +00:00
|
|
|
|
|
|
|
|
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`
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 20:10:06 +00:00
|
|
|
const onResultClick = (event: TrustedEvent) => {
|
|
|
|
|
popModal()
|
2026-02-16 19:34:46 +00:00
|
|
|
goToEvent(event, {keepFocus: true})
|
2026-02-16 17:03:00 +00:00
|
|
|
}
|
2026-06-24 20:10:06 +00:00
|
|
|
|
|
|
|
|
onMount(() => {
|
|
|
|
|
tick().then(() => input?.focus())
|
|
|
|
|
|
|
|
|
|
return () => controller?.abort()
|
|
|
|
|
})
|
2026-02-16 17:03:00 +00:00
|
|
|
</script>
|
|
|
|
|
|
2026-06-24 20:56:53 +00:00
|
|
|
<Modal class="flex flex-col gap-2">
|
2026-06-24 20:10:06 +00:00
|
|
|
<ModalHeader>
|
|
|
|
|
<ModalTitle>Search Content</ModalTitle>
|
|
|
|
|
<ModalSubtitle>
|
|
|
|
|
on <span class="text-primary">{displayRelayUrl(url)}</span>
|
|
|
|
|
</ModalSubtitle>
|
|
|
|
|
</ModalHeader>
|
|
|
|
|
<ModalBody>
|
2026-06-24 20:56:53 +00:00
|
|
|
<label class="input input-sm input-group flex w-full items-center gap-2">
|
2026-06-24 20:10:06 +00:00
|
|
|
<Icon size={4} icon={Magnifier} />
|
|
|
|
|
<input
|
|
|
|
|
bind:this={input}
|
|
|
|
|
bind:value={term}
|
|
|
|
|
class="min-w-0 grow"
|
|
|
|
|
type="text"
|
|
|
|
|
placeholder="Search this space..."
|
|
|
|
|
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)}
|
|
|
|
|
{@const h = getTagValue("h", event.tags)}
|
|
|
|
|
<Button
|
2026-06-24 20:56:53 +00:00
|
|
|
class="card card-sm card-interactive flex flex-col gap-2"
|
2026-06-24 20:10:06 +00:00
|
|
|
onclick={() => onResultClick(event)}>
|
|
|
|
|
<NoteCard minimal {event}>
|
|
|
|
|
<NoteContentMinimal {event} />
|
|
|
|
|
</NoteCard>
|
2026-06-24 20:56:53 +00:00
|
|
|
<div class="flex gap-2">
|
|
|
|
|
<Badge variant="neutral">
|
2026-06-24 20:10:06 +00:00
|
|
|
{getAgeLabel(event.created_at)}
|
2026-06-24 20:56:53 +00:00
|
|
|
</Badge>
|
2026-06-24 20:10:06 +00:00
|
|
|
{#if h}
|
2026-06-24 20:56:53 +00:00
|
|
|
<Badge variant="neutral">
|
2026-06-24 20:10:06 +00:00
|
|
|
<RoomName {url} {h} />
|
2026-06-24 20:56:53 +00:00
|
|
|
</Badge>
|
2026-06-24 20:10:06 +00:00
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</Button>
|
|
|
|
|
{/each}
|
|
|
|
|
{/each}
|
|
|
|
|
{/if}
|
|
|
|
|
</ModalBody>
|
|
|
|
|
</Modal>
|