Space search

This commit is contained in:
Ben
2026-02-14 21:27:36 -05:00
committed by Jon Staab
parent 7823e1d803
commit 66a7a2a7af
4 changed files with 627 additions and 34 deletions
+47
View File
@@ -131,8 +131,55 @@ export const makeFeed = ({
insertEvent(event)
}
const reveal = (id: string, targetEvent?: TrustedEvent) => {
const current = get(events)
if (current.find(e => e.id === id)) {
return true
}
const queued = get(buffer)
const index = queued.findIndex(e => e.id === id)
if (index === -1) {
const event = targetEvent || repository.getEvent(id)
if (event && matchFilters(filters, event)) {
insertEvent(event)
const next = get(events)
if (next.find(e => e.id === id)) {
return true
}
const queuedNext = get(buffer)
const queuedIndex = queuedNext.findIndex(e => e.id === id)
if (queuedIndex > -1) {
const count = Math.max(30, queuedIndex + 1)
const chunk = queuedNext.splice(0, count)
events.update($events => [...$events, ...chunk])
return true
}
}
return false
}
const count = Math.max(30, index + 1)
const chunk = queued.splice(0, count)
events.update($events => [...$events, ...chunk])
return true
}
return {
events,
reveal,
cleanup: () => {
scroller.stop()
controller.abort()
+5 -5
View File
@@ -118,6 +118,10 @@ export const scrollToEvent = async (id: string, attempts = 3): Promise<boolean>
return true
} else if (elements.length > 0) {
if (attempts <= 0) {
return false
}
const lastElement = last(elements)
if (lastElement && !isIntersecting(lastElement)) {
@@ -126,11 +130,7 @@ export const scrollToEvent = async (id: string, attempts = 3): Promise<boolean>
await sleep(300)
if (attempts > 0) {
return scrollToEvent(id, attempts - 1)
} else {
return false
}
return scrollToEvent(id, attempts - 1)
}
return false
+452 -27
View File
@@ -1,56 +1,73 @@
<script lang="ts">
import {onMount, onDestroy, tick} from "svelte"
import {readable} from "svelte/store"
import {onMount, onDestroy} from "svelte"
import cx from "classnames"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import type {Readable} from "svelte/store"
import {
pubkey,
publishThunk,
waitForThunkError,
joinRoom,
leaveRoom,
createSearch,
} from "@welshman/app"
import {now, int, formatTimestampAsDate, ago, MINUTE, sleep} from "@welshman/lib"
import type {MakeNonOptional} from "@welshman/lib"
import {now, int, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {
getTagValue,
makeEvent,
makeRoomMeta,
MESSAGE,
ROOM_ADD_MEMBER,
ROOM_REMOVE_MEMBER,
} from "@welshman/util"
import {pubkey, publishThunk, waitForThunkError, joinRoom, leaveRoom} from "@welshman/app"
import {slide, fade, fly} from "@lib/transition"
import InfoCircle from "@assets/icons/info-circle.svg?dataurl"
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import Login2 from "@assets/icons/login-3.svg?dataurl"
import {load} from "@welshman/net"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
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 {scrollToEvent} from "@lib/html"
import {slide, fade, fly} from "@lib/transition"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Divider from "@lib/components/Divider.svelte"
import Icon from "@lib/components/Icon.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import RoomName from "@app/components/RoomName.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import RoomCompose from "@app/components/RoomCompose.svelte"
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
import RoomImage from "@app/components/RoomImage.svelte"
import RoomDetail from "@app/components/RoomDetail.svelte"
import RoomItem from "@app/components/RoomItem.svelte"
import RoomName from "@app/components/RoomName.svelte"
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
import RoomItemRemoveMember from "@src/app/components/RoomItemRemoveMember.svelte"
import RoomCompose from "@app/components/RoomCompose.svelte"
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
import RoomItemRemoveMember from "@src/app/components/RoomItemRemoveMember.svelte"
import {canEnforceNip70, prependParent, publishDelete} from "@app/core/commands"
import {
decodeRelay,
deriveUserRoomMembershipStatus,
deriveEventsForUrl,
displayRoom,
deriveRoom,
deriveUserRoomMembershipStatus,
MESSAGE_KINDS,
MembershipStatus,
PROTECTED,
MESSAGE_KINDS,
userSettingsValues,
} from "@app/core/state"
import {checked} from "@app/util/notifications"
import {canEnforceNip70, prependParent, publishDelete} from "@app/core/commands"
import {makeFeed} from "@app/core/requests"
import {popKey} from "@lib/implicit"
import {pushToast} from "@app/util/toast"
import {popKey, setKey} from "@lib/implicit"
import {checked} from "@app/util/notifications"
import {pushModal} from "@app/util/modal"
import {makeRoomPath} from "@app/util/routes"
import {pushToast} from "@app/util/toast"
const {h, relay} = $page.params as MakeNonOptional<typeof $page.params>
const mounted = now()
@@ -59,9 +76,65 @@
const room = deriveRoom(url, h)
const shouldProtect = canEnforceNip70(url)
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
const spaceMessages = deriveEventsForUrl(url, [{kinds: [MESSAGE]}])
const pendingSearchEvent = popKey<TrustedEvent | undefined>("room_search_event")
const ageSections = [
{key: "day", label: "Last 24 Hours"},
{key: "week", label: "Last 7 Days"},
{key: "older", label: "Older"},
] as const
type AgeSectionKey = (typeof ageSections)[number]["key"]
type RoomSearchResult = {
id: string
h: string
roomName: string
event: TrustedEvent
searchText: string
}
type RoomSearchResultItem = RoomSearchResult & {
ageLabel: string
ageSection: AgeSectionKey
dateLabel: string
preview: string
}
const showRoomDetail = () => pushModal(RoomDetail, {url, h})
const showRoomSearch = () => {
showRoomSearchResults = true
}
const closeRoomSearch = () => {
showRoomSearchResults = false
}
const clearRoomSearch = () => {
roomSearchTerm = ""
showRoomSearchResults = false
}
const onRoomSearchInput = () => {
showRoomSearchResults = Boolean(roomSearchTerm.trim())
}
const normalizeSearchText = (value: string) =>
value
.toLowerCase()
.replace(/[^\p{L}\p{N}\s]/gu, " ")
.replace(/\s+/g, " ")
.trim()
const matchesAllTerms = (query: string, value: string) => {
const terms = normalizeSearchText(query).split(" ").filter(Boolean)
const normalizedValue = normalizeSearchText(value)
return terms.every(term => normalizedValue.includes(term))
}
const join = async () => {
joining = true
@@ -200,8 +273,102 @@
let showScrollButton = $state(false)
let cleanup: () => void
let events: Readable<TrustedEvent[]> = $state(readable([]))
let revealInFeed = (_id: string, _event?: TrustedEvent) => false
let compose: RoomCompose | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state()
let roomSearchTerm = $state("")
let showRoomSearchResults = $state(false)
let jumpInFlight = $state(false)
let lastJumpId: string | undefined = $state()
const trimmedRoomSearchTerm = $derived(roomSearchTerm.trim())
const roomSearchResults = $derived.by(() => {
if (!trimmedRoomSearchTerm) {
return [] as RoomSearchResult[]
}
const search = createSearch(
$spaceMessages.map(event => {
const eventH = getTagValue("h", event.tags) || "chat"
const roomName = eventH === "chat" ? "Space Chat" : displayRoom(url, eventH)
return {
id: event.id,
h: eventH,
roomName,
event,
searchText: `${roomName} ${event.content}`.trim(),
} as RoomSearchResult
}),
{
getValue: result => result.id,
fuseOptions: {keys: ["searchText", "roomName"]},
},
)
return (search.searchOptions(trimmedRoomSearchTerm) as RoomSearchResult[]).filter(result =>
matchesAllTerms(trimmedRoomSearchTerm, result.searchText),
)
})
const spaceMessageById = $derived(
new Map($spaceMessages.map(event => [event.id, event] as const)),
)
const groupedRoomSearchResults = $derived.by(() => {
const groupedByRoom = new Map<
string,
{
h: string
roomName: string
sections: Record<AgeSectionKey, RoomSearchResultItem[]>
}
>()
for (const result of roomSearchResults) {
let roomGroup = groupedByRoom.get(result.h)
if (!roomGroup) {
roomGroup = {
h: result.h,
roomName: result.roomName,
sections: {day: [], week: [], older: []},
}
}
const preview = result.event.content.trim() || "(No text content)"
const ageSection = getAgeSection(result.event.created_at)
roomGroup.sections[ageSection].push({
...result,
ageSection,
ageLabel: getAgeLabel(result.event.created_at),
dateLabel: formatTimestampAsDate(result.event.created_at),
preview,
})
groupedByRoom.set(result.h, roomGroup)
}
return Array.from(groupedByRoom.values())
.sort((a, b) => {
if (a.h === h) return -1
if (b.h === h) return 1
return a.roomName.localeCompare(b.roomName)
})
.map(group => ({
...group,
visibleSections: ageSections
.map(section => ({
...section,
items: group.sections[section.key].sort(
(a, b) => b.event.created_at - a.event.created_at,
),
}))
.filter(section => section.items.length > 0),
}))
})
const elements = $derived.by(() => {
const elements = []
@@ -280,6 +447,7 @@
})
events = feed.events
revealInFeed = feed.reveal
cleanup = feed.cleanup
}
@@ -306,6 +474,172 @@
}
}
const getAgeSection = (createdAt: number): AgeSectionKey => {
const age = now() - createdAt
if (age <= 24 * 60 * 60) {
return "day"
}
if (age <= 7 * 24 * 60 * 60) {
return "week"
}
return "older"
}
const getAgeLabel = (createdAt: number) => {
const age = now() - createdAt
if (age < 60) {
return "Just now"
}
if (age < 60 * 60) {
return `${Math.floor(age / 60)}m ago`
}
if (age < 24 * 60 * 60) {
return `${Math.floor(age / (60 * 60))}h ago`
}
return `${Math.floor(age / (24 * 60 * 60))}d ago`
}
const revealMessageById = async (id: string, targetEvent?: TrustedEvent) => {
const tryScroll = async () => {
for (let i = 0; i < 4; i++) {
if (await scrollToEvent(id, 0)) {
return true
}
await tick()
await sleep(120)
}
return false
}
let revealed = false
let inserted = revealInFeed(id, targetEvent)
if (inserted) {
await tick()
}
revealed = await tryScroll()
if (!revealed) {
await load({relays: [url], filters: [{ids: [id]}]})
inserted = revealInFeed(id, targetEvent)
if (inserted) {
await tick()
}
revealed = await tryScroll()
}
for (let i = 0; i < 18 && !revealed; i++) {
await sleep(250)
inserted = revealInFeed(id, targetEvent)
if (inserted) {
await tick()
}
revealed = await tryScroll()
}
return revealed
}
const stabilizeJumpScroll = async (id: string) => {
const maybeCenter = (behavior: "auto" | "smooth") => {
const element = document.querySelector(`[data-event="${id}"]`)
if (!element) {
return
}
const {top, bottom} = element.getBoundingClientRect()
const viewport = window.innerHeight
const inViewBand = top >= viewport * 0.2 && bottom <= viewport * 0.8
if (!inViewBand) {
element.scrollIntoView({behavior, block: "center"})
}
}
await sleep(220)
maybeCenter("smooth")
await sleep(320)
maybeCenter("auto")
}
const openRoomSearchResult = async (eventId: string, targetH: string) => {
const targetPath = makeRoomPath(url, targetH)
const targetEvent = spaceMessageById.get(eventId)
if (targetEvent) {
setKey("room_search_event", targetEvent)
}
if ($page.url.pathname === targetPath) {
await handleJump(eventId)
return
}
await goto(`${targetPath}?jump=${encodeURIComponent(eventId)}`, {
noScroll: true,
keepFocus: true,
})
}
const clearJumpParam = () => {
const next = new URL($page.url)
next.searchParams.delete("jump")
window.history.replaceState(
window.history.state,
"",
`${next.pathname}${next.search}${next.hash}`,
)
}
const handleJump = async (jumpId: string) => {
if (jumpInFlight && lastJumpId === jumpId) {
return
}
jumpInFlight = true
lastJumpId = jumpId
const targetEvent = pendingSearchEvent?.id === jumpId ? pendingSearchEvent : undefined
const revealed = await revealMessageById(jumpId, targetEvent)
if (!revealed) {
pushToast({theme: "error", message: "Could not load this older message yet."})
} else {
await stabilizeJumpScroll(jumpId)
}
clearJumpParam()
jumpInFlight = false
}
const onRoomSearchResultClick = (event: MouseEvent) => {
closeRoomSearch()
const eventId = (event.currentTarget as HTMLElement).dataset.eventId
const targetH = (event.currentTarget as HTMLElement).dataset.roomH || h
if (!eventId) {
return
}
void openRoomSearchResult(eventId, targetH)
}
onMount(() => {
const observer = new ResizeObserver(() => {
if (dynamicPadding && chatCompose) {
@@ -323,6 +657,18 @@
}
})
$effect(() => {
const jumpId = $page.url.searchParams.get("jump")
if (!jumpId) {
return
}
setTimeout(() => {
void handleJump(jumpId)
}, 400)
})
onDestroy(() => {
cleanup?.()
})
@@ -333,17 +679,31 @@
<RoomImage {url} {h} />
{/snippet}
{#snippet title()}
<RoomName {url} {h} />
{/snippet}
{#snippet action()}
<div class="row-2">
<RoomName {url} {h} />
<Button
class="btn btn-neutral btn-sm tooltip tooltip-left"
class="btn btn-neutral btn-xs tooltip tooltip-bottom"
data-tip="Room information"
onclick={showRoomDetail}>
<Icon size={4} icon={InfoCircle} />
</Button>
<SpaceMenuButton {url} />
</div>
{/snippet}
{#snippet action()}
<div class="row-2 w-[10.5rem] min-w-0 shrink-0 sm:w-[14rem] md:w-auto">
<label class="input input-sm input-bordered flex min-w-0 w-full items-center gap-2 md:w-64">
<Icon size={4} icon={Magnifier} />
<input
bind:value={roomSearchTerm}
class="min-w-0 grow"
type="text"
placeholder="Search space messages..."
onfocus={showRoomSearch}
oninput={onRoomSearchInput} />
</label>
<div class="shrink-0">
<SpaceMenuButton {url} />
</div>
</div>
{/snippet}
</PageBar>
@@ -415,6 +775,71 @@
{/if}
</PageContent>
{#if showRoomSearchResults}
<div>
<button
class="fixed inset-0 z-feature"
aria-label="Close search results"
onclick={closeRoomSearch}></button>
<div class="cw fixed top-[calc(var(--sait)+3rem)] z-popover p-2">
<div
transition:fly={{y: -40, duration: 150}}
class="mx-auto flex w-[42rem] max-w-full flex-col overflow-hidden rounded-box border border-base-content/15 bg-base-100 shadow-xl md:ml-auto md:mr-0">
<div class="row-2 border-b border-base-100 p-3">
<strong>Search Results</strong>
<div class="grow"></div>
<Button class="btn btn-ghost btn-sm" onclick={clearRoomSearch}>Clear</Button>
<Button class="btn btn-ghost btn-sm" onclick={closeRoomSearch}>
<Icon size={4} icon={CloseCircle} />
</Button>
</div>
<div class="max-h-[65vh] overflow-y-auto bg-base-100 p-4">
{#if !trimmedRoomSearchTerm}
<p class="text-sm opacity-70">Search for messages across all rooms in this space.</p>
{:else if groupedRoomSearchResults.length === 0}
<p class="text-sm opacity-70">No results found.</p>
{:else}
<div class="col-3">
{#each groupedRoomSearchResults as roomGroup (roomGroup.h)}
<section class="col-2">
<h4 class={cx("text-sm font-semibold", roomGroup.h === h && "text-primary")}>
{roomGroup.roomName}
</h4>
{#each roomGroup.visibleSections as section (section.key)}
<div class="col-2">
<p class="text-xs uppercase tracking-wide opacity-60">{section.label}</p>
<div class="col-2">
{#each section.items as result (result.id)}
<div class="p-1">
<button
data-event-id={result.id}
data-room-h={result.h}
class={cx(
"col-2 w-full rounded-box bg-base-300 p-4 text-left transition-colors hover:bg-base-200",
result.h === h && "border border-primary/40",
)}
onclick={onRoomSearchResultClick}>
<p class="line-clamp-2 text-sm">{result.preview}</p>
<div class="row-2 text-xs opacity-70">
<span>{result.ageLabel}</span>
<span>{result.dateLabel}</span>
</div>
</button>
</div>
{/each}
</div>
</div>
{/each}
</section>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
<!-- pass -->
+123 -2
View File
@@ -1,12 +1,14 @@
<script lang="ts">
import {onMount} from "svelte"
import {onMount, tick} from "svelte"
import {page} from "$app/stores"
import type {Readable} from "svelte/store"
import {readable} from "svelte/store"
import {now, int, formatTimestampAsDate, MINUTE, ago} from "@welshman/lib"
import {now, int, formatTimestampAsDate, MINUTE, ago, sleep} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {makeEvent, MESSAGE, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER} from "@welshman/util"
import {pubkey, publishThunk} from "@welshman/app"
import {load} from "@welshman/net"
import {scrollToEvent} from "@lib/html"
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"
@@ -35,6 +37,7 @@
const lastChecked = $checked[$page.url.pathname]
const url = decodeRelay($page.params.relay!)
const shouldProtect = canEnforceNip70(url)
const pendingSearchEvent = popKey<TrustedEvent | undefined>("room_search_event")
const replyTo = (event: TrustedEvent) => {
parent = event
@@ -136,8 +139,11 @@
let showScrollButton = $state(false)
let cleanup: () => void
let events: Readable<TrustedEvent[]> = $state(readable([]))
let revealInFeed = (_id: string, _event?: TrustedEvent) => false
let compose: RoomCompose | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state()
let jumpInFlight = $state(false)
let lastJumpId: string | undefined = $state()
const elements = $derived.by(() => {
const elements = []
@@ -226,6 +232,108 @@
}
}
const revealMessageById = async (id: string, targetEvent?: TrustedEvent) => {
const tryScroll = async () => {
for (let i = 0; i < 4; i++) {
if (await scrollToEvent(id, 0)) {
return true
}
await tick()
await sleep(120)
}
return false
}
let revealed = false
let inserted = revealInFeed(id, targetEvent)
if (inserted) {
await tick()
}
revealed = await tryScroll()
if (!revealed) {
await load({relays: [url], filters: [{ids: [id]}]})
inserted = revealInFeed(id, targetEvent)
if (inserted) {
await tick()
}
revealed = await tryScroll()
}
for (let i = 0; i < 18 && !revealed; i++) {
await sleep(250)
inserted = revealInFeed(id, targetEvent)
if (inserted) {
await tick()
}
revealed = await tryScroll()
}
return revealed
}
const stabilizeJumpScroll = async (id: string) => {
const maybeCenter = (behavior: "auto" | "smooth") => {
const element = document.querySelector(`[data-event="${id}"]`)
if (!element) {
return
}
const {top, bottom} = element.getBoundingClientRect()
const viewport = window.innerHeight
const inViewBand = top >= viewport * 0.2 && bottom <= viewport * 0.8
if (!inViewBand) {
element.scrollIntoView({behavior, block: "center"})
}
}
await sleep(220)
maybeCenter("smooth")
await sleep(320)
maybeCenter("auto")
}
const clearJumpParam = () => {
const next = new URL($page.url)
next.searchParams.delete("jump")
window.history.replaceState(
window.history.state,
"",
`${next.pathname}${next.search}${next.hash}`,
)
}
const handleJump = async (jumpId: string) => {
if (jumpInFlight && lastJumpId === jumpId) {
return
}
jumpInFlight = true
lastJumpId = jumpId
const targetEvent = pendingSearchEvent?.id === jumpId ? pendingSearchEvent : undefined
const revealed = await revealMessageById(jumpId, targetEvent)
if (!revealed) {
pushToast({theme: "error", message: "Could not load this older message yet."})
} else {
await stabilizeJumpScroll(jumpId)
}
clearJumpParam()
jumpInFlight = false
}
onMount(() => {
const controller = new AbortController()
@@ -248,6 +356,7 @@
})
events = feed.events
revealInFeed = feed.reveal
cleanup = feed.cleanup
return () => {
@@ -262,6 +371,18 @@
}, 800)
}
})
$effect(() => {
const jumpId = $page.url.searchParams.get("jump")
if (!jumpId) {
return
}
setTimeout(() => {
void handleJump(jumpId)
}, 400)
})
</script>
<PageBar>