Clean up search

This commit is contained in:
Jon Staab
2026-06-24 13:10:06 -07:00
parent 74d3a25461
commit 9955a50add
21 changed files with 482 additions and 457 deletions
+4
View File
@@ -88,6 +88,10 @@
@apply rounded-box text-base-content border-base-content/20 bg-base-100 border border-solid p-4 shadow-xl/5 sm:p-6;
}
@utility card2-interactive {
@apply cursor-pointer hover:scale-101 transition-all;
}
@utility column {
@apply flex flex-col;
}
+1 -1
View File
@@ -144,7 +144,7 @@
<div class="relative">
{#if warning}
<div class="card2 card2-sm bg-alt row-2">
<div class="card2 card2-sm shadow-none row-2">
<Icon icon={Danger} />
<p>
This note has been flagged by the author as "{warning}".<br />
+1 -1
View File
@@ -98,7 +98,7 @@
<div class="relative">
{#if warning}
<div class="card2 card2-sm bg-alt row-2">
<div class="card2 card2-sm shadow-none row-2">
<Icon icon={Danger} />
<p>
This note has been flagged by the author as "{warning}".<br />
-1
View File
@@ -54,7 +54,6 @@
</div>
{:else}
<NoteCard
noShadow
event={$quote}
{url}
class="border border-solid border-base-content/20 rounded-box p-4">
+3 -5
View File
@@ -16,15 +16,13 @@
children,
minimal = false,
hideProfile = false,
noShadow = false,
url,
...restProps
}: {
event: TrustedEvent
children: Snippet
children?: Snippet
minimal?: boolean
hideProfile?: boolean
noShadow?: boolean
url?: string
class?: string
} = $props()
@@ -36,7 +34,7 @@
let muted = $state($isEventMuted(event))
</script>
<div class="flex flex-col gap-2 {restProps.class}" class:shadow-md={!noShadow}>
<div class="flex flex-col gap-2 {restProps.class}">
{#if muted}
<div class="flex items-center justify-between">
<div class="row-2 relative">
@@ -60,6 +58,6 @@
{formatTimestamp(event.created_at)}
</Button>
</div>
{@render children()}
{@render children?.()}
{/if}
</div>
+1 -1
View File
@@ -18,7 +18,7 @@
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
</script>
<div class="card2 bg-alt flex flex-col gap-4 shadow-md">
<div class="card2 card2-interactive col-4">
<div class="flex justify-between">
<Profile {pubkey} {url} />
<Button onclick={openProfile} class="btn btn-primary hidden sm:flex">
@@ -7,9 +7,10 @@
type Props = {
url: string
showTooltip?: boolean
}
const {url}: Props = $props()
const {url, showTooltip = true}: Props = $props()
const onClick = () => goToSpace(url)
@@ -21,7 +22,7 @@
<PrimaryNavItem
href={path}
onclick={onClick}
title={$display}
title={showTooltip ? $display : ""}
class="tooltip-right"
notification={$notifications.has(path)}>
<RelayIcon {url} size={10} class="rounded-full" />
+28 -7
View File
@@ -3,8 +3,10 @@
import Widget from "@assets/icons/widget-4.svg?dataurl"
import ImageIcon from "@lib/components/ImageIcon.svelte"
import Divider from "@lib/components/Divider.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
import PrimaryNavSpacesOverflow from "@app/components/PrimaryNavSpacesOverflow.svelte"
import {userSpaceUrls} from "@app/groups"
import {PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/env"
import {notifications} from "@app/notifications"
@@ -16,6 +18,13 @@
const itemLimit = $derived(Math.max(0, (windowHeight - navPadding) / itemHeight))
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(p => $notifications.has(p)))
// Tippy mounts its content component once, so pass a stable reactive object it can read from
const overflowProps = $state({urls: [] as string[]})
$effect(() => {
overflowProps.urls = secondarySpaceUrls
})
</script>
<svelte:window bind:innerHeight={windowHeight} />
@@ -31,12 +40,24 @@
{#each primarySpaceUrls as url (url)}
<PrimaryNavItemSpace {url} />
{/each}
<PrimaryNavItem
href="/spaces"
title="All Spaces"
prefix="no-highlight"
notification={otherSpaceNotifications}>
<ImageIcon alt="All Spaces" src={Widget} size={8} />
</PrimaryNavItem>
{#snippet allSpaces(title: string)}
<PrimaryNavItem
href="/spaces"
{title}
prefix="no-highlight"
notification={otherSpaceNotifications}>
<ImageIcon alt="All Spaces" src={Widget} size={8} />
</PrimaryNavItem>
{/snippet}
{#if secondarySpaceUrls.length > 0}
<Tippy
component={PrimaryNavSpacesOverflow}
props={overflowProps}
params={{placement: "right", interactive: true}}>
{@render allSpaces("")}
</Tippy>
{:else}
{@render allSpaces("All Spaces")}
{/if}
{/each}
</div>
@@ -0,0 +1,16 @@
<script lang="ts">
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
type Props = {
urls: string[]
}
const {urls}: Props = $props()
</script>
<div
class="flex max-h-[80vh] flex-col overflow-y-auto rounded-box border border-solid border-base-content/15 bg-base-200 p-1 shadow-xl">
{#each urls as url (url)}
<PrimaryNavItemSpace {url} showTooltip={false} />
{/each}
</div>
+1 -1
View File
@@ -13,7 +13,7 @@
const maxLength = 5500
</script>
<div class={cx("text-sm", {"card2 card2-sm bg-alt": props.event.kind !== MESSAGE})}>
<div class={cx("text-sm", {"card2 card2-sm shadow-none": props.event.kind !== MESSAGE})}>
{#if path && !isMobile}
<Link href={path}>
<NoteContent {...props} {minLength} {maxLength} />
+174
View File
@@ -0,0 +1,174 @@
<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>
+6
View File
@@ -34,6 +34,7 @@
import SpaceJoin from "@app/components/SpaceJoin.svelte"
import RelayName from "@app/components/RelayName.svelte"
import SpaceActionItems from "@app/components/SpaceActionItems.svelte"
import SpaceSearch from "@app/components/SpaceSearch.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte"
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
import VoiceWidget from "@app/components/VoiceWidget.svelte"
@@ -96,6 +97,8 @@
const showActionItems = () => pushModal(SpaceActionItems, {url})
const openSearch = () => pushModal(SpaceSearch, {url})
const canCreateRoom = deriveUserCanCreateRoom(url)
const createInvite = () => pushModal(SpaceInvite, {url})
@@ -248,6 +251,9 @@
</SecondaryNavItem>
{/if}
{#if hasNip29($relay)}
<SecondaryNavItem onclick={openSearch}>
<Icon icon={Magnifier} /> Search
</SecondaryNavItem>
{#if $userRooms.length > 0}
<div class="h-2 shrink-0"></div>
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
+98 -131
View File
@@ -1,94 +1,66 @@
<script lang="ts">
import {tick} from "svelte"
import {onMount, tick} from "svelte"
import {get} from "svelte/store"
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 {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} from "@welshman/util"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import {MESSAGE, getTagValue, sortEventsDesc, displayRelayUrl} from "@welshman/util"
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 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 RoomName from "@app/components/RoomName.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()
const {url}: 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()) {
const doSearch = debounce(300, async (searchTerm: string, controller: AbortController) => {
if (!searchTerm?.trim()) {
loading = false
results = []
return
}
controller = new AbortController()
loading = true
const filter: Filter = {
kinds: [MESSAGE, ...CONTENT_KINDS],
search: searchTerm.trim(),
}
const filter = getFilter(searchTerm.trim())
const localResults = getLocalResults(filter)
results = sortEventsDesc(localResults)
results = get(deriveEventsForUrlDesc(url, [filter]))
try {
const events = await request({
relays: getRelayUrls(),
autoClose: true,
const events = await load({
relays: [url],
signal: controller.signal,
filters: [filter],
})
results = sortEventsDesc(uniqBy((e: TrustedEvent) => e.id, [...events, ...localResults]))
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")) {
results = sortEventsDesc(localResults)
throw error
}
} finally {
loading = false
@@ -96,7 +68,10 @@
})
const onInput = () => {
void search(term)
loading = true
controller?.abort()
controller = new AbortController()
doSearch(term, controller)
}
const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results))
@@ -133,80 +108,72 @@
return `${Math.floor(age / DAY)}d ago`
}
const onRoomSearchResultClick = (event: TrustedEvent) => {
close()
const onResultClick = (event: TrustedEvent) => {
popModal()
goToEvent(event, {keepFocus: true})
}
onMount(() => {
tick().then(() => input?.focus())
return () => controller?.abort()
})
</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>
<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 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
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>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}
{#if h}
<div class="badge badge-sm badge-neutral">
<RoomName {url} {h} />
</div>
{/if}
</div>
</Button>
{/each}
{/each}
{/if}
</ModalBody>
</Modal>
+1
View File
@@ -14,6 +14,7 @@
style?: string
disabled?: boolean
"data-tip"?: string
"aria-label"?: string
"aria-pressed"?: boolean
} = $props()
+2 -2
View File
@@ -8,13 +8,13 @@
const {...props}: Props = $props()
</script>
<div class="content-padding-t content-padding-x flex h-full flex-col gap-1 {props.class}">
<div class="col-1 h-full {props.class}">
<div class="z-feature">
<div class="content-sizing">
{@render props.input?.()}
</div>
</div>
<div class="scroll-container content-sizing h-full overflow-auto pt-2">
<div class="scroll-container content-sizing h-full pt-2">
{@render props.content?.()}
</div>
</div>
+3 -2
View File
@@ -4,12 +4,13 @@
interface Props {
loading?: boolean
children?: import("svelte").Snippet
class?: string
}
const {loading = false, children}: Props = $props()
const {loading = false, children, ...props}: Props = $props()
</script>
<span class="flex min-h-10 items-center">
<span class="flex min-h-10 items-center justify-center {props.class}">
{#if loading}
<span class="pr-3" transition:slide|local={{axis: "x"}}>
<span class="loading loading-spinner" transition:fade|local={{duration: 100}}></span>
+24 -21
View File
@@ -6,6 +6,7 @@
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Page from "@lib/components/Page.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import ContentSearch from "@lib/components/ContentSearch.svelte"
import PeopleItem from "@app/components/PeopleItem.svelte"
import {bootstrapPubkeys} from "@app/social"
@@ -38,25 +39,27 @@
</script>
<Page>
<ContentSearch>
{#snippet input()}
<label class="row-2 input input-bordered w-full">
<Icon icon={Magnifier} />
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus={!isMobile}
bind:value={term}
class="grow"
type="text"
placeholder="Search for people..." />
</label>
{/snippet}
{#snippet content()}
<div class="col-2 h-full" bind:this={element}>
{#each pubkeys.slice(0, limit) as pubkey (pubkey)}
<PeopleItem {pubkey} />
{/each}
</div>
{/snippet}
</ContentSearch>
<PageContent class="col-2 p-2 sm:col-4 sm:p-4">
<ContentSearch>
{#snippet input()}
<label class="row-2 input input-bordered w-full">
<Icon icon={Magnifier} />
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus={!isMobile}
bind:value={term}
class="grow"
type="text"
placeholder="Search for people..." />
</label>
{/snippet}
{#snippet content()}
<div class="col-2 h-full" bind:this={element}>
{#each pubkeys.slice(0, limit) as pubkey (pubkey)}
<PeopleItem {pubkey} />
{/each}
</div>
{/snippet}
</ContentSearch>
</PageContent>
</Page>
+92 -114
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import {onMount, tick} from "svelte"
import {onMount} from "svelte"
import {flip} from "svelte/animate"
import {cubicOut} from "svelte/easing"
import {derived as _derived} from "svelte/store"
@@ -8,13 +8,12 @@
import {ROOMS} from "@welshman/util"
import {throttled} from "@welshman/store"
import {pull, relays, createSearch} from "@welshman/app"
import {createScroller} from "@lib/html"
import {createScroller, isMobile} from "@lib/html"
import {fly} from "@lib/transition"
import DragHandle from "@assets/icons/drag-handle.svg?dataurl"
import Widget from "@assets/icons/widget-4.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Page from "@lib/components/Page.svelte"
@@ -22,6 +21,7 @@
import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ContentSearch from "@lib/components/ContentSearch.svelte"
import RelaySummary from "@app/components/RelaySummary.svelte"
import SpaceAdd from "@app/components/SpaceAdd.svelte"
import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte"
@@ -154,8 +154,6 @@
})
let term = $state("")
let showSearch = $state(false)
let searchInput: HTMLInputElement | undefined = $state()
let limit = $state(20)
let element: Element
let orderedSpaceUrls = $state<string[]>([])
@@ -164,16 +162,6 @@
let lastDragTarget = $state<string | undefined>()
let didDrop = $state(false)
const openSearch = () => {
showSearch = true
tick().then(() => searchInput?.focus())
}
const closeSearch = () => {
showSearch = false
term = ""
}
const inviteData = $derived(parseInviteLink(term))
const searchResults = $derived($relaySearch.searchOptions(term))
const userSpaceSet = $derived(new Set($userSpaceUrls))
@@ -213,32 +201,6 @@
<strong>Spaces</strong>
</div>
<div class="flex items-center gap-2">
<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 top-sai right-sai left-content-full z-feature p-2">
<div
class="card2 card2-sm p-2! bg-alt flex flex-col shadow-md"
transition:fly={{y: -40, duration: 150}}>
<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 for spaces..."
onkeydown={e => e.key === "Escape" && closeSearch()} />
<Button onclick={closeSearch} class="flex items-center">
<Icon icon={CloseCircle} />
</Button>
</label>
</div>
</div>
{/if}
{#if PLATFORM_RELAYS.length === 0}
<Button class="btn btn-primary btn-sm" onclick={addSpace}>
<Icon icon={AddCircle} />
@@ -248,82 +210,98 @@
</div>
</div>
</PageBar>
<PageContent class="flex flex-col gap-2 p-2 sm:gap-4 p-4">
<div class="flex flex-col gap-2" bind:this={element}>
{#each PLATFORM_RELAYS as url (url)}
<Button
class="card2 bg-alt shadow-md transition-all hover:shadow-lg hover:dark:brightness-[1.1]"
onclick={() => openSpace(url)}>
<RelaySummary {url} />
</Button>
{:else}
{#await loadUserGroupList()}
<div class="flex items-center justify-center py-20">
<span class="loading loading-spinner mr-3"></span>
Loading your spaces...
</div>
{:then}
{#if inviteData}
<Divider>Search results</Divider>
{#key inviteData.url}
<Button
class="card2 bg-alt shadow-md transition-all hover:shadow-lg hover:dark:brightness-[1.1]"
onclick={() => openSpace(inviteData.url, inviteData.claim)}>
<RelaySummary url={inviteData.url} />
</Button>
{/key}
{/if}
{#if filteredUserUrls.length > 0}
<Divider>Your spaces</Divider>
{#each filteredUserUrls as url (url)}
<div
animate:flip={{duration: 300, easing: cubicOut}}
class="transition-opacity duration-200 {draggedUrl === url ? 'opacity-50' : ''}"
draggable="true"
role="listitem"
ondragstart={e => onDragStart(e, url)}
ondragover={onDragOver}
ondragenter={e => onDragEnter(e, url)}
ondrop={e => onDrop(e, url)}
ondragend={onDragEnd}>
<Button
class="group card2 bg-alt shadow-md transition-all hover:shadow-lg hover:dark:brightness-[1.1] w-full relative min-w-0"
onclick={() => openSpace(url)}>
<div class="flex w-full items-start gap-2">
<div
class="mt-4 flex cursor-grab p-1 text-base-content/30 transition-colors group-hover:text-base-content/60">
<Icon icon={DragHandle} />
</div>
<RelaySummary hideFavorites {url} />
</div>
{#if $notifications.has(makeSpacePath(url))}
<div class="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary"></div>
{/if}
</Button>
</div>
{/each}
{:else if !term}
<p class="py-12 text-center">You haven't joined any spaces yet.</p>
{/if}
<Divider>{filteredUserUrls.length > 0 ? "More Spaces" : "Browse Spaces"}</Divider>
{#each otherSpaces.slice(0, limit) as relay (relay.url)}
<Button
class="card2 bg-alt shadow-md transition-all hover:shadow-lg hover:dark:brightness-[1.1]"
onclick={() => openSpace(relay.url)}>
<RelaySummary url={relay.url} />
<PageContent class="col-2 p-2 sm:col-4 sm:p-4">
<ContentSearch>
{#snippet input()}
<label class="row-2 input input-bordered w-full">
<Icon icon={Magnifier} />
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus={!isMobile}
bind:value={term}
class="min-w-0 grow"
type="text"
placeholder="Search for spaces..." />
</label>
{/snippet}
{#snippet content()}
<div class="col-2" bind:this={element}>
{#each PLATFORM_RELAYS as url (url)}
<Button class="card2 card2-interactive" onclick={() => openSpace(url)}>
<RelaySummary {url} />
</Button>
{/each}
<div class="flex justify-center py-20">
{#await sleep(5000)}
<Spinner loading>Looking for spaces...</Spinner>
{:else}
{#await loadUserGroupList()}
<div class="flex items-center justify-center py-20">
<span class="loading loading-spinner mr-3"></span>
Loading your spaces...
</div>
{:then}
{#if otherSpaces.length === 0}
<Spinner>No other spaces found.</Spinner>
{#if inviteData}
<Divider>Search results</Divider>
{#key inviteData.url}
<Button
class="card2 card2-interactive"
onclick={() => openSpace(inviteData.url, inviteData.claim)}>
<RelaySummary url={inviteData.url} />
</Button>
{/key}
{/if}
{#if filteredUserUrls.length > 0}
<Divider>Your spaces</Divider>
{#each filteredUserUrls as url (url)}
<div
animate:flip={{duration: 300, easing: cubicOut}}
class="transition-opacity duration-200 {draggedUrl === url ? 'opacity-50' : ''}"
draggable="true"
role="listitem"
ondragstart={e => onDragStart(e, url)}
ondragover={onDragOver}
ondragenter={e => onDragEnter(e, url)}
ondrop={e => onDrop(e, url)}
ondragend={onDragEnd}>
<Button
class="group card2 card2-interactive w-full relative min-w-0"
onclick={() => openSpace(url)}>
<div class="flex w-full items-start gap-2">
<div
class="mt-4 flex cursor-grab p-1 text-base-content/30 transition-colors group-hover:text-base-content/60">
<Icon icon={DragHandle} />
</div>
<RelaySummary hideFavorites {url} />
</div>
{#if $notifications.has(makeSpacePath(url))}
<div class="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary"></div>
{/if}
</Button>
</div>
{/each}
{:else if !term}
<p class="py-12 text-center">You haven't joined any spaces yet.</p>
{/if}
{#if otherSpaces.length > 0}
<Divider>{filteredUserUrls.length > 0 ? "More Spaces" : "Browse Spaces"}</Divider>
{/if}
{#each otherSpaces.slice(0, limit) as relay (relay.url)}
<Button
class="card2 card2-interactive"
onclick={() => openSpace(relay.url)}>
<RelaySummary url={relay.url} />
</Button>
{/each}
<div class="flex justify-center py-20">
{#await sleep(5000)}
<Spinner loading>Looking for spaces...</Spinner>
{:then}
{#if otherSpaces.length === 0}
<Spinner>No other spaces found.</Spinner>
{/if}
{/await}
</div>
{/await}
</div>
{/await}
{/each}
</div>
{/each}
</div>
{/snippet}
</ContentSearch>
</PageContent>
</Page>
+9 -3
View File
@@ -14,6 +14,7 @@
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import InfoCircle from "@assets/icons/info-circle.svg?dataurl"
import Login2 from "@assets/icons/login-3.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import cx from "classnames"
import {fade, fly} from "@lib/transition"
import Button from "@lib/components/Button.svelte"
@@ -28,7 +29,7 @@
import RoomDetail from "@app/components/RoomDetail.svelte"
import RoomItem from "@app/components/RoomItem.svelte"
import RoomName from "@app/components/RoomName.svelte"
import SpaceSearch from "@app/components/SpaceSearch.svelte"
import RoomSearch from "@app/components/RoomSearch.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
@@ -424,7 +425,12 @@
<RoomName {url} {h} />
{/snippet}
{#snippet action()}
<SpaceSearch {url} {h} />
<Button
class="btn btn-neutral btn-sm btn-square"
aria-label="Search"
onclick={() => pushModal(RoomSearch, {url, h})}>
<Icon size={4} icon={Magnifier} />
</Button>
<Button class="btn btn-neutral btn-sm btn-square" onclick={showRoomDetail}>
<Icon size={4} icon={InfoCircle} />
</Button>
@@ -483,7 +489,7 @@
</div>
</div>
{:else}
{#if loadingForward}
{#if loadingForward && elements.length > 0}
<p class="py-20 flex justify-center">
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
+9 -2
View File
@@ -12,6 +12,7 @@
import {fade, fly} from "@lib/transition"
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
@@ -32,6 +33,7 @@
import {publishDelete} from "@app/deletes"
import {checked} from "@app/notifications"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
import {makeFeed} from "@app/feeds"
import {popKey} from "@lib/implicit"
@@ -308,12 +310,17 @@
<strong>Chat</strong>
{/snippet}
{#snippet action()}
<SpaceSearch {url} />
<Button
class="btn btn-neutral btn-sm btn-square"
aria-label="Search"
onclick={() => pushModal(SpaceSearch, {url})}>
<Icon size={4} icon={Magnifier} />
</Button>
{/snippet}
</SpaceBar>
<PageContent bind:element onscroll={onScroll} class="flex-col-reverse !mb-0">
{#if loadingForward}
{#if loadingForward && elements.length > 0}
<p class="py-20 flex justify-center">
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
+6 -163
View File
@@ -1,117 +1,28 @@
<script lang="ts">
import {tick, onMount} from "svelte"
import {onMount} from "svelte"
import {page} from "$app/stores"
import {debounce} from "throttle-debounce"
import {formatTimestampAsDate, groupBy, now, MINUTE, HOUR, DAY, WEEK, uniqBy} from "@welshman/lib"
import {request} from "@welshman/net"
import {MESSAGE, getTagValue, sortEventsDesc} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
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"
import SpaceBar from "@app/components/SpaceBar.svelte"
import RecentItem from "@app/components/RecentItem.svelte"
import SpaceSearch from "@app/components/SpaceSearch.svelte"
import {decodeRelay} from "@app/relays"
import {CONTENT_KINDS} from "@app/content"
import {deriveRecentActivity} from "@app/recent"
import {goToEvent} from "@app/routes"
import {pushModal} from "@app/modal"
const url = decodeRelay($page.params.relay!)
const recentActivity = deriveRecentActivity(url)
let term = $state("")
let showSearch = $state(false)
let loading = $state(false)
let searchResults: TrustedEvent[] = $state([])
let searchInput: HTMLInputElement | undefined = $state()
let controller: AbortController | undefined
const openSearch = () => pushModal(SpaceSearch, {url})
let limit = $state(20)
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(() => {
const scroller = createScroller({
element: element!,
@@ -132,77 +43,9 @@
<strong>Recent Activity</strong>
{/snippet}
{#snippet action()}
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={openSearch}>
<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 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}
</Button>
{/snippet}
</SpaceBar>