forked from coracle/flotilla
Compare commits
1 Commits
dev
...
refactor/search
| Author | SHA1 | Date | |
|---|---|---|---|
| a97065a86a |
@@ -2,7 +2,7 @@
|
|||||||
import {tick} from "svelte"
|
import {tick} from "svelte"
|
||||||
import {debounce} from "throttle-debounce"
|
import {debounce} from "throttle-debounce"
|
||||||
import {request} from "@welshman/net"
|
import {request} from "@welshman/net"
|
||||||
import {formatTimestampAsDate, groupBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
|
import {formatTimestampAsDate, groupBy, now, uniqBy, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
|
||||||
import type {TrustedEvent, Filter} from "@welshman/util"
|
import type {TrustedEvent, Filter} from "@welshman/util"
|
||||||
import {sortEventsDesc} from "@welshman/util"
|
import {sortEventsDesc} from "@welshman/util"
|
||||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||||
@@ -10,15 +10,17 @@
|
|||||||
import {fly} from "@lib/transition"
|
import {fly} from "@lib/transition"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
||||||
import {CONTENT_KINDS} from "@app/core/state"
|
import {CONTENT_KINDS} from "@app/core/state"
|
||||||
import {goToEvent} from "@app/util/routes"
|
import {goToEvent} from "@app/util/routes"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
h?: string
|
h?: string
|
||||||
|
kinds?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, h}: Props = $props()
|
const {url, h, kinds = CONTENT_KINDS}: Props = $props()
|
||||||
|
|
||||||
let term = $state("")
|
let term = $state("")
|
||||||
let show = $state(false)
|
let show = $state(false)
|
||||||
@@ -52,9 +54,7 @@
|
|||||||
const getRelayUrls = () => [url]
|
const getRelayUrls = () => [url]
|
||||||
|
|
||||||
const getFilter = (searchTerm: string): Filter =>
|
const getFilter = (searchTerm: string): Filter =>
|
||||||
h
|
h ? {kinds, "#h": [h], search: searchTerm} : {kinds, search: searchTerm}
|
||||||
? {kinds: CONTENT_KINDS, "#h": [h], search: searchTerm}
|
|
||||||
: {kinds: CONTENT_KINDS, search: searchTerm}
|
|
||||||
|
|
||||||
const search = debounce(300, async (searchTerm: string) => {
|
const search = debounce(300, async (searchTerm: string) => {
|
||||||
controller?.abort()
|
controller?.abort()
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
filters: [getFilter(searchTerm.trim())],
|
filters: [getFilter(searchTerm.trim())],
|
||||||
})
|
})
|
||||||
|
|
||||||
results = sortEventsDesc(events)
|
results = sortEventsDesc(uniqBy(e => e.id, events))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!(error instanceof DOMException && error.name === "AbortError")) {
|
if (!(error instanceof DOMException && error.name === "AbortError")) {
|
||||||
results = []
|
results = []
|
||||||
@@ -86,12 +86,6 @@
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const onInput = () => {
|
|
||||||
void search(term)
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results))
|
|
||||||
|
|
||||||
const getAgeSection = (createdAt: number) => {
|
const getAgeSection = (createdAt: number) => {
|
||||||
const age = now() - createdAt
|
const age = now() - createdAt
|
||||||
|
|
||||||
@@ -124,6 +118,12 @@
|
|||||||
return `${Math.floor(age / DAY)}d ago`
|
return `${Math.floor(age / DAY)}d ago`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onInput = () => {
|
||||||
|
void search(term)
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results))
|
||||||
|
|
||||||
const onRoomSearchResultClick = (event: TrustedEvent) => {
|
const onRoomSearchResultClick = (event: TrustedEvent) => {
|
||||||
close()
|
close()
|
||||||
goToEvent(event, {keepFocus: true})
|
goToEvent(event, {keepFocus: true})
|
||||||
@@ -162,9 +162,12 @@
|
|||||||
{h ? "Search for content in this room." : "Search for content in this space."}
|
{h ? "Search for content in this room." : "Search for content in this space."}
|
||||||
</p>
|
</p>
|
||||||
{:else if loading}
|
{:else if loading}
|
||||||
<p class="text-sm opacity-70">Searching...</p>
|
<div class="flex flex-col items-center gap-2 py-4">
|
||||||
|
<span class="loading loading-spinner loading-sm opacity-70"></span>
|
||||||
|
<p class="text-sm opacity-70">Searching...</p>
|
||||||
|
</div>
|
||||||
{:else if eventsByAge.size === 0}
|
{:else if eventsByAge.size === 0}
|
||||||
<p class="text-sm opacity-70">No results found.</p>
|
<p class="text-center text-sm opacity-70">No results found.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="col-2">
|
<div class="col-2">
|
||||||
{#each eventsByAge as [key, events] (key)}
|
{#each eventsByAge as [key, events] (key)}
|
||||||
@@ -181,11 +184,9 @@
|
|||||||
<div class="col-2">
|
<div class="col-2">
|
||||||
{#each events as event (event.id)}
|
{#each events as event (event.id)}
|
||||||
<button
|
<button
|
||||||
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
|
class="col-2 rounded px-1 py-1 text-left transition-colors hover:bg-base-200"
|
||||||
onclick={() => onRoomSearchResultClick(event)}>
|
onclick={() => onRoomSearchResultClick(event)}>
|
||||||
<p class="line-clamp-2 text-sm">
|
<NoteContentMinimal {event} />
|
||||||
{event.content.trim() || "(No text content)"}
|
|
||||||
</p>
|
|
||||||
<div class="row-2 text-xs opacity-70">
|
<div class="row-2 text-xs opacity-70">
|
||||||
<span>{getAgeLabel(event.created_at)}</span>
|
<span>{getAgeLabel(event.created_at)}</span>
|
||||||
<span>{formatTimestampAsDate(event.created_at)}</span>
|
<span>{formatTimestampAsDate(event.created_at)}</span>
|
||||||
|
|||||||
@@ -1,23 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {tick, onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {derived} from "svelte/store"
|
import {derived} from "svelte/store"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import {debounce} from "throttle-debounce"
|
import {groupBy, ago, MONTH, first, sortBy, uniqBy} from "@welshman/lib"
|
||||||
import {
|
|
||||||
formatTimestampAsDate,
|
|
||||||
groupBy,
|
|
||||||
ago,
|
|
||||||
now,
|
|
||||||
MONTH,
|
|
||||||
MINUTE,
|
|
||||||
HOUR,
|
|
||||||
DAY,
|
|
||||||
WEEK,
|
|
||||||
first,
|
|
||||||
sortBy,
|
|
||||||
uniqBy,
|
|
||||||
} from "@welshman/lib"
|
|
||||||
import {request} from "@welshman/net"
|
|
||||||
import {
|
import {
|
||||||
MESSAGE,
|
MESSAGE,
|
||||||
THREAD,
|
THREAD,
|
||||||
@@ -28,19 +13,15 @@
|
|||||||
getTagValue,
|
getTagValue,
|
||||||
getTagValues,
|
getTagValues,
|
||||||
getIdAndAddress,
|
getIdAndAddress,
|
||||||
sortEventsDesc,
|
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {repository} from "@welshman/app"
|
import {repository} from "@welshman/app"
|
||||||
import History from "@assets/icons/history.svg?dataurl"
|
import History from "@assets/icons/history.svg?dataurl"
|
||||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
|
||||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
|
||||||
import {createScroller} from "@lib/html"
|
import {createScroller} from "@lib/html"
|
||||||
import {fly} from "@lib/transition"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
|
||||||
import PageContent from "@lib/components/PageContent.svelte"
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||||
|
import SpaceSearch from "@app/components/SpaceSearch.svelte"
|
||||||
import NoteItem from "@app/components/NoteItem.svelte"
|
import NoteItem from "@app/components/NoteItem.svelte"
|
||||||
import ThreadItem from "@app/components/ThreadItem.svelte"
|
import ThreadItem from "@app/components/ThreadItem.svelte"
|
||||||
import ClassifiedItem from "@app/components/ClassifiedItem.svelte"
|
import ClassifiedItem from "@app/components/ClassifiedItem.svelte"
|
||||||
@@ -48,8 +29,7 @@
|
|||||||
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
|
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
|
||||||
import PollItem from "@app/components/PollItem.svelte"
|
import PollItem from "@app/components/PollItem.svelte"
|
||||||
import RecentConversation from "@app/components/RecentConversation.svelte"
|
import RecentConversation from "@app/components/RecentConversation.svelte"
|
||||||
import {decodeRelay, deriveEventsForUrl, CONTENT_KINDS} from "@app/core/state"
|
import {decodeRelay, deriveEventsForUrl, CONTENT_KINDS, MESSAGE_KINDS} from "@app/core/state"
|
||||||
import {goToEvent} from "@app/util/routes"
|
|
||||||
import {Poll} from "nostr-tools/kinds"
|
import {Poll} from "nostr-tools/kinds"
|
||||||
|
|
||||||
const url = decodeRelay($page.params.relay!)
|
const url = decodeRelay($page.params.relay!)
|
||||||
@@ -111,93 +91,9 @@
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
let term = $state("")
|
|
||||||
let showSearch = $state(false)
|
|
||||||
let loading = $state(false)
|
|
||||||
let searchResults: TrustedEvent[] = $state([])
|
|
||||||
let searchInput: HTMLInputElement | undefined = $state()
|
|
||||||
let controller: AbortController | undefined
|
|
||||||
|
|
||||||
let limit = $state(20)
|
let limit = $state(20)
|
||||||
let element: Element | undefined = $state()
|
let element: Element | undefined = $state()
|
||||||
|
|
||||||
const resultsByAge = $derived(groupBy(e => getAgeSection(e.created_at), searchResults))
|
|
||||||
|
|
||||||
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 openSearch = () => {
|
|
||||||
showSearch = true
|
|
||||||
tick().then(() => searchInput?.focus())
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeSearch = () => {
|
|
||||||
showSearch = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearSearch = () => {
|
|
||||||
term = ""
|
|
||||||
showSearch = false
|
|
||||||
loading = false
|
|
||||||
searchResults = []
|
|
||||||
controller?.abort()
|
|
||||||
controller = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const search = debounce(300, async (searchTerm: string) => {
|
|
||||||
controller?.abort()
|
|
||||||
|
|
||||||
if (!searchTerm.trim()) {
|
|
||||||
loading = false
|
|
||||||
searchResults = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
controller = new AbortController()
|
|
||||||
loading = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const events = await request({
|
|
||||||
relays: [url],
|
|
||||||
autoClose: true,
|
|
||||||
signal: controller.signal,
|
|
||||||
filters: [{kinds: [MESSAGE, ...CONTENT_KINDS], search: searchTerm.trim()}],
|
|
||||||
})
|
|
||||||
|
|
||||||
searchResults = sortEventsDesc(uniqBy((e: TrustedEvent) => e.id, events))
|
|
||||||
} catch (error) {
|
|
||||||
if (!(error instanceof DOMException && error.name === "AbortError")) {
|
|
||||||
searchResults = []
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
loading = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const onInput = () => {
|
|
||||||
showSearch = true
|
|
||||||
void search(term)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onResultClick = (event: TrustedEvent) => {
|
|
||||||
closeSearch()
|
|
||||||
goToEvent(event, {keepFocus: true})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const scroller = createScroller({
|
const scroller = createScroller({
|
||||||
element: element!,
|
element: element!,
|
||||||
@@ -216,77 +112,7 @@
|
|||||||
<strong>Recent Activity</strong>
|
<strong>Recent Activity</strong>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet action()}
|
{#snippet action()}
|
||||||
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={openSearch}>
|
<SpaceSearch {url} kinds={MESSAGE_KINDS} />
|
||||||
<Icon size={4} icon={Magnifier} />
|
|
||||||
</button>
|
|
||||||
{#if showSearch}
|
|
||||||
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={closeSearch}
|
|
||||||
></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={clearSearch}>
|
|
||||||
<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={searchInput}
|
|
||||||
bind:value={term}
|
|
||||||
class="min-w-0 grow"
|
|
||||||
type="text"
|
|
||||||
placeholder="Search this space..."
|
|
||||||
oninput={onInput} />
|
|
||||||
</label>
|
|
||||||
<div class="max-h-[65vh] overflow-y-auto">
|
|
||||||
{#if !term}
|
|
||||||
<p class="text-sm opacity-70">Search for content across this space.</p>
|
|
||||||
{:else if loading}
|
|
||||||
<p class="text-sm opacity-70">Searching...</p>
|
|
||||||
{:else if resultsByAge.size === 0}
|
|
||||||
<p class="text-sm opacity-70">No results found.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="col-2">
|
|
||||||
{#each resultsByAge 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={() => onResultClick(event)}>
|
|
||||||
<p class="line-clamp-2 text-sm">
|
|
||||||
{event.content.trim() ||
|
|
||||||
getTagValue("title", event.tags) ||
|
|
||||||
"(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}
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user