forked from coracle/flotilla
Clean up search
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -54,7 +54,6 @@
|
||||
</div>
|
||||
{:else}
|
||||
<NoteCard
|
||||
noShadow
|
||||
event={$quote}
|
||||
{url}
|
||||
class="border border-solid border-base-content/20 rounded-box p-4">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
style?: string
|
||||
disabled?: boolean
|
||||
"data-tip"?: string
|
||||
"aria-label"?: string
|
||||
"aria-pressed"?: boolean
|
||||
} = $props()
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user