Rework search dialogs
This commit is contained in:
parent
cfb5dd53b1
commit
9c5b3d42d2
10 changed files with 266 additions and 396 deletions
|
|
@ -1083,9 +1083,9 @@ profile.
|
||||||
|
|
||||||
Acceptance:
|
Acceptance:
|
||||||
|
|
||||||
- Typing a name on the people search filters results as she types, each showing
|
- Typing a name in the search dialog filters results as she types, each showing
|
||||||
avatar, display name, and about text.
|
avatar, display name, and about text.
|
||||||
- Scrolling to the bottom loads more matches.
|
- Only the ten best matches are listed.
|
||||||
- Clicking a result opens that person's profile.
|
- Clicking a result opens that person's profile.
|
||||||
|
|
||||||
### US-075 — View someone's profile
|
### US-075 — View someone's profile
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,13 @@ const sidebar = (page: Page) => page.locator("aside")
|
||||||
// content in it.
|
// content in it.
|
||||||
const dialog = (page: Page) => page.locator(".dialog").last()
|
const dialog = (page: Page) => page.locator(".dialog").last()
|
||||||
|
|
||||||
|
// Search is a dialog the nav opens rather than a page of its own.
|
||||||
|
const openSearch = (page: Page) => page.locator('.primary-nav button[data-tip="Search"]').click()
|
||||||
|
|
||||||
|
const searchTerm = (page: Page) => page.getByPlaceholder("Search your spaces...")
|
||||||
|
|
||||||
|
const searchResults = (page: Page) => dialog(page).locator(".card.card-interactive")
|
||||||
|
|
||||||
const notes = (page: Page) => pageContent(page).locator(".cv.card")
|
const notes = (page: Page) => pageContent(page).locator(".cv.card")
|
||||||
|
|
||||||
const readClipboard = (page: Page) => page.evaluate(() => navigator.clipboard.readText())
|
const readClipboard = (page: Page) => page.evaluate(() => navigator.clipboard.readText())
|
||||||
|
|
@ -76,11 +83,8 @@ const seedRelayList = (space: SeededSpace, user: TestUser) =>
|
||||||
)
|
)
|
||||||
|
|
||||||
test("US-074 find a person", async ({seed, as}) => {
|
test("US-074 find a person", async ({seed, as}) => {
|
||||||
// Sixty of them: the people list starts at ten and adds another ten every second until the page
|
// Sixty of them, so that the ten the dialog lists are visibly the best matches rather than
|
||||||
// is taller than the scroller's threshold — and it keeps counting while the profiles are still
|
// everyone who matched.
|
||||||
// on their way, so by the time the first ones render the limit is already well past ten.
|
|
||||||
// "Scrolling loads more" is only a fact when there are far more matches than that first fill
|
|
||||||
// can reach.
|
|
||||||
const searchers = Array.from({length: 60}, (_, i) => makeTestUser(`searcher-${i}`))
|
const searchers = Array.from({length: 60}, (_, i) => makeTestUser(`searcher-${i}`))
|
||||||
const searcherName = (i: number) => `Searcher ${String(i).padStart(2, "0")}`
|
const searcherName = (i: number) => `Searcher ${String(i).padStart(2, "0")}`
|
||||||
const searcherAvatar = (i: number) => `https://images.test/searcher-${i}.png`
|
const searcherAvatar = (i: number) => `https://images.test/searcher-${i}.png`
|
||||||
|
|
@ -91,7 +95,7 @@ test("US-074 find a person", async ({seed, as}) => {
|
||||||
"Answers to a whistle and to nothing else.",
|
"Answers to a whistle and to nothing else.",
|
||||||
].join("\n")
|
].join("\n")
|
||||||
|
|
||||||
await seed(({relay, user}) => {
|
const scenario = await seed(({relay, user}) => {
|
||||||
const space = relay("space")
|
const space = relay("space")
|
||||||
|
|
||||||
space.room("general", {name: "General"})
|
space.room("general", {name: "General"})
|
||||||
|
|
@ -113,10 +117,11 @@ test("US-074 find a person", async ({seed, as}) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const page = await as(users.alice, "/people", {context: DESKTOP})
|
const page = await as(users.alice, spacePath(scenario.space("space").url), {context: DESKTOP})
|
||||||
const term = page.getByPlaceholder("Search for people...")
|
const term = searchTerm(page)
|
||||||
const cards = page.locator(".card.card-interactive")
|
const cards = searchResults(page)
|
||||||
|
|
||||||
|
await openSearch(page)
|
||||||
await term.fill("Searcher")
|
await term.fill("Searcher")
|
||||||
|
|
||||||
// Which of them ranks first is fuse's business, so a result is described by what every
|
// Which of them ranks first is fuse's business, so a result is described by what every
|
||||||
|
|
@ -128,15 +133,7 @@ test("US-074 find a person", async ({seed, as}) => {
|
||||||
await expect(first).toContainText(/Searcher \d\d/)
|
await expect(first).toContainText(/Searcher \d\d/)
|
||||||
await expect(first).toContainText("Deck crew on the northern run.")
|
await expect(first).toContainText("Deck crew on the northern run.")
|
||||||
|
|
||||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(10)
|
await expect.poll(() => cards.count()).toBe(10)
|
||||||
|
|
||||||
const shown = await cards.count()
|
|
||||||
|
|
||||||
expect(shown).toBeLessThan(searchers.length)
|
|
||||||
|
|
||||||
await cards.last().scrollIntoViewIfNeeded()
|
|
||||||
|
|
||||||
await expect.poll(() => cards.count()).toBeGreaterThan(shown)
|
|
||||||
|
|
||||||
// Narrowing the term filters the list rather than reordering it.
|
// Narrowing the term filters the list rather than reordering it.
|
||||||
await term.fill("Barnacle")
|
await term.fill("Barnacle")
|
||||||
|
|
@ -267,7 +264,7 @@ test("US-076 follow and unfollow", async ({seed, as}) => {
|
||||||
test("US-077 see web-of-trust standing build up", async ({seed, as}) => {
|
test("US-077 see web-of-trust standing build up", async ({seed, as}) => {
|
||||||
const carolAvatar = "https://images.test/carol-avatar.png"
|
const carolAvatar = "https://images.test/carol-avatar.png"
|
||||||
|
|
||||||
await seed(({relay, user}) => {
|
const scenario = await seed(({relay, user}) => {
|
||||||
const space = relay("space")
|
const space = relay("space")
|
||||||
|
|
||||||
space.room("general", {name: "General"})
|
space.room("general", {name: "General"})
|
||||||
|
|
@ -289,9 +286,9 @@ test("US-077 see web-of-trust standing build up", async ({seed, as}) => {
|
||||||
|
|
||||||
// Every hop here is a link click rather than a navigation, so the follow alice publishes stays
|
// Every hop here is a link click rather than a navigation, so the follow alice publishes stays
|
||||||
// in the client that published it.
|
// in the client that published it.
|
||||||
const page = await as(users.alice, "/people", {context: DESKTOP})
|
const page = await as(users.alice, spacePath(scenario.space("space").url), {context: DESKTOP})
|
||||||
const term = page.getByPlaceholder("Search for people...")
|
const term = searchTerm(page)
|
||||||
const cards = page.locator(".card.card-interactive")
|
const cards = searchResults(page)
|
||||||
const bobCard = cards.filter({hasText: "Bob Barnacle"})
|
const bobCard = cards.filter({hasText: "Bob Barnacle"})
|
||||||
const carolCard = cards.filter({hasText: "Carol Cutter"})
|
const carolCard = cards.filter({hasText: "Carol Cutter"})
|
||||||
const reputation = () => sidebar(page).locator(".card.card-sm").filter({hasText: "Reputation"})
|
const reputation = () => sidebar(page).locator(".card.card-sm").filter({hasText: "Reputation"})
|
||||||
|
|
@ -300,6 +297,7 @@ test("US-077 see web-of-trust standing build up", async ({seed, as}) => {
|
||||||
const ring = async () =>
|
const ring = async () =>
|
||||||
Number(await bobCard.locator("circle.wot-highlight").getAttribute("stroke-dashoffset"))
|
Number(await bobCard.locator("circle.wot-highlight").getAttribute("stroke-dashoffset"))
|
||||||
|
|
||||||
|
await openSearch(page)
|
||||||
await term.fill("Barnacle")
|
await term.fill("Barnacle")
|
||||||
|
|
||||||
await expect(bobCard).toHaveCount(1)
|
await expect(bobCard).toHaveCount(1)
|
||||||
|
|
@ -315,7 +313,7 @@ test("US-077 see web-of-trust standing build up", async ({seed, as}) => {
|
||||||
await expect(reputation()).toContainText(/\b0 \/ 100\b/)
|
await expect(reputation()).toContainText(/\b0 \/ 100\b/)
|
||||||
await expect(reputation()).toContainText("This user is not well known in your network.")
|
await expect(reputation()).toContainText("This user is not well known in your network.")
|
||||||
|
|
||||||
await page.locator('.primary-nav a[href="/people"]').click()
|
await openSearch(page)
|
||||||
await term.fill("Cutter")
|
await term.fill("Cutter")
|
||||||
|
|
||||||
await expect(carolCard).toHaveCount(1)
|
await expect(carolCard).toHaveCount(1)
|
||||||
|
|
@ -328,7 +326,7 @@ test("US-077 see web-of-trust standing build up", async ({seed, as}) => {
|
||||||
|
|
||||||
await expect(page.getByRole("button", {name: "Unfollow", exact: true})).toBeVisible()
|
await expect(page.getByRole("button", {name: "Unfollow", exact: true})).toBeVisible()
|
||||||
|
|
||||||
await page.locator('.primary-nav a[href="/people"]').click()
|
await openSearch(page)
|
||||||
await term.fill("Barnacle")
|
await term.fill("Barnacle")
|
||||||
|
|
||||||
await expect(bobCard).toHaveCount(1)
|
await expect(bobCard).toHaveCount(1)
|
||||||
|
|
@ -733,10 +731,10 @@ test("US-082 mute an account", async ({seed, as}) => {
|
||||||
// Through the nav rather than a fresh navigation, so what is on screen is what the client that
|
// Through the nav rather than a fresh navigation, so what is on screen is what the client that
|
||||||
// just published the mute believes.
|
// just published the mute believes.
|
||||||
const openBobsProfile = async () => {
|
const openBobsProfile = async () => {
|
||||||
await page.locator('.primary-nav a[href="/people"]').click()
|
await openSearch(page)
|
||||||
await page.getByPlaceholder("Search for people...").fill("Barnacle")
|
await searchTerm(page).fill("Barnacle")
|
||||||
|
|
||||||
const card = page.locator(".card.card-interactive").filter({hasText: "Bob Barnacle"})
|
const card = searchResults(page).filter({hasText: "Bob Barnacle"})
|
||||||
|
|
||||||
await expect(card).toHaveCount(1)
|
await expect(card).toHaveCount(1)
|
||||||
await viewProfile(card).click()
|
await viewProfile(card).click()
|
||||||
|
|
|
||||||
|
|
@ -683,7 +683,7 @@ test("US-027 find a past message and jump to it", async ({seed, as}) => {
|
||||||
await pageBar(page).getByRole("button", {name: "Search"}).click()
|
await pageBar(page).getByRole("button", {name: "Search"}).click()
|
||||||
|
|
||||||
const term = page.getByPlaceholder("Search this room...")
|
const term = page.getByPlaceholder("Search this room...")
|
||||||
const search = dialog(page, "Search Content")
|
const search = dialog(page, "Search")
|
||||||
|
|
||||||
await term.fill("harbor")
|
await term.fill("harbor")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
import MenuSettings from "@app/components/MenuSettings.svelte"
|
import MenuSettings from "@app/components/MenuSettings.svelte"
|
||||||
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
|
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
|
||||||
import PrimaryNavSpaces from "@app/components/PrimaryNavSpaces.svelte"
|
import PrimaryNavSpaces from "@app/components/PrimaryNavSpaces.svelte"
|
||||||
|
import Search from "@app/components/Search.svelte"
|
||||||
import {userSpaceUrls} from "@app/rooms"
|
import {userSpaceUrls} from "@app/rooms"
|
||||||
import {PLATFORM_RELAYS} from "@app/env"
|
import {PLATFORM_RELAYS} from "@app/env"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
@ -32,6 +33,8 @@
|
||||||
|
|
||||||
const showSettingsMenu = () => pushModal(MenuSettings)
|
const showSettingsMenu = () => pushModal(MenuSettings)
|
||||||
|
|
||||||
|
const showSearch = () => pushModal(Search)
|
||||||
|
|
||||||
const anySpaceNotifications = $derived(
|
const anySpaceNotifications = $derived(
|
||||||
$userSpaceUrls.some(p => $notifications.has(makeSpacePath(p))),
|
$userSpaceUrls.some(p => $notifications.has(makeSpacePath(p))),
|
||||||
)
|
)
|
||||||
|
|
@ -56,7 +59,7 @@
|
||||||
notification={$notifications.has("/chat")}>
|
notification={$notifications.has("/chat")}>
|
||||||
<ImageIcon alt="Messages" src={Letter} size={8} />
|
<ImageIcon alt="Messages" src={Letter} size={8} />
|
||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
<PrimaryNavItem title="Search" href="/people">
|
<PrimaryNavItem title="Search" onclick={showSearch}>
|
||||||
<ImageIcon alt="Search" src={Magnifier} size={8} />
|
<ImageIcon alt="Search" src={Magnifier} size={8} />
|
||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -74,7 +77,7 @@
|
||||||
{#if PLATFORM_RELAYS.length === 1}
|
{#if PLATFORM_RELAYS.length === 1}
|
||||||
<PrimaryNavItemSpace url={PLATFORM_RELAYS[0]} />
|
<PrimaryNavItemSpace url={PLATFORM_RELAYS[0]} />
|
||||||
{:else}
|
{:else}
|
||||||
<PrimaryNavItem href="/people">
|
<PrimaryNavItem onclick={showSearch}>
|
||||||
<ImageIcon alt="Search" src={Magnifier} size={8} />
|
<ImageIcon alt="Search" src={Magnifier} size={8} />
|
||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,13 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount, tick} from "svelte"
|
import {MESSAGE} from "@welshman/util"
|
||||||
import {debounce} from "throttle-debounce"
|
|
||||||
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 Badge from "@lib/components/Badge.svelte"
|
|
||||||
import Modal from "@lib/components/Modal.svelte"
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
|
||||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
import NoteCard from "@app/components/NoteCard.svelte"
|
import RoomName from "@app/components/RoomName.svelte"
|
||||||
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
import SearchBody from "@app/components/SearchBody.svelte"
|
||||||
import {CONTENT_KINDS} from "@app/content"
|
import {CONTENT_KINDS} from "@app/content"
|
||||||
import {getEventsForUrl} from "@app/repository"
|
import {deriveRoomMembers} from "@app/rooms"
|
||||||
import {network} from "@app/core"
|
|
||||||
import {popModal} from "@app/modal"
|
|
||||||
import {goToEvent} from "@app/routes"
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
|
|
@ -29,146 +16,17 @@
|
||||||
|
|
||||||
const {url, h}: Props = $props()
|
const {url, h}: Props = $props()
|
||||||
|
|
||||||
let term = $state("")
|
const filter = {kinds: [MESSAGE, ...CONTENT_KINDS], "#h": [h]}
|
||||||
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) => {
|
const members = deriveRoomMembers(url, h)
|
||||||
if (!searchTerm?.trim()) {
|
|
||||||
loading = false
|
|
||||||
results = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const filter: Filter = {
|
|
||||||
kinds: [MESSAGE, ...CONTENT_KINDS],
|
|
||||||
"#h": [h],
|
|
||||||
search: searchTerm.trim(),
|
|
||||||
}
|
|
||||||
|
|
||||||
results = sortEventsDesc(getEventsForUrl(url, [filter]))
|
|
||||||
|
|
||||||
try {
|
|
||||||
const events = await $network.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>
|
</script>
|
||||||
|
|
||||||
<Modal class="flex flex-col gap-2">
|
<Modal class="flex flex-col gap-2">
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
<ModalTitle>Search Content</ModalTitle>
|
<ModalTitle>Search</ModalTitle>
|
||||||
<ModalSubtitle>
|
<ModalSubtitle>
|
||||||
on <span class="text-primary">{displayRelayUrl(url)}</span>
|
in <RoomName {url} {h} class="text-primary" />
|
||||||
</ModalSubtitle>
|
</ModalSubtitle>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalBody>
|
<SearchBody {url} {filter} placeholder="Search this room..." relays={[url]} members={$members} />
|
||||||
<label class="input input-sm 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="card card-sm card-interactive flex flex-col gap-2"
|
|
||||||
onclick={() => onResultClick(event)}>
|
|
||||||
<NoteCard minimal {event}>
|
|
||||||
<NoteContentMinimal {event} />
|
|
||||||
</NoteCard>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Badge variant="neutral">
|
|
||||||
{getAgeLabel(event.created_at)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
{/each}
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</ModalBody>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
36
src/app/components/Search.svelte
Normal file
36
src/app/components/Search.svelte
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {MESSAGE} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import Badge from "@lib/components/Badge.svelte"
|
||||||
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
|
import RelayName from "@app/components/RelayName.svelte"
|
||||||
|
import SearchBody from "@app/components/SearchBody.svelte"
|
||||||
|
import {CONTENT_KINDS} from "@app/content"
|
||||||
|
import {app} from "@app/core"
|
||||||
|
import {userSpaceUrls} from "@app/rooms"
|
||||||
|
|
||||||
|
const filter = {kinds: [MESSAGE, ...CONTENT_KINDS]}
|
||||||
|
|
||||||
|
const getSpaceUrl = (event: TrustedEvent) =>
|
||||||
|
$userSpaceUrls.find(url => $app.tracker.getRelays(event.id).has(url))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal class="flex flex-col gap-2">
|
||||||
|
<ModalHeader>
|
||||||
|
<ModalTitle>Search</ModalTitle>
|
||||||
|
<ModalSubtitle>across all your spaces</ModalSubtitle>
|
||||||
|
</ModalHeader>
|
||||||
|
<SearchBody placeholder="Search your spaces..." relays={$userSpaceUrls} {filter}>
|
||||||
|
{#snippet badges(event)}
|
||||||
|
{@const url = getSpaceUrl(event)}
|
||||||
|
{#if url}
|
||||||
|
<Badge variant="neutral">
|
||||||
|
<RelayName {url} />
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</SearchBody>
|
||||||
|
</Modal>
|
||||||
167
src/app/components/SearchBody.svelte
Normal file
167
src/app/components/SearchBody.svelte
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type {Snippet} from "svelte"
|
||||||
|
import {onMount, tick} from "svelte"
|
||||||
|
import {debounce} from "throttle-debounce"
|
||||||
|
import {formatTimestampRelative, groupBy, now, uniqBy, DAY, WEEK} from "@welshman/lib"
|
||||||
|
import type {Filter, TrustedEvent} from "@welshman/util"
|
||||||
|
import {sortEventsDesc} 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 Badge from "@lib/components/Badge.svelte"
|
||||||
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
|
import PeopleItem from "@app/components/PeopleItem.svelte"
|
||||||
|
import NoteCard from "@app/components/NoteCard.svelte"
|
||||||
|
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
||||||
|
import {getEventsForUrl} from "@app/repository"
|
||||||
|
import {network, profiles} from "@app/core"
|
||||||
|
import {popModal} from "@app/modal"
|
||||||
|
import {pushToast} from "@app/toast"
|
||||||
|
import {goToEvent} from "@app/routes"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
placeholder: string
|
||||||
|
relays: string[]
|
||||||
|
filter: Filter
|
||||||
|
url?: string
|
||||||
|
members?: string[]
|
||||||
|
badges?: Snippet<[TrustedEvent]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const {placeholder, relays, filter, url, members, badges}: Props = $props()
|
||||||
|
|
||||||
|
const profileSearch = $profiles.profileSearch
|
||||||
|
|
||||||
|
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 filters = [{...filter, search: searchTerm.trim()}]
|
||||||
|
|
||||||
|
results = sortEventsDesc(
|
||||||
|
uniqBy(
|
||||||
|
(e: TrustedEvent) => e.id,
|
||||||
|
relays.flatMap(relay => Array.from(getEventsForUrl(relay, filters))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await $network.load({relays, filters, signal: controller.signal})
|
||||||
|
|
||||||
|
results = sortEventsDesc(uniqBy((e: TrustedEvent) => e.id, [...events, ...results]))
|
||||||
|
} catch (error) {
|
||||||
|
// Superseded searches abort; the search that replaced this one owns loading from here
|
||||||
|
if (error instanceof DOMException && error.name === "AbortError") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(error)
|
||||||
|
pushToast({theme: "error", message: "Something went wrong while searching."})
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const onInput = () => {
|
||||||
|
loading = true
|
||||||
|
controller?.abort()
|
||||||
|
controller = new AbortController()
|
||||||
|
doSearch(term, controller)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAgeSection = (createdAt: number) => {
|
||||||
|
const age = now() - createdAt
|
||||||
|
|
||||||
|
if (age <= DAY) {
|
||||||
|
return "day"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (age <= WEEK) {
|
||||||
|
return "week"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "older"
|
||||||
|
}
|
||||||
|
|
||||||
|
const onResultClick = (event: TrustedEvent) => {
|
||||||
|
popModal()
|
||||||
|
goToEvent(event, {keepFocus: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
const people = $derived.by(() => {
|
||||||
|
const memberSet = members && new Set(members)
|
||||||
|
// An empty term matches every profile there is, which is not what an unused search looks like
|
||||||
|
const matches = term ? $profileSearch.searchValues(term) : []
|
||||||
|
|
||||||
|
return matches.filter(pubkey => !memberSet || memberSet.has(pubkey)).slice(0, 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
const eventsByAge = $derived(groupBy(e => getAgeSection(e.created_at), results))
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
tick().then(() => input?.focus())
|
||||||
|
|
||||||
|
return () => controller?.abort()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ModalBody>
|
||||||
|
<label class="input input-sm input-group 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}
|
||||||
|
oninput={onInput} />
|
||||||
|
</label>
|
||||||
|
{#if people.length > 0}
|
||||||
|
<p class="text-xs uppercase tracking-wide opacity-60">People</p>
|
||||||
|
{#each people as pubkey (pubkey)}
|
||||||
|
<PeopleItem {pubkey} {url} />
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{#if loading}
|
||||||
|
<Spinner {loading}>Searching...</Spinner>
|
||||||
|
{:else if term && results.length === 0 && people.length === 0}
|
||||||
|
<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="card card-sm card-interactive flex flex-col gap-2"
|
||||||
|
onclick={() => onResultClick(event)}>
|
||||||
|
<NoteCard minimal {event} {url}>
|
||||||
|
<NoteContentMinimal {event} />
|
||||||
|
</NoteCard>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Badge variant="neutral">
|
||||||
|
{formatTimestampRelative(event.created_at)}
|
||||||
|
</Badge>
|
||||||
|
{@render badges?.(event)}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</ModalBody>
|
||||||
|
|
@ -1,28 +1,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount, tick} from "svelte"
|
import {MESSAGE, displayRelayUrl, tagSpec, tagValue} from "@welshman/util"
|
||||||
import {debounce} from "throttle-debounce"
|
|
||||||
import {groupBy, uniqBy, now, MINUTE, HOUR, DAY, WEEK} from "@welshman/lib"
|
|
||||||
import type {TrustedEvent, Filter} from "@welshman/util"
|
|
||||||
import {MESSAGE, sortEventsDesc, displayRelayUrl, tagValue, tagSpec} 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 Badge from "@lib/components/Badge.svelte"
|
import Badge from "@lib/components/Badge.svelte"
|
||||||
import Modal from "@lib/components/Modal.svelte"
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
|
||||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
import RoomName from "@app/components/RoomName.svelte"
|
import RoomName from "@app/components/RoomName.svelte"
|
||||||
import NoteCard from "@app/components/NoteCard.svelte"
|
import SearchBody from "@app/components/SearchBody.svelte"
|
||||||
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
|
||||||
import {CONTENT_KINDS} from "@app/content"
|
import {CONTENT_KINDS} from "@app/content"
|
||||||
import {getEventsForUrl} from "@app/repository"
|
import {relayMemberLists} from "@app/core"
|
||||||
import {network} from "@app/core"
|
|
||||||
import {popModal} from "@app/modal"
|
|
||||||
import {pushToast} from "@app/toast"
|
|
||||||
import {goToEvent} from "@app/routes"
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
|
|
@ -30,154 +16,31 @@
|
||||||
|
|
||||||
const {url}: Props = $props()
|
const {url}: Props = $props()
|
||||||
|
|
||||||
let term = $state("")
|
const filter = {kinds: [MESSAGE, ...CONTENT_KINDS]}
|
||||||
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) => {
|
const members = $relayMemberLists.forUrl(url)
|
||||||
if (!searchTerm?.trim()) {
|
|
||||||
loading = false
|
|
||||||
results = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const filter: Filter = {
|
|
||||||
kinds: [MESSAGE, ...CONTENT_KINDS],
|
|
||||||
search: searchTerm.trim(),
|
|
||||||
}
|
|
||||||
|
|
||||||
results = sortEventsDesc(getEventsForUrl(url, [filter]))
|
|
||||||
|
|
||||||
try {
|
|
||||||
const events = await $network.load({
|
|
||||||
relays: [url],
|
|
||||||
signal: controller.signal,
|
|
||||||
filters: [filter],
|
|
||||||
})
|
|
||||||
|
|
||||||
results = sortEventsDesc(uniqBy((e: TrustedEvent) => e.id, [...events, ...results]))
|
|
||||||
} catch (error) {
|
|
||||||
// Superseded searches abort; the search that replaced this one owns loading from here
|
|
||||||
if (error instanceof DOMException && error.name === "AbortError") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(error)
|
|
||||||
pushToast({theme: "error", message: "Something went wrong while searching."})
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<Modal class="flex flex-col gap-2">
|
<Modal class="flex flex-col gap-2">
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
<ModalTitle>Search Content</ModalTitle>
|
<ModalTitle>Search</ModalTitle>
|
||||||
<ModalSubtitle>
|
<ModalSubtitle>
|
||||||
on <span class="text-primary">{displayRelayUrl(url)}</span>
|
on <span class="text-primary">{displayRelayUrl(url)}</span>
|
||||||
</ModalSubtitle>
|
</ModalSubtitle>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalBody>
|
<SearchBody
|
||||||
<label class="input input-sm input-group flex w-full items-center gap-2">
|
{url}
|
||||||
<Icon size={4} icon={Magnifier} />
|
{filter}
|
||||||
<input
|
placeholder="Search this space..."
|
||||||
bind:this={input}
|
relays={[url]}
|
||||||
bind:value={term}
|
members={$members?.pubkeys() ?? []}>
|
||||||
class="min-w-0 grow"
|
{#snippet badges(event)}
|
||||||
type="text"
|
{@const h = tagValue(tagSpec("h"), event.tags)}
|
||||||
placeholder="Search this space..."
|
{#if h}
|
||||||
oninput={onInput} />
|
<Badge variant="neutral">
|
||||||
</label>
|
<RoomName {url} {h} />
|
||||||
{#if loading}
|
</Badge>
|
||||||
<Spinner {loading}>Searching...</Spinner>
|
{/if}
|
||||||
{:else if eventsByAge.size === 0 && term}
|
{/snippet}
|
||||||
<Spinner {loading}>No results found.</Spinner>
|
</SearchBody>
|
||||||
{: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 = tagValue(tagSpec("h"), event.tags)}
|
|
||||||
<Button
|
|
||||||
class="card card-sm card-interactive flex flex-col gap-2"
|
|
||||||
onclick={() => onResultClick(event)}>
|
|
||||||
<NoteCard minimal {event}>
|
|
||||||
<NoteContentMinimal {event} />
|
|
||||||
</NoteCard>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Badge variant="neutral">
|
|
||||||
{getAgeLabel(event.created_at)}
|
|
||||||
</Badge>
|
|
||||||
{#if h}
|
|
||||||
<Badge variant="neutral">
|
|
||||||
<RoomName {url} {h} />
|
|
||||||
</Badge>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
{/each}
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</ModalBody>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ const staticTitles = new Map<string, string>([
|
||||||
["/spaces/[relay]/admin", "Space Administration"],
|
["/spaces/[relay]/admin", "Space Administration"],
|
||||||
["/chat", "Messages"],
|
["/chat", "Messages"],
|
||||||
["/join", "Join Space"],
|
["/join", "Join Space"],
|
||||||
["/people", "Find People"],
|
|
||||||
["/settings/about", "About"],
|
["/settings/about", "About"],
|
||||||
["/settings/profile", "Profile Settings"],
|
["/settings/profile", "Profile Settings"],
|
||||||
["/settings/content", "Content Settings"],
|
["/settings/content", "Content Settings"],
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import {onMount} from "svelte"
|
|
||||||
import {createScroller, isMobile} from "@lib/html"
|
|
||||||
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 {profiles} from "@app/core"
|
|
||||||
import PeopleItem from "@app/components/PeopleItem.svelte"
|
|
||||||
|
|
||||||
const search = $profiles.profileSearch
|
|
||||||
|
|
||||||
let term = $state("")
|
|
||||||
let limit = $state(10)
|
|
||||||
let element: Element | undefined = $state()
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const scroller = createScroller({
|
|
||||||
element: element!,
|
|
||||||
onScroll: () => {
|
|
||||||
limit += 10
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => scroller.stop()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Page>
|
|
||||||
<PageContent>
|
|
||||||
<ContentSearch>
|
|
||||||
{#snippet input()}
|
|
||||||
<label class="flex gap-2 input input-group 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="flex flex-col gap-2 h-full" bind:this={element}>
|
|
||||||
{#each $search.searchValues(term).slice(0, limit) as pubkey (pubkey)}
|
|
||||||
<PeopleItem {pubkey} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</ContentSearch>
|
|
||||||
</PageContent>
|
|
||||||
</Page>
|
|
||||||
Loading…
Reference in a new issue