Redesign classifieds as a browsable marketplace (#383)

This commit is contained in:
Aditya Chaudhary 2026-09-10 18:21:49 +00:00 committed by hodlbod
parent 56ee9b4c44
commit e6ce3e5ec8
12 changed files with 375 additions and 159 deletions

View file

@ -685,7 +685,8 @@ test("US-051 post, edit, and close out a classified listing", async ({seed, as})
const listing = page.getByRole("link").filter({hasText: "Vintage Road Bike"})
await expect(listing).toBeVisible()
await expect(listing).toContainText("1200")
// The card groups thousands, and which separator depends on the browser locale.
await expect(listing).toContainText(/1[,.\s]?200/)
await expect(listing).toContainText("#bicycles")
await expect(listing.locator('img[src^="https://space.test/"]')).toBeVisible()

74
src/app/classifieds.ts Normal file
View file

@ -0,0 +1,74 @@
import {max, partition, pushToMapKey, sortBy, spec} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {CLASSIFIED, tagSpec, tagValue} from "@welshman/util"
import {Classified} from "@welshman/domain"
import {normalizeTopic} from "@lib/util"
import {reader} from "@app/core"
export const CLASSIFIED_STATUS_TABS = [
{value: "all", label: "All"},
{value: "active", label: "Active"},
{value: "sold", label: "Sold"},
]
export const SUGGESTED_TOPICS = ["for sale", "wanted", "job", "service", "free"]
// Splits a feed into listings and their comments, scoring each listing by last activity.
export const partitionListings = (events: TrustedEvent[]) => {
const scores = new Map<string, number[]>()
const [listings, comments] = partition(spec({kind: CLASSIFIED}), events)
for (const comment of comments) {
const id = tagValue(tagSpec("E"), comment.tags)
if (id) {
pushToMapKey(scores, id, comment.created_at)
}
}
const activeAt = new Map<string, number>()
for (const listing of listings) {
activeAt.set(listing.id, max([...(scores.get(listing.id) || []), listing.created_at]))
}
return {listings, activeAt}
}
export const deriveTopicCounts = (events: TrustedEvent[]) => {
const counts = new Map<string, number>()
for (const event of events) {
for (const topic of reader(Classified)(event).topics() ?? []) {
const normalized = normalizeTopic(topic)
counts.set(normalized, (counts.get(normalized) || 0) + 1)
}
}
return sortBy(([, count]) => -count, Array.from(counts.entries()))
}
export const getStatus = (event: TrustedEvent) => reader(Classified)(event).status() ?? "active"
export const matchesTopic = (event: TrustedEvent, topic: string) =>
!topic ||
Boolean(
reader(Classified)(event)
.topics()
?.some(t => normalizeTopic(t) === topic),
)
export const matchesQuery = (event: TrustedEvent, query: string) => {
const q = query.trim().toLowerCase()
if (!q) return true
const classified = reader(Classified)(event)
const haystack = [classified.title(), classified.summary(), event.content]
.filter(Boolean)
.join(" ")
.toLowerCase()
return haystack.includes(q)
}

View file

@ -2,6 +2,7 @@
import {uniq} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {Classified} from "@welshman/domain"
import {goto} from "$app/navigation"
import Pen2 from "@assets/icons/pen-2.svg?dataurl"
import {normalizeTopic} from "@lib/util"
import Link from "@lib/components/Link.svelte"
@ -17,7 +18,7 @@
import EventActions from "@app/components/EventActions.svelte"
import ClassifiedEdit from "@app/components/ClassifiedEdit.svelte"
import {reader, user} from "@app/core"
import {makeSpacePath} from "@app/routes"
import {makeClassifiedPath, makeSpacePath} from "@app/routes"
import {pushModal} from "@app/modal"
type Props = {
@ -26,9 +27,20 @@
showRoom?: boolean
showActivity?: boolean
context: FeedContext
// The grid card lays these out itself, so it opts out of both.
showTopics?: boolean
showStatus?: boolean
}
const {url, event, showRoom, showActivity, context}: Props = $props()
const {
url,
event,
showRoom,
showActivity,
context,
showTopics = true,
showStatus = true,
}: Props = $props()
// Editing a listing hands this a new version of the event, so every value read off it has to be
// recomputed rather than captured when the component was created.
@ -38,27 +50,43 @@
const editClassified = () => pushModal(ClassifiedEdit, {url, event})
const filterByTopic = (topic: string) => (e: MouseEvent) => {
e.stopPropagation()
e.preventDefault()
goto(`${makeClassifiedPath(url)}?topic=${encodeURIComponent(normalizeTopic(topic))}`)
}
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
</script>
<div class="flex grow flex-wrap justify-end gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full">
Posted in #<RoomName {h} {url} />
</Link>
<div class="flex grow flex-wrap items-center justify-end gap-2">
{#if (h && showRoom) || (showTopics && uniq(topics).length > 0)}
<div class="flex min-w-0 flex-wrap items-center gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full">
Posted in #<RoomName {h} {url} />
</Link>
{/if}
{#if showTopics}
{#each uniq(topics) as topic (topic)}
<button
type="button"
class="badge badge-neutral badge-sm cursor-pointer font-normal"
onclick={filterByTopic(topic)}>
#{normalizeTopic(topic)}
</button>
{/each}
{/if}
</div>
<span class="h-6 w-px shrink-0 bg-line"></span>
{/if}
<div class="flex min-w-0 flex-wrap gap-2">
{#each uniq(topics) as topic (topic)}
<button type="button" class="button button-xs rounded-full font-normal">
#{normalizeTopic(topic)}
</button>
{/each}
</div>
<ThunkStatusOrDeleted {event} {context}>
{#snippet status()}
<ClassifiedStatus {event} />
{#if showStatus}
<ClassifiedStatus {event} />
{/if}
{/snippet}
<ReactionSummary
{url}

View file

@ -1,11 +1,13 @@
<script lang="ts">
import type {Snippet} from "svelte"
import {removeUndefined, randomId, uniq} from "@welshman/lib"
import cx from "classnames"
import {removeUndefined, randomId, uniq, toggle} from "@welshman/lib"
import {relay} from "@welshman/util"
import {publish} from "@welshman/app"
import {Classified} from "@welshman/domain"
import {isMobile, preventDefault} from "@lib/html"
import {normalizeTopic} from "@lib/util"
import {SUGGESTED_TOPICS} from "@app/classifieds"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
@ -148,6 +150,10 @@
content = json
}
const toggleTopic = (topic: string) => () => {
topics = toggle(topic, topics)
}
const editor = makeEditor({url, submit, onChange, content})
$effect(() => {
@ -187,10 +193,25 @@
</Field>
<Field>
{#snippet label()}
<p>Topics</p>
<p>Category</p>
{/snippet}
{#snippet input()}
<TopicMultiSelect bind:value={topics} />
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2">
{#each SUGGESTED_TOPICS as topic (topic)}
<button
type="button"
class={cx(
"button button-xs rounded-full",
topics.includes(topic) ? "button-primary" : "button-neutral",
)}
onclick={toggleTopic(topic)}>
#{topic}
</button>
{/each}
</div>
<TopicMultiSelect bind:value={topics} />
</div>
{/snippet}
</Field>
<Field>
@ -198,7 +219,7 @@
<p>Price*</p>
{/snippet}
{#snippet input()}
<div class="join grid grid-cols-2">
<div class="grid grid-cols-2 gap-2">
<label class="input flex w-full items-center gap-2">
<input bind:value={price} class="grow w-32" type="number" />
</label>

View file

@ -1,18 +1,20 @@
<script lang="ts">
import {formatTimestamp} from "@welshman/lib"
import cx from "classnames"
import {formatTimestampRelative, uniq} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {getAddress} from "@welshman/util"
import {Classified} from "@welshman/domain"
import Gallery from "@assets/icons/gallery.svg?dataurl"
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
import {normalizeTopic} from "@lib/util"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Cv from "@lib/components/Cv.svelte"
import CurrencySymbol from "@lib/components/CurrencySymbol.svelte"
import {reader} from "@app/core"
import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte"
import Content from "@app/components/Content.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import ClassifiedActions from "@app/components/ClassifiedActions.svelte"
import type {FeedContext} from "@app/feeds"
import RoomLink from "@app/components/RoomLink.svelte"
import UnreadDot from "@app/components/UnreadDot.svelte"
import {makeClassifiedPath} from "@app/routes"
@ -27,42 +29,76 @@
const path = $derived(makeClassifiedPath(url, getAddress(event)))
const classified = $derived(reader(Classified)(event))
const title = $derived(classified.title())
const h = $derived(classified.room())
const images = $derived(new Set(classified.images()))
const summary = $derived(classified.summary() || event.content)
const images = $derived(uniq(classified.images()))
const cover = $derived(images[0])
const extraImages = $derived(images.slice(1))
const price = $derived(classified.price())
const topics = $derived(uniq(classified.topics() ?? []).map(normalizeTopic))
const isSold = $derived(classified.status() === "sold")
// Zero is the form's default, not a claim that the listing is free.
const hasPrice = $derived(Boolean(price && price.amount > 0))
</script>
<Cv tag={Link} class="relative flex flex-col gap-2 card card-interactive w-full" href={path}>
<UnreadDot {path} class="absolute right-3 top-3" />
{#if title}
<div class="flex w-full items-center justify-between gap-2">
<p class="text-xl">
{title} —
<CurrencySymbol code={price?.currency ?? "SAT"} />{price?.amount ?? 0}
</p>
<p class="text-sm opacity-75">
{formatTimestamp(event.created_at)}
</p>
</div>
{:else}
<p class="mb-3 h-0 text-xs opacity-75">
{formatTimestamp(event.created_at)}
</p>
{/if}
<Content {event} {url} expandMode="inline" />
<div class="grid grid-cols-3 sm:grid-cols-5">
{#each images as image (image)}
<ContentLinkBlock {event} value={{url: image}} />
{/each}
<Cv
tag={Link}
class="group card card-interactive card-media flex h-full w-full flex-col"
href={makeClassifiedPath(url, getAddress(event))}>
<div class="relative aspect-[4/3] w-full overflow-hidden bg-surface-more">
<UnreadDot {path} class="absolute right-3 top-3" />
{#if cover}
<img
src={cover}
alt=""
class={cx(
"h-full w-full object-cover transition-transform duration-300 group-hover:scale-105",
isSold && "opacity-40 grayscale",
)} />
{:else}
<div class="flex h-full w-full items-center justify-center">
<Icon icon={CaseMinimalistic} size={10} class="opacity-20" />
</div>
{/if}
{#if isSold}
<span class="badge badge-neutral badge-sm absolute top-3 left-3">Sold</span>
{/if}
{#if extraImages.length > 0}
<span
class="absolute right-3 bottom-3 flex items-center gap-1 rounded-full bg-black/60 px-2 py-1 text-xs font-bold text-white">
<Icon icon={Gallery} size={3} />
{images.length}
</span>
{/if}
</div>
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by
<ProfileLink pubkey={event.pubkey} {url} />
{#if h}
in <RoomLink {url} {h} />
{/if}
<div class="flex min-w-0 grow flex-col gap-2 p-4">
<p class="line-clamp-2 wrap-break-word font-bold leading-snug">{title}</p>
{#if hasPrice}
<p class="text-xl leading-none font-bold text-primary">
<CurrencySymbol code={price?.currency ?? "SAT"} />{price?.amount.toLocaleString()}
</p>
{/if}
{#if summary}
<p class="line-clamp-2 wrap-break-word text-sm text-content-muted">{summary}</p>
{/if}
{#if topics.length > 0}
<div class="flex min-w-0 flex-wrap gap-1">
{#each topics.slice(0, 2) as topic (topic)}
<span class="badge badge-sm rounded-full font-normal">#{topic}</span>
{/each}
{#if topics.length > 2}
<span class="badge badge-sm rounded-full font-normal">+{topics.length - 2}</span>
{/if}
</div>
{/if}
</div>
<div
class="flex min-w-0 items-center justify-between gap-2 border-t border-solid border-line px-4 py-3">
<span class="flex min-w-0 flex-col text-xs text-content-muted">
<span class="truncate">
<ProfileLink pubkey={event.pubkey} {url} />
</span>
<span class="truncate">{formatTimestampRelative(event.created_at)}</span>
</span>
<ClassifiedActions showActivity {url} {event} {context} />
<ClassifiedActions {url} {event} {context} showTopics={false} showStatus={false} />
</div>
</Cv>

View file

@ -15,7 +15,7 @@
</script>
{#if status}
<Badge variant={status !== "active" ? "primary" : "neutral"} class="rounded-full">
<Badge variant={status !== "active" ? "primary" : "neutral"} class="badge-sm rounded-full">
{ucFirst(status)}
</Badge>
{/if}

View file

@ -84,7 +84,7 @@
{#each value as topic (topic)}
<div class="flex-inline badge badge-neutral mr-1 gap-1">
<Button class="flex items-center" onclick={() => removeTopic(topic)}>
<Icon icon={CloseCircle} size={4} class="-ml-1 mt-px" />
<Icon icon={CloseCircle} size={4} class="mt-px" />
</Button>
<span>#{topic}</span>
</div>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import {currencyOptions} from "@lib/currency"
import {getCurrencyOption} from "@lib/currency"
export let code
</script>
@ -7,5 +7,5 @@
{#if code.match(/^sats?$/i)}
<span style="font-family: Satoshis; font-size: 1.2em;">!</span>
{:else}
{currencyOptions.find(c => c.code)?.symbol || code}
{getCurrencyOption(code).symbol}
{/if}

View file

@ -103,9 +103,9 @@
alt="Upload preview"
class="h-full w-full object-cover rounded-2xl" />
<Button
class="button button-neutral bg-surface absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full"
class="button button-neutral button-xs button-circle bg-surface absolute right-1 top-1"
onclick={() => removeItem(index)}>
<Icon icon={CloseCircle} size={6} />
<Icon icon={CloseCircle} size={4} />
</Button>
</div>
{/each}

View file

@ -26,6 +26,11 @@
box-shadow: none;
}
/* content reaches the card's edges; padding moves to its inner sections */
&.card-media {
@apply overflow-hidden p-0;
}
&.card-primary {
--accent: var(--primary);
}

View file

@ -2,14 +2,18 @@
import {onDestroy, onMount} from "svelte"
import {readable} from "svelte/store"
import type {Readable} from "svelte/store"
import cx from "classnames"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import {sortBy, partition, spec, max, pushToMapKey} from "@welshman/lib"
import {sortBy} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {CLASSIFIED, tagSpec, tagValue} from "@welshman/util"
import {CLASSIFIED} from "@welshman/util"
import {Classified} from "@welshman/domain"
import {fly} from "@lib/transition"
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
import Add from "@assets/icons/add.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 PageContent from "@lib/components/PageContent.svelte"
@ -20,6 +24,16 @@
import {decodeRelay} from "@app/relays"
import {makeCommentFilter} from "@app/content"
import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds"
import {makeClassifiedPath} from "@app/routes"
import {
CLASSIFIED_STATUS_TABS,
partitionListings,
deriveTopicCounts,
getStatus,
matchesTopic,
matchesQuery,
} from "@app/classifieds"
import {reader} from "@app/core"
import {pushModal} from "@app/modal"
const url = decodeRelay($page.params.relay!)
@ -27,28 +41,65 @@
onDestroy(context.cleanup)
const createClassified = () => pushModal(ClassifiedCreate, {url})
const setTopic = (value?: string) => () => {
const path = makeClassifiedPath(url) + (value ? `?topic=${encodeURIComponent(value)}` : "")
goto(path, {replaceState: true, noScroll: true})
}
const setTab = (value: string) => () => {
tab = value
}
let older: Maybe<ReturnType<typeof makeScrollLoader>> = $state()
const loading = $derived(isFeedLoading($older))
const exhausted = $derived($older?.status === "exhausted")
let tab = $state("all")
let sort = $state("active")
let query = $state("")
let element: HTMLElement | undefined = $state()
let events: Readable<TrustedEvent[]> = $state(readable([]))
const createClassified = () => pushModal(ClassifiedCreate, {url})
const topic = $derived($page.url.searchParams.get("topic") ?? "")
const items = $derived.by(() => {
const scores = new Map<string, number[]>()
const [goals, comments] = partition(spec({kind: CLASSIFIED}), $events)
const {listings, activeAt} = $derived(partitionListings($events))
const topicCounts = $derived(deriveTopicCounts(listings))
for (const comment of comments) {
const id = tagValue(tagSpec("E"), comment.tags)
// Narrowed by everything except the status tab, so its counts match what it shows.
const scoped = $derived(
listings.filter(event => matchesQuery(event, query) && matchesTopic(event, topic)),
)
if (id) {
pushToMapKey(scores, id, comment.created_at)
}
const counts = $derived.by(() => {
const result: Record<string, number> = {all: scoped.length, active: 0, sold: 0}
for (const event of scoped) {
const status = getStatus(event)
result[status] = (result[status] ?? 0) + 1
}
return sortBy(e => -max([...(scores.get(e.id) || []), e.created_at]), goals)
return result
})
const items = $derived.by(() => {
const matches = scoped.filter(event => tab === "all" || getStatus(event) === tab)
if (sort === "new") return sortBy(event => -event.created_at, matches)
if (sort === "price-asc") {
return sortBy(event => reader(Classified)(event).price()?.amount ?? Infinity, matches)
}
if (sort === "price-desc") {
return sortBy(event => -(reader(Classified)(event).price()?.amount ?? -1), matches)
}
return sortBy(event => -(activeAt.get(event.id) ?? event.created_at), matches)
})
onMount(() => {
@ -86,21 +137,82 @@
{/snippet}
</SpaceBar>
<PageContent bind:element class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
{#each items as event (event.id)}
<div in:fly>
<ClassifiedItem {url} {context} event={$state.snapshot(event)} />
<PageContent bind:element class="flex flex-col gap-3 p-2 sm:gap-4 sm:p-4">
{#if listings.length > 0}
<div class="flex flex-wrap items-center gap-2">
<label class="input flex w-auto min-w-48 grow items-center gap-2">
<Icon icon={Magnifier} class="shrink-0 text-content-muted" />
<input bind:value={query} class="grow" type="text" placeholder="Search listings..." />
</label>
<select class="select input w-full sm:w-auto" bind:value={sort}>
<option value="active">Recently active</option>
<option value="new">Newest</option>
<option value="price-asc">Price: low to high</option>
<option value="price-desc">Price: high to low</option>
</select>
</div>
{/each}
<p class="flex h-10 items-center justify-center py-20">
<Spinner {loading}>
{#if loading}
Looking for listings...
{:else if exhausted && items.length === 0}
No classified listings found.
{:else if exhausted}
That's all!
<!-- Scrolls on a phone, where wrapping this many chips would bury the listings. -->
<div
class="flex shrink-0 flex-nowrap items-center gap-2 overflow-x-auto pb-1 sm:flex-wrap sm:overflow-x-visible sm:pb-0">
{#each CLASSIFIED_STATUS_TABS as { value, label } (value)}
<Button
aria-pressed={tab === value}
class={cx(
"button button-sm shrink-0 rounded-full",
tab === value ? "button-primary" : "button-neutral",
)}
onclick={setTab(value)}>
{label}
<span class="opacity-75">{counts[value] ?? 0}</span>
</Button>
{/each}
{#if topicCounts.length > 0}
<span class="mx-1 h-6 w-px shrink-0 bg-line"></span>
{#each topicCounts as [value, count] (value)}
<Button
aria-pressed={topic === value}
class={cx(
"button button-sm shrink-0 rounded-full font-normal",
topic === value ? "button-primary" : "button-neutral",
)}
onclick={setTopic(topic === value ? undefined : value)}>
#{value}
<span class="opacity-75">{count}</span>
</Button>
{/each}
{/if}
</Spinner>
</p>
</div>
{/if}
<div class="grid gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 2xl:grid-cols-4">
{#each items as event (event.id)}
<div in:fly class="h-full min-w-0">
<ClassifiedItem {url} {context} event={$state.snapshot(event)} />
</div>
{/each}
</div>
{#if exhausted && listings.length === 0}
<div class="flex flex-col items-center gap-3 card w-full py-16 text-center">
<Icon icon={CaseMinimalistic} size={10} class="text-primary" />
<h2 class="text-xl font-bold">No listings yet</h2>
<p class="text-content-muted">
Classifieds let this space buy, sell, and find things together.
</p>
<Button class="button button-primary" onclick={createClassified}>
<Icon icon={Add} />
Post the first listing
</Button>
</div>
{:else}
<p class="flex h-10 items-center justify-center py-20">
<Spinner {loading}>
{#if loading}
Looking for listings...
{:else if exhausted && items.length === 0}
No listings match your filters.
{:else if exhausted}
That's all!
{/if}
</Spinner>
</p>
{/if}
</PageContent>

View file

@ -4,23 +4,16 @@
import {page} from "$app/stores"
import {sleep} from "@welshman/lib"
import type {MakeNonOptional} from "@welshman/lib"
import {getCommentFiltersForRoot} from "@welshman/util"
import {deriveEventsAsc} from "@welshman/store"
import {Classified} from "@welshman/domain"
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import ClassifiedActions from "@app/components/ClassifiedActions.svelte"
import CommentActions from "@app/components/CommentActions.svelte"
import EventReply from "@app/components/EventReply.svelte"
import {network, reader} from "@app/core"
import {deriveEvent, deriveEventsById} from "@app/repository"
import EventComments from "@app/components/EventComments.svelte"
import {reader} from "@app/core"
import {deriveEvent} from "@app/repository"
import {makeFeedContext} from "@app/feeds"
import {decodeRelay} from "@app/relays"
@ -29,37 +22,10 @@
const context = makeFeedContext({relays: [url]})
const event = deriveEvent(address, [url])
const classified = derived(event, $event => ($event ? reader(Classified)($event) : undefined))
const filters = $derived($event ? getCommentFiltersForRoot([$event]) : [])
const replies = $derived(deriveEventsAsc(deriveEventsById(filters)))
const back = () => history.back()
const openReply = () => {
showReply = true
}
const closeReply = () => {
showReply = false
}
const expand = () => {
showAll = true
}
onDestroy(context.cleanup)
let showAll = $state(false)
let showReply = $state(false)
$effect(() => {
if (filters.length > 0) {
const controller = new AbortController()
$network.request({relays: [url], filters, signal: controller.signal})
return () => controller.abort()
}
})
</script>
<SpaceBar {back}>
@ -70,40 +36,13 @@
<PageContent class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
{#if $event}
<div class="flex flex-col gap-3">
<NoteCard event={$event} {url} class="card z-feature w-full">
<div class="flex flex-col gap-3 ml-12">
<NoteContent showEntire event={$event} {url} />
<ClassifiedActions showRoom event={$event} {url} {context} />
</div>
</NoteCard>
{#if !showAll && $replies.length > 4}
<div class="flex justify-center">
<Button class="button button-link" onclick={expand}>
<Icon icon={SortVertical} />
Show all {$replies.length} replies
</Button>
</div>
{/if}
{#each $replies.slice(0, showAll ? undefined : 4) as reply (reply.id)}
<NoteCard event={reply} {url} class="card z-feature w-full">
<div class="flex flex-col gap-3 ml-12">
<NoteContent showEntire event={reply} {url} />
<CommentActions event={reply} {url} {context} />
</div>
</NoteCard>
{/each}
</div>
{#if showReply}
<EventReply {url} event={$event} onClose={closeReply} onSubmit={closeReply} />
{:else}
<div class="flex justify-end">
<Button class="button button-primary" onclick={openReply}>
<Icon icon={Reply} />
Reply to listing
</Button>
<NoteCard event={$event} {url} class="card z-feature w-full">
<div class="flex flex-col gap-3 ml-12">
<NoteContent showEntire event={$event} {url} />
<ClassifiedActions showRoom event={$event} {url} {context} />
</div>
{/if}
</NoteCard>
<EventComments event={$event} {url} {context} />
{:else}
<div class="flex justify-center py-20">
{#await sleep(5000)}