Use EventComments on the poll and calendar pages and publish comments through one publishComment

This commit is contained in:
Jon Staab 2026-09-25 21:50:13 -07:00
parent 9c3d77a2f2
commit b77c8eafd1
11 changed files with 84 additions and 210 deletions

View file

@ -10,7 +10,7 @@ and NIP-43 tables in [SKILL.md](SKILL.md). Routes are under `src/routes/`.
|---|---|---|---|---|
| Chat | `MESSAGE` (9) | none | `src/app/rooms.ts` | `spaces/[relay]/chat`, `spaces/[relay]/[h]` (`RoomChat`) |
| Threads | `THREAD` (11) | `Thread` | none | `spaces/[relay]/threads`, `threads/[id]` (`ThreadCreate`) |
| Comments | `COMMENT` (1111) | `Comment` | `src/app/content.ts` (`makeCommentFilter`) | `CommentCompose`, `EventReply` |
| Comments | `COMMENT` (1111) | `Comment` | `src/app/content.ts` (`makeCommentFilter`), `src/app/rooms.ts` (`publishComment`) | `CommentCompose`, `EventReply`, `EventComments` |
| Articles | `LONG_FORM` (30023) | `Article` | `src/app/articles.ts` | `spaces/[relay]/articles`, `articles/create`, `articles/[address]` |
| Calendar | `EVENT_TIME` (31923) | `TimeEvent` | `src/app/feeds.ts` (`makeCalendarFeed`) | `spaces/[relay]/calendar`, `calendar/[address]` (`CalendarEventForm`) |
| Classifieds | `CLASSIFIED` (30402) | `Classified` | `src/app/classifieds.ts` | `spaces/[relay]/classifieds`, `classifieds/[address]` (`ClassifiedForm`) |

View file

@ -379,8 +379,8 @@ Plugin mutators already return a `Command`: `roomLists.get().addRelay(url).then(
Some call sites call `thunks.get().publish({event, relays, delay})` directly. Anything that
honours the `send_delay` window does, because `Command` cannot carry it: room chat
(`RoomChat.svelte`), the comment composers (`CommentCompose.svelte` and `EventReply.svelte`) and
`publishRoomQuote` in `rooms.ts`. So do the push adapters and `ProfileDelete.svelte`. DMs go through
(`RoomChat.svelte`), and `publishComment` (behind both comment composers) and `publishRoomQuote` in
`rooms.ts`. So do the push adapters and `ProfileDelete.svelte`. DMs go through
`wraps.get().publish({event, recipients})`, which returns a merged thunk (see `reactions.ts`).
NIP-86 calls (`relayManagement.get().forUrl(url)`) are not thunks. They return
`{result, error}`, and the caller handles `error`.

View file

@ -573,14 +573,17 @@ test("US-052 comment on and react to community posts", async ({seed, as}) => {
await expect(heroCard.getByRole("button", {name: PARTY})).toBeVisible()
await bob.getByRole("button", {name: /^Discussion/}).click()
await bob.getByRole("button", {name: "Leave comment"}).click()
await bob.getByRole("button", {name: "Add a comment"}).click()
const reply = bob.locator("form").filter({has: bob.locator(".note-editor")})
await noteEditor(reply).pressSequentially("Is there parking at The Green?")
await reply.getByRole("button", {name: "Post Reply"}).click()
await reply.getByRole("button", {name: "Comment"}).click()
await expect(bob.getByText("Is there parking at The Green?")).toBeVisible()
// The composer holds what was typed until the relay confirms it, so match the comment itself.
await expect(
bob.locator('[data-component="Comment"]').filter({hasText: "Is there parking at The Green?"}),
).toBeVisible()
const alice = await as(users.alice, calendarPath)
@ -594,14 +597,18 @@ test("US-052 comment on and react to community posts", async ({seed, as}) => {
await expect(alice.getByText("Is there parking at The Green?")).toBeVisible()
await alice.getByRole("button", {name: "Leave comment"}).click()
await alice.getByRole("button", {name: "Add a comment"}).click()
const alicesReply = alice.locator("form").filter({has: alice.locator(".note-editor")})
await noteEditor(alicesReply).pressSequentially("Yes, in the field behind the pub.")
await alicesReply.getByRole("button", {name: "Post Reply"}).click()
await alicesReply.getByRole("button", {name: "Comment"}).click()
await expect(alice.getByText("Yes, in the field behind the pub.")).toBeVisible()
await expect(
alice
.locator('[data-component="Comment"]')
.filter({hasText: "Yes, in the field behind the pub."}),
).toBeVisible()
})
test("US-053 browse and search the library", async ({seed, as}) => {

View file

@ -1,78 +0,0 @@
<script lang="ts">
import {sortBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
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 Button from "@lib/components/Button.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import CommentActions from "@app/components/CommentActions.svelte"
import EventReply from "@app/components/EventReply.svelte"
import type {FeedContext} from "@app/feeds"
type Props = {
url: string
event: TrustedEvent
replies: TrustedEvent[]
context: FeedContext
}
const {url, event, replies, context}: Props = $props()
const expand = () => {
showAll = true
}
const openReply = () => {
showReply = true
}
const closeReply = () => {
showReply = false
}
let showAll = $state(false)
let showReply = $state(false)
const ordered = $derived(sortBy(reply => reply.created_at, replies))
</script>
<div class="flex flex-col gap-2 sm:gap-4">
{#if ordered.length === 0 && !showReply}
<div class="flex flex-col items-center gap-3 py-6">
<p class="opacity-75">No comments yet — start the conversation.</p>
<Button class="button button-primary" onclick={openReply}>
<Icon icon={Reply} />
Leave comment
</Button>
</div>
{:else}
{#if !showAll && ordered.length > 4}
<div class="flex justify-center">
<Button class="button button-link" onclick={expand}>
<Icon icon={SortVertical} />
Show all {ordered.length} replies
</Button>
</div>
{/if}
{#each ordered.slice(0, showAll ? undefined : 4) as reply (reply.id)}
<NoteCard event={reply} {url} class="card z-feature w-full">
<div class="ml-12 flex flex-col gap-3">
<NoteContent showEntire event={reply} {url} />
<CommentActions event={reply} {url} {context} />
</div>
</NoteCard>
{/each}
{#if showReply}
<EventReply {url} {event} onClose={closeReply} onSubmit={closeReply} />
{:else}
<div class="flex justify-end px-2 pb-2">
<Button class="button button-primary" onclick={openReply}>
<Icon icon={Reply} />
Leave comment
</Button>
</div>
{/if}
{/if}
</div>

View file

@ -1,8 +1,7 @@
<script lang="ts">
import {writable} from "svelte/store"
import type {TrustedEvent} from "@welshman/util"
import {tagSpec, tagValue} from "@welshman/util"
import {Comment, Note} from "@welshman/domain"
import {Note} from "@welshman/domain"
import {isMobile, preventDefault} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
@ -12,7 +11,7 @@
import {thunks, writer} from "@app/core"
import {DraftKey} from "@app/drafts"
import {makeEditor} from "@app/editor"
import {toSpace} from "@app/rooms"
import {publishComment} from "@app/rooms"
import {loadSendDelay} from "@app/settings"
import {pushToast} from "@app/toast"
@ -31,8 +30,6 @@
const {event, onCancel, onSubmit, parent, url, noun = "Comment"}: Props = $props()
const h = $derived(tagValue(tagSpec("h"), event.tags))
const draftKey = new DraftKey<Values>(`comment:${event.id}:${parent?.id ?? ""}`)
const initialValues = draftKey.get()
const uploading = writable(false)
@ -40,26 +37,19 @@
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
// A reply to a room event is a NIP-22 comment, and a reply to a kind 1 note is a NIP-10 note.
const renderReply = async (content: string, tags: string[][]) => {
const publishReply = async (content: string, tags: string[][]) => {
if (url) {
const eventWriter = await toSpace(
writer(Comment)
.setContent(content)
.addTags(...tags)
.setRootFromEvent(event)
.setParentFromEvent(parent ?? event),
url,
h,
)
return {relays: [url], event: await eventWriter.renderTemplate()}
return publishComment({url, event, parent, content, tags})
}
return writer(Note)
.setContent(content)
.addTags(...tags)
.setParent(parent ?? event)
.render()
return $thunks.publish({
...(await writer(Note)
.setContent(content)
.addTags(...tags)
.setParent(parent ?? event)
.render()),
delay: await loadSendDelay(),
})
}
const submit = async () => {
@ -77,8 +67,7 @@
loading = true
try {
const reply = await renderReply(content, ed.storage.nostr.getEditorTags())
const thunk = $thunks.publish({...reply, delay: await loadSendDelay()})
const thunk = await publishReply(content, ed.storage.nostr.getEditorTags())
const error = await thunk.waitForError()
if (error) {

View file

@ -1,5 +1,6 @@
<script lang="ts">
import {onMount} from "svelte"
import cx from "classnames"
import {removeUndefined} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {getCommentFiltersForRoot} from "@welshman/util"
@ -16,9 +17,10 @@
event: TrustedEvent
url?: string
context: FeedContext
columnClass?: string
}
const {event, url, context}: Props = $props()
const {event, url, context, columnClass}: Props = $props()
const relays = removeUndefined([url])
const filters = getCommentFiltersForRoot([event])
@ -48,10 +50,8 @@
</script>
<section class="flex flex-col">
<!-- The heading rule is a page-level divider, so it runs full width; the comments below it
stay in the article's reading column. -->
<div class="border-b px-5 sm:px-8" style="border-color: var(--line)">
<div class="mx-auto flex w-full max-w-[68ch] flex-wrap items-center justify-between gap-2 py-3">
<div class={cx("flex w-full flex-wrap items-center justify-between gap-2 py-3", columnClass)}>
<h2 class="text-lg font-bold">
{nodes.length === 0
? "Discussion about this post"
@ -66,7 +66,7 @@
</div>
</div>
<div class="px-5 sm:px-8">
<div class="mx-auto flex w-full max-w-[68ch] flex-col pb-10">
<div class={cx("flex w-full flex-col pb-10", columnClass)}>
{#if url && replyTo?.id === event.id}
<div class="py-4">
<CommentCompose {url} {event} onCancel={clearReplyTo} onSubmit={clearReplyTo} />

View file

@ -2,8 +2,6 @@
import {onMount} from "svelte"
import {writable} from "svelte/store"
import type {TrustedEvent} from "@welshman/util"
import {tagSpec, tagValue} from "@welshman/util"
import {Comment} from "@welshman/domain"
import {isMobile, preventDefault} from "@lib/html"
import {fly} from "@lib/transition"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
@ -12,12 +10,10 @@
import Spinner from "@lib/components/Spinner.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import ComposeParent from "@app/components/ComposeParent.svelte"
import {prependParent, toSpace} from "@app/rooms"
import {prependParent, publishComment} from "@app/rooms"
import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/drafts"
import {pushToast} from "@app/toast"
import {thunks, writer} from "@app/core"
import {loadSendDelay} from "@app/settings"
type Values = {
content?: string | object
@ -33,7 +29,6 @@
}
const {url, event, parent, onClose, onClearParent, onSubmit}: Props = $props()
const h = $derived(tagValue(tagSpec("h"), event.tags))
const draftKey = new DraftKey<Values>(`reply:${event.id}:${parent?.id || ""}`)
const initialValues = draftKey.get()
const uploading = writable(false)
@ -47,8 +42,7 @@
}
const ed = await editor
let content = ed.getText({blockSeparator: "\n"}).trim()
let tags = ed.storage.nostr.getEditorTags()
const content = ed.getText({blockSeparator: "\n"}).trim()
if (!content) {
return pushToast({
@ -57,24 +51,11 @@
})
}
if (parent) {
;({content, tags} = await prependParent(parent, {content, tags}, url))
}
const eventWriter = await toSpace(
writer(Comment)
.setContent(content)
.addTags(...tags)
.setRootFromEvent(event)
.setParentFromEvent(event),
const tags = ed.storage.nostr.getEditorTags()
const thunk = await publishComment({
url,
h,
)
const thunk = $thunks.publish({
relays: [url],
event: await eventWriter.renderTemplate(),
delay: await loadSendDelay(),
event,
...(await prependParent(parent, {content, tags}, url)),
})
draftKey.clear()

View file

@ -14,7 +14,7 @@ import {
toNostrURI,
} from "@welshman/util"
import type {EventContent, TrustedEvent} from "@welshman/util"
import {Message} from "@welshman/domain"
import {Comment, Message} from "@welshman/domain"
import type {BaseEventReader, EventWriter} from "@welshman/domain"
import {MembershipStatus, RoomLists, makeRoomKey, createSearch, publish} from "@welshman/app"
import type {Room, RoomMeta} from "@welshman/app"
@ -33,6 +33,7 @@ import {
} from "@app/core"
import {deriveSpacePermissions, deriveUserIsSpaceStaff} from "@app/management"
import {makeRoomPath} from "@app/routes"
import {loadSendDelay} from "@app/settings"
export const PROTECTED = ["-"]
@ -139,6 +140,34 @@ export const publishRoomQuote = async ({
})
}
export const publishComment = async ({
url,
event,
parent = event,
content,
tags,
}: EventContent & {
url: string
event: TrustedEvent
parent?: TrustedEvent
}) => {
const eventWriter = await toSpace(
writer(Comment)
.setContent(content)
.addTags(...tags)
.setRootFromEvent(event)
.setParentFromEvent(parent),
url,
tagValue(tagSpec("h"), event.tags),
)
return thunks.get().publish({
relays: [url],
event: await eventWriter.renderTemplate(),
delay: await loadSendDelay(),
})
}
export const publishToSpace = async (
eventWriter: EventWriter<BaseEventReader>,
{url, h, shareToChat}: {url: string; h?: string; shareToChat?: boolean},

View file

@ -128,7 +128,7 @@
</div>
</div>
<div class="border-t" style="border-color: var(--line)">
<EventComments event={$event} {url} {context} />
<EventComments event={$event} {url} {context} columnClass="mx-auto max-w-[68ch]" />
</div>
</div>
<ArticleAuthorSidebar {url} event={$event} />

View file

@ -20,7 +20,7 @@
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
import CalendarEventMeta from "@app/components/CalendarEventMeta.svelte"
import CalendarEventDate from "@app/components/CalendarEventDate.svelte"
import CalendarEventDiscussion from "@app/components/CalendarEventDiscussion.svelte"
import EventComments from "@app/components/EventComments.svelte"
import CalendarEventPeople from "@app/components/CalendarEventPeople.svelte"
import CalendarRsvp from "@app/components/CalendarRsvp.svelte"
import {deriveRsvps, getRsvpsByStatus, makeRsvpFilter} from "@app/calendar"
@ -37,7 +37,7 @@
const event = $events.one(address, [url]).$
const timeEvent = derived(event, $event => ($event ? reader(TimeEvent)($event) : undefined))
const filters = $derived($event ? getCommentFiltersForRoot([$event]) : [])
const replies = $derived($events.desc(filters).$)
const comments = $derived($events.asc(filters).$)
const rsvps = $derived($event ? deriveRsvps($event) : readable<TrustedEvent[]>([]))
const people = $derived(getRsvpsByStatus($rsvps))
@ -113,8 +113,8 @@
aria-pressed={tab === "discussion"}
onclick={showTab("discussion")}>
Discussion
{#if $replies.length > 0}
<span class="opacity-75">{$replies.length}</span>
{#if $comments.length > 0}
<span class="opacity-75">{$comments.length}</span>
{/if}
</Button>
<Button
@ -138,7 +138,7 @@
{/if}
</div>
{:else if tab === "discussion"}
<CalendarEventDiscussion {url} event={$event} replies={$replies} {context} />
<EventComments {url} event={$event} {context} />
{:else}
<CalendarEventPeople {url} event={$event} rsvps={$rsvps} />
{/if}

View file

@ -1,17 +1,14 @@
<script lang="ts">
import {onDestroy, onMount} from "svelte"
import {sleep} from "@welshman/lib"
import {POLL, POLL_RESPONSE, getCommentFiltersForRoot} from "@welshman/util"
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import {POLL, POLL_RESPONSE} from "@welshman/util"
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 NoteCard from "@app/components/NoteCard.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import CommentActions from "@app/components/CommentActions.svelte"
import EventReply from "@app/components/EventReply.svelte"
import EventComments from "@app/components/EventComments.svelte"
import {events, network} from "@app/core"
import {makeFeedContext} from "@app/feeds"
import {decodeRelay} from "@app/relays"
@ -23,28 +20,11 @@
const url = decodeRelay(relay)
const context = makeFeedContext({relays: [url]})
const event = $events.one(id, [url]).$
const filters = $derived($event ? getCommentFiltersForRoot([$event]) : [])
const comments = $derived($events.asc(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)
onMount(() => {
const controller = new AbortController()
@ -61,16 +41,6 @@
controller.abort()
}
})
$effect(() => {
if (filters.length > 0) {
const controller = new AbortController()
$network.request({relays: [url], filters, signal: controller.signal})
return () => controller.abort()
}
})
</script>
<SpaceBar {back}>
@ -81,37 +51,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 flex flex-col gap-3">
<NoteContent showEntire event={$event} {url} />
<CommentActions showActivity {url} event={$event} {context} />
</div>
</NoteCard>
{#if !showAll && $comments.length > 4}
<div class="flex justify-center">
<Button class="button button-link" onclick={expand}>
<Icon icon={SortVertical} />
Show all {$comments.length} comments
</Button>
</div>
{/if}
{#each $comments.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}>Comment on this poll</Button>
<NoteCard event={$event} {url} class="card z-feature w-full">
<div class="ml-12 flex flex-col gap-3">
<NoteContent showEntire event={$event} {url} />
<CommentActions showActivity {url} event={$event} {context} />
</div>
{/if}
</NoteCard>
<EventComments event={$event} {url} {context} />
{:else}
<div class="flex justify-center py-20">
{#await sleep(5000)}