feat: add space search to recent activity page (#59) #119
@@ -1,8 +1,23 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {tick, onMount} from "svelte"
|
||||
import {derived} from "svelte/store"
|
||||
import {page} from "$app/stores"
|
||||
import {groupBy, ago, MONTH, first, sortBy, uniqBy} from "@welshman/lib"
|
||||
import {debounce} from "throttle-debounce"
|
||||
import {
|
||||
formatTimestampAsDate,
|
||||
groupBy,
|
||||
ago,
|
||||
now,
|
||||
MONTH,
|
||||
MINUTE,
|
||||
HOUR,
|
||||
DAY,
|
||||
WEEK,
|
||||
first,
|
||||
sortBy,
|
||||
uniqBy,
|
||||
} from "@welshman/lib"
|
||||
import {request} from "@welshman/net"
|
||||
import {
|
||||
MESSAGE,
|
||||
THREAD,
|
||||
@@ -13,13 +28,15 @@
|
||||
getTagValue,
|
||||
getTagValues,
|
||||
getIdAndAddress,
|
||||
sortEventsDesc,
|
||||
} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {repository, createSearch} from "@welshman/app"
|
||||
import {repository} from "@welshman/app"
|
||||
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 {fly} from "@lib/transition"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
@@ -40,17 +57,15 @@
|
||||
const content = deriveEventsForUrl(url, [{kinds: CONTENT_KINDS, since}])
|
||||
const comments = deriveEventsForUrl(url, [{kinds: [COMMENT], since}])
|
||||
|
||||
type ActivityItem = {
|
||||
type: "message" | "content"
|
||||
event: TrustedEvent
|
||||
count: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const recentActivity = derived(
|
||||
[messages, content, comments],
|
||||
([$messages, $content, $comments]) => {
|
||||
const activity: Array<ActivityItem> = []
|
||||
const activity: Array<{
|
||||
type: "message" | "content"
|
||||
event: TrustedEvent
|
||||
count: number
|
||||
timestamp: number
|
||||
}> = []
|
||||
|
||||
const byRoom = groupBy(e => getTagValue("h", e.tags), $messages)
|
||||
for (const roomMessages of byRoom.values()) {
|
||||
@@ -94,37 +109,99 @@
|
||||
},
|
||||
)
|
||||
|
||||
const allSpaceEvents = derived([messages, content], ([$messages, $content]) => [
|
||||
...$messages,
|
||||
...$content,
|
||||
])
|
||||
|
||||
const searchIndex = $derived.by(() =>
|
||||
createSearch($allSpaceEvents, {
|
||||
getValue: (event: TrustedEvent) => event.id,
|
||||
fuseOptions: {keys: ["content", "tags.1"]},
|
||||
}),
|
||||
)
|
||||
|
||||
let term = $state("")
|
||||
let showSearch = $state(false)
|
||||
let loading = $state(false)
|
||||
let searchResults: TrustedEvent[] = $state([])
|
||||
let searchInput: HTMLInputElement | undefined = $state()
|
||||
let currentSearchId = 0
|
||||
let controller: AbortController | undefined
|
||||
|
||||
let limit = $state(20)
|
||||
let element: Element | undefined = $state()
|
||||
|
||||
const searchResults = $derived(term ? searchIndex.searchOptions(term) : [])
|
||||
const resultsByAge = $derived(groupBy(e => getAgeSection(e.created_at), searchResults))
|
||||
|
||||
const filteredActivity = $derived.by(() => {
|
||||
if (!term) return $recentActivity
|
||||
const getAgeSection = (createdAt: number) => {
|
||||
const age = now() - createdAt
|
||||
|
||||
const matchedIds = new Set(searchResults.map((e: TrustedEvent) => e.id))
|
||||
if (age <= DAY) return "day"
|
||||
if (age <= WEEK) return "week"
|
||||
return "older"
|
||||
}
|
||||
|
||||
return $recentActivity.filter(a => matchedIds.has(a.event.id))
|
||||
})
|
||||
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 onSearchResultClick = (event: TrustedEvent) => {
|
||||
const search = debounce(300, async (searchTerm: string, searchId: number) => {
|
||||
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()}],
|
||||
})
|
||||
|
||||
|
hodlbod marked this conversation as resolved
Outdated
|
||||
if (searchId === currentSearchId) {
|
||||
searchResults = sortEventsDesc(uniqBy((e: TrustedEvent) => e.id, events))
|
||||
}
|
||||
|
hodlbod
commented
searchId/currentSearchId are redundant with AbortController, they can be removed searchId/currentSearchId are redundant with AbortController, they can be removed
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof DOMException && error.name === "AbortError") &&
|
||||
searchId === currentSearchId
|
||||
) {
|
||||
searchResults = []
|
||||
}
|
||||
} finally {
|
||||
if (searchId === currentSearchId) {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const onInput = () => {
|
||||
showSearch = true
|
||||
currentSearchId += 1
|
||||
void search(term, currentSearchId)
|
||||
}
|
||||
|
||||
const onResultClick = (event: TrustedEvent) => {
|
||||
closeSearch()
|
||||
goToEvent(event, {keepFocus: true})
|
||||
}
|
||||
|
||||
@@ -145,41 +222,89 @@
|
||||
<Icon icon={History} />
|
||||
<strong>Recent Activity</strong>
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<div>
|
||||
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={openSearch}>
|
||||
<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 cw top-0 right-0 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}
|
||||
</div>
|
||||
{/snippet}
|
||||
</SpaceBar>
|
||||
|
||||
<div bind:this={element}>
|
||||
<PageContent class="flex flex-col gap-2 p-2 pt-4">
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<Icon size={4} icon={Magnifier} />
|
||||
<input
|
||||
bind:value={term}
|
||||
class="min-w-0 grow"
|
||||
type="text"
|
||||
placeholder="Search this space..." />
|
||||
{#if term}
|
||||
<Button onclick={clearSearch} class="btn btn-ghost btn-xs btn-square">
|
||||
<Icon size={4} icon={CloseCircle} />
|
||||
</Button>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
{#if term && searchResults.length > 0 && filteredActivity.length === 0}
|
||||
<p class="text-xs uppercase tracking-wide opacity-60 pt-2">Search Results</p>
|
||||
{#each searchResults as event (event.id)}
|
||||
<button
|
||||
class="card2 card2-sm bg-alt transition-colors hover:bg-base-200 text-left"
|
||||
onclick={() => onSearchResultClick(event)}>
|
||||
<p class="line-clamp-2 text-sm">
|
||||
{event.content.trim() || getTagValue("title", event.tags) || "(No text content)"}
|
||||
</p>
|
||||
</button>
|
||||
{/each}
|
||||
{:else if term && searchResults.length === 0}
|
||||
<p class="flex flex-col items-center py-20 text-center">No results found.</p>
|
||||
{:else if filteredActivity.length === 0}
|
||||
{#if $recentActivity.length === 0}
|
||||
<p class="flex flex-col items-center py-20 text-center">No recent activity found!</p>
|
||||
{:else}
|
||||
{#each filteredActivity.slice(0, limit) as { type, event, count = 0 } (event.id)}
|
||||
{#each $recentActivity.slice(0, limit) as { type, event, count = 0 } (event.id)}
|
||||
{#if type === "message"}
|
||||
<RecentConversation {url} {event} {count} />
|
||||
{:else if event.kind === THREAD}
|
||||
|
||||
Reference in New Issue
Block a user
Take a look at how the room search works — we should add a search icon to the PageBar which shows the same kind of popover/dialog when clicked.