forked from coracle/flotilla
213 lines
6.1 KiB
Svelte
213 lines
6.1 KiB
Svelte
<script lang="ts">
|
|
import {tick} from "svelte"
|
|
import {debounce} from "throttle-debounce"
|
|
import {request} from "@welshman/net"
|
|
import {repository, tracker} from "@welshman/app"
|
|
import {formatTimestampAsDate, groupBy, uniqBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
|
|
import type {TrustedEvent, Filter} from "@welshman/util"
|
|
import {MESSAGE, sortEventsDesc} from "@welshman/util"
|
|
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
|
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
|
import {fly} from "@lib/transition"
|
|
import Button from "@lib/components/Button.svelte"
|
|
import Icon from "@lib/components/Icon.svelte"
|
|
import {CONTENT_KINDS} from "@app/content"
|
|
import {goToEvent} from "@app/routes"
|
|
|
|
type Props = {
|
|
url: string
|
|
h?: string
|
|
}
|
|
|
|
const {url, h}: Props = $props()
|
|
|
|
let term = $state("")
|
|
let show = $state(false)
|
|
let results = $state<TrustedEvent[]>([])
|
|
let loading = $state(false)
|
|
let input: HTMLInputElement | undefined = $state()
|
|
let controller: AbortController | undefined
|
|
|
|
const relayStatus = $derived(
|
|
h ? `Searching this room on relay: ${url}.` : `Searching this space on relay: ${url}.`,
|
|
)
|
|
|
|
const open = () => {
|
|
show = true
|
|
tick().then(() => input?.focus())
|
|
}
|
|
|
|
const close = () => {
|
|
show = false
|
|
}
|
|
|
|
const clear = () => {
|
|
term = ""
|
|
show = false
|
|
loading = false
|
|
results = []
|
|
controller?.abort()
|
|
controller = undefined
|
|
}
|
|
|
|
const getRelayUrls = () => [url]
|
|
|
|
const getFilter = (searchTerm: string): Filter =>
|
|
h
|
|
? {kinds: [MESSAGE, ...CONTENT_KINDS], "#h": [h], search: searchTerm}
|
|
: {kinds: [MESSAGE, ...CONTENT_KINDS], search: searchTerm}
|
|
|
|
const getLocalResults = (filter: Filter) =>
|
|
repository.query([filter]).filter(event => tracker.getRelays(event.id).has(url))
|
|
|
|
const search = debounce(300, async (searchTerm: string) => {
|
|
controller?.abort()
|
|
|
|
if (!searchTerm.trim()) {
|
|
loading = false
|
|
results = []
|
|
return
|
|
}
|
|
|
|
controller = new AbortController()
|
|
loading = true
|
|
|
|
const filter = getFilter(searchTerm.trim())
|
|
const localResults = getLocalResults(filter)
|
|
|
|
results = sortEventsDesc(localResults)
|
|
|
|
try {
|
|
const events = await request({
|
|
relays: getRelayUrls(),
|
|
autoClose: true,
|
|
signal: controller.signal,
|
|
filters: [filter],
|
|
})
|
|
|
|
results = sortEventsDesc(uniqBy((e: TrustedEvent) => e.id, [...events, ...localResults]))
|
|
} catch (error) {
|
|
if (!(error instanceof DOMException && error.name === "AbortError")) {
|
|
results = sortEventsDesc(localResults)
|
|
}
|
|
} finally {
|
|
loading = false
|
|
}
|
|
})
|
|
|
|
const onInput = () => {
|
|
void search(term)
|
|
}
|
|
|
|
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 onRoomSearchResultClick = (event: TrustedEvent) => {
|
|
close()
|
|
goToEvent(event, {keepFocus: true})
|
|
}
|
|
</script>
|
|
|
|
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={open}>
|
|
<Icon size={4} icon={Magnifier} />
|
|
</button>
|
|
{#if show}
|
|
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={close}></button>
|
|
<div class="fixed top-sai right-sai left-content z-feature p-2">
|
|
<div
|
|
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
|
|
transition:fly={{y: -40, duration: 150}}>
|
|
<div class="flex justify-between">
|
|
<strong>Search</strong>
|
|
<Button onclick={clear}>
|
|
<Icon icon={CloseCircle} />
|
|
</Button>
|
|
</div>
|
|
<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={h ? "Search this room..." : "Search this space..."}
|
|
oninput={onInput} />
|
|
</label>
|
|
<div class="max-h-[65vh] overflow-y-auto">
|
|
<p class="mb-2 text-xs opacity-70">{relayStatus}</p>
|
|
{#if !term}
|
|
<p class="text-sm opacity-70">
|
|
{h ? "Search for content in this room." : "Search for content in this space."}
|
|
</p>
|
|
{:else if loading}
|
|
<p class="text-sm opacity-70">Searching...</p>
|
|
{:else if eventsByAge.size === 0}
|
|
<p class="text-sm opacity-70">No results found.</p>
|
|
{:else}
|
|
<div class="col-2">
|
|
{#each eventsByAge as [key, events] (key)}
|
|
<div class="col-2">
|
|
<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>
|
|
<div class="col-2">
|
|
{#each events as event (event.id)}
|
|
<button
|
|
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
|
|
onclick={() => onRoomSearchResultClick(event)}>
|
|
<p class="line-clamp-2 text-sm">
|
|
{event.content.trim() || "(No text content)"}
|
|
</p>
|
|
<div class="row-2 text-xs opacity-70">
|
|
<span>{getAgeLabel(event.created_at)}</span>
|
|
<span>{formatTimestampAsDate(event.created_at)}</span>
|
|
</div>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|