From 3a680033c5933df12a845776b646d7204a0116f8 Mon Sep 17 00:00:00 2001 From: Coracle-Bot Date: Wed, 23 Sep 2026 02:35:06 +0000 Subject: [PATCH] Add a note detail page so a kind 1 note and its thread open in the app (#625) --- src/app/components/CommentCompose.svelte | 61 ++++---- src/app/components/CommentTree.svelte | 25 ++-- src/app/components/EventReactions.svelte | 42 ++++++ src/app/components/HomeNetworkItem.svelte | 2 +- src/app/components/NoteCard.svelte | 23 +++- src/app/components/NoteItem.svelte | 56 +++----- src/app/components/NotePage.svelte | 85 ++++++++++++ src/app/components/PinContentEvent.svelte | 6 +- src/app/routes.ts | 8 ++ src/app/social.ts | 161 ++++++++++++++++++++-- src/routes/notes/[nevent]/+layout.svelte | 9 ++ src/routes/notes/[nevent]/+page.svelte | 21 +++ 12 files changed, 405 insertions(+), 94 deletions(-) create mode 100644 src/app/components/EventReactions.svelte create mode 100644 src/app/components/NotePage.svelte create mode 100644 src/routes/notes/[nevent]/+layout.svelte create mode 100644 src/routes/notes/[nevent]/+page.svelte diff --git a/src/app/components/CommentCompose.svelte b/src/app/components/CommentCompose.svelte index 63d14657..700fe5ee 100644 --- a/src/app/components/CommentCompose.svelte +++ b/src/app/components/CommentCompose.svelte @@ -2,7 +2,7 @@ import {writable} from "svelte/store" import type {TrustedEvent} from "@welshman/util" import {tagSpec, tagValue} from "@welshman/util" - import {Comment} from "@welshman/domain" + import {Comment, 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" @@ -20,14 +20,15 @@ } type Props = { - url: string event: TrustedEvent - parent?: TrustedEvent onCancel: () => void onSubmit: () => void + parent?: TrustedEvent + url?: string + noun?: string } - const {url, event, parent, onCancel, onSubmit}: Props = $props() + const {event, onCancel, onSubmit, parent, url, noun = "Comment"}: Props = $props() const h = $derived(tagValue(tagSpec("h"), event.tags)) @@ -37,6 +38,34 @@ const selectFiles = () => editor.then(ed => ed.commands.selectFiles()) + // A reply to a room event is a NIP-22 comment on the space's own relay. A reply to a kind 1 + // note is a NIP-10 note, which the outbox model routes to the note's author and back to the + // replier's own relays. + const renderReply = async (content: string, tags: string[][]) => { + if (url) { + const eventWriter = writer(Comment) + .setContent(content) + .addTags(...tags) + .setRootFromEvent(event) + .setParentFromEvent(parent ?? event) + .setProtected(await $relays.hasNip(url, 70)) + + // A comment on a room event is a room event too: an untagged one isn't visible to the + // group at all, so the relay neither gates it with the room nor deletes it with it. + if (h) { + eventWriter.setRoom(url, h) + } + + return {relays: [url], event: await eventWriter.renderTemplate()} + } + + return writer(Note) + .setContent(content) + .addTags(...tags) + .setParent(parent ?? event) + .render() + } + const submit = async () => { if ($uploading || loading) { return @@ -52,24 +81,8 @@ loading = true try { - const eventWriter = writer(Comment) - .setContent(content) - .addTags(...ed.storage.nostr.getEditorTags()) - .setRootFromEvent(event) - .setParentFromEvent(parent ?? event) - .setProtected(await $relays.hasNip(url, 70)) - - // A comment on a room event is a room event too: an untagged one isn't visible to the - // group at all, so the relay neither gates it with the room nor deletes it with it. - if (h) { - eventWriter.setRoom(url, h) - } - - const thunk = $thunks.publish({ - relays: [url], - event: await eventWriter.renderTemplate(), - delay: getSetting("send_delay"), - }) + const reply = await renderReply(content, ed.storage.nostr.getEditorTags()) + const thunk = $thunks.publish({...reply, delay: getSetting("send_delay")}) const error = await thunk.waitForError() if (error) { @@ -99,7 +112,7 @@ onChange, content, empty, - placeholder: parent ? "Write a reply..." : "Write a comment...", + placeholder: parent ? "Write a reply..." : `Write a ${noun.toLowerCase()}...`, }) $effect(() => { @@ -137,7 +150,7 @@ {#if parent} Reply {:else} - Comment + {noun} {/if} diff --git a/src/app/components/CommentTree.svelte b/src/app/components/CommentTree.svelte index 8b2c7668..4047f292 100644 --- a/src/app/components/CommentTree.svelte +++ b/src/app/components/CommentTree.svelte @@ -11,6 +11,7 @@ import ProfileDetail from "@app/components/ProfileDetail.svelte" import Content from "@app/components/Content.svelte" import CommentActions from "@app/components/CommentActions.svelte" + import EventReactions from "@app/components/EventReactions.svelte" import type {FeedContext} from "@app/feeds" import Danger from "@assets/icons/danger-triangle.svg?dataurl" import {pushModal} from "@app/modal" @@ -69,21 +70,23 @@ - {#if url} -
- {#if setReplyTo} - - {/if} +
+ {#if setReplyTo} + + {/if} + {#if url} -
- {/if} + {:else} + + {/if} +
{/if} - {#if composing && url} + {#if composing}
+ import type {NativeEmoji} from "emoji-picker-element/shared" + import type {TrustedEvent, EventContent} from "@welshman/util" + import {seen} from "@welshman/util" + import SmileCircle from "@assets/icons/smile-circle.svg?dataurl" + import Icon from "@lib/components/Icon.svelte" + import EmojiButton from "@lib/components/EmojiButton.svelte" + import ReactionSummary from "@app/components/ReactionSummary.svelte" + import {publishReaction, retractReaction} from "@app/reactions" + import type {FeedContext} from "@app/feeds" + import {router} from "@app/core" + + type Props = { + event: TrustedEvent + context: FeedContext + url?: string + reactionClass?: string + } + + const {event, context, url, reactionClass = ""}: Props = $props() + + // A reaction goes where the thing it is about lives: the space's own relay in a space, and + // wherever the event has been seen outside one. + const getRelays = () => (url ? [url] : $router.resolver.relays([seen(event)])) + + const deleteReaction = async (reaction: TrustedEvent) => + retractReaction(reaction, {url, urls: await getRelays()}) + + const createReaction = async (values: EventContent) => + publishReaction(event, values, {url, urls: await getRelays()}) + + const onEmoji = (emoji: NativeEmoji) => createReaction({content: emoji.unicode, tags: []}) + + + + + + + diff --git a/src/app/components/HomeNetworkItem.svelte b/src/app/components/HomeNetworkItem.svelte index d3fd5d5e..80a330b3 100644 --- a/src/app/components/HomeNetworkItem.svelte +++ b/src/app/components/HomeNetworkItem.svelte @@ -24,7 +24,7 @@
- + + {/if} + +
+
+
+ {#if replyTo?.id === note.id} +
+ +
+ {/if} + {#each $replies as node (node.comment.id)} + + {/each} + {#if $replies.length === 0 && replyTo?.id !== note.id} +

No replies yet.

+ {/if} +
+ {:else} +
+ {#await sleep(5000)} + Loading note... + {:then} +

Failed to load note.

+ {/await} +
+ {/if} + + diff --git a/src/app/components/PinContentEvent.svelte b/src/app/components/PinContentEvent.svelte index b0b26eb1..5a3f5159 100644 --- a/src/app/components/PinContentEvent.svelte +++ b/src/app/components/PinContentEvent.svelte @@ -1,11 +1,9 @@ {#if $event} - + {:else}

Loading event... diff --git a/src/app/routes.ts b/src/app/routes.ts index 05d6af41..1e8ca7de 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -8,6 +8,7 @@ import { EVENT_TIME, LONG_FORM, MESSAGE, + NOTE, PINBOARD, POLL, THREAD, @@ -225,6 +226,13 @@ export const makeEventPath = (event: TrustedEvent, urls: string[]) => { } } + // A note belongs to no space, so its path carries the relays it was found on. + if (event.kind === NOTE) { + const {id, pubkey: author, kind} = event + + return `/notes/${nip19.neventEncode({id, author, kind, relays: urls.slice(0, 3)})}` + } + return entityLink(nip19.neventEncode({id: event.id, relays: urls})) } diff --git a/src/app/social.ts b/src/app/social.ts index 8b74e04b..5d311fd8 100644 --- a/src/app/social.ts +++ b/src/app/social.ts @@ -1,22 +1,44 @@ -import {derived} from "svelte/store" -import {noop, pushToMapKey, removeUndefined, shuffle, sortBy, uniqBy} from "@welshman/lib" +import * as nip19 from "nostr-tools/nip19" +import {derived, readable} from "svelte/store" +import { + first, + noop, + pushToMapKey, + removeUndefined, + shuffle, + sortBy, + tryCatch, + uniq, + uniqBy, +} from "@welshman/lib" +import type {Maybe} from "@welshman/lib" import { COMMENT, + NOTE, addressTags, + eventOutbox, + fromNostrURI, getAddress, + getCommentFiltersForRoot, getIdAndAddress, + getIdFilters, hexTags, + relays as relaySelections, + seen, tagSpec, tagValues, topicTags, } from "@welshman/util" -import type {TrustedEvent} from "@welshman/util" +import type {EventRef, TrustedEvent} from "@welshman/util" import {withGetter} from "@welshman/store" -import {displayPubkey, getCommentTagValues, getReplyTagValues} from "@welshman/domain" -import {FollowLists, MuteLists, Profiles} from "@welshman/app" -import {deriveUserItem, fromApp, profiles, user} from "@app/core" +import {displayPubkey, getCommentTagValues, getReplyTagValues, getReplyTags} from "@welshman/domain" +import {Events, FollowLists, MuteLists, Network, Profiles, Router} from "@welshman/app" +import type {IApp} from "@welshman/app" +import {deriveUserItem, fromApp, profiles, user, usePlugin} from "@app/core" import {DEFAULT_PUBKEYS} from "@app/env" +// People + const profileIndex = fromApp($app => $app.use(Profiles).index.$) export const deriveDisplaysByPubkey = (pubkeys: string[], url?: string) => { @@ -49,13 +71,69 @@ export const bootstrapPubkeys = derived(deriveUserItem(FollowLists), $userFollow return userPubkeys.length > 5 ? userPubkeys : [...userPubkeys, ...appPubkeys] }) +// Pointers +// +// How a note is named before it has been loaded: an id, and whatever hints came with it. + +export type NotePointer = EventRef & {id: string} + +export const decodeNotePointer = (entity: string): Maybe => { + const decoded = tryCatch(() => nip19.decode(fromNostrURI(entity))) + + if (decoded?.type === "nevent") { + return {id: decoded.data.id, relays: decoded.data.relays, pubkey: decoded.data.author} + } + + if (decoded?.type === "note") { + return {id: decoded.data} + } +} + +// Structure +// +// The shape of a conversation, read off tags alone. Nothing here loads anything. + // Ids and addresses of an event's immediate parents, falling back to its thread roots. -const getParents = ({kind, tags}: TrustedEvent) => { +export const getParents = ({kind, tags}: TrustedEvent) => { const {roots, replies} = kind === COMMENT ? getCommentTagValues(tags) : getReplyTagValues(tags) return replies.length > 0 ? replies : roots } +// A reply names the note it answers with an `e` tag; a deeper one names the thread root as well. +// Asking by root is what finds the replies more than one level down. +export const getThreadFilters = (event: TrustedEvent) => { + const roots = tagValues(hexTags("e"), getReplyTags(event.tags).roots) + + return [...getCommentFiltersForRoot([event]), {kinds: [NOTE], "#e": uniq([event.id, ...roots])}] +} + +// Everything hanging off a note, however deep. A reply names only the event directly above it, so +// the set grows a generation at a time out of whatever has been loaded. +export const getDescendants = (root: TrustedEvent, events: TrustedEvent[]) => { + const values = new Set(getIdAndAddress(root)) + const descendants: TrustedEvent[] = [] + + let growing = true + + while (growing) { + growing = false + + for (const event of events) { + if (!values.has(event.id) && getParents(event).some(value => values.has(value))) { + for (const value of getIdAndAddress(event)) { + values.add(value) + } + + descendants.push(event) + growing = true + } + } + } + + return descendants +} + export type CommentNode = { comment: TrustedEvent children: CommentNode[] @@ -74,16 +152,16 @@ export const buildCommentTree = (root: TrustedEvent, comments: TrustedEvent[]) = // Nothing stops a comment from naming several parents, which would let the tree cycle, so // walk down from the root and keep each comment at the first place it turns up. - const seen = new Set() + const seenIds = new Set() const build = (parent: TrustedEvent): CommentNode[] => { const children = uniqBy( e => e.id, getIdAndAddress(parent).flatMap(value => byParent.get(value) ?? []), - ).filter(e => !seen.has(e.id)) + ).filter(e => !seenIds.has(e.id)) for (const child of children) { - seen.add(child.id) + seenIds.add(child.id) } return children.map(comment => ({comment, children: build(comment)})) @@ -95,8 +173,8 @@ export const buildCommentTree = (root: TrustedEvent, comments: TrustedEvent[]) = // or a parent that failed to load — so adopt whatever's left rather than dropping it. // `comments` is oldest first, so a parent is always adopted before its own children. for (const comment of comments) { - if (!seen.has(comment.id)) { - seen.add(comment.id) + if (!seenIds.has(comment.id)) { + seenIds.add(comment.id) nodes.push({comment, children: build(comment)}) } } @@ -104,6 +182,8 @@ export const buildCommentTree = (root: TrustedEvent, comments: TrustedEvent[]) = return sortBy(node => node.comment.created_at, nodes) } +// Muting + export const isEventMuted = withGetter( derived([user, deriveUserItem(MuteLists)], ([$user, $muteList]) => { const tags = $muteList?.tags() ?? [] @@ -155,3 +235,60 @@ export const isEventMuted = withGetter( } }), ) + +// Loading +// +// The only part that goes to the network: where a note lives, and the stores a page reads it +// and its conversation from. + +export class Notes { + constructor(private readonly app: IApp) {} + + // Where a note and the conversation around it live: the hints it arrived with, the relays it + // has been seen on, and its author's outbox. + relays = (ref: EventRef) => + this.app + .use(Router) + .resolver.relays([...relaySelections(ref.relays ?? []), seen(ref), eventOutbox(ref)]) + + load = async (pointer: NotePointer) => + this.app.repository.getEvent(pointer.id) ?? + first( + await this.app + .use(Network) + .loadComplete({relays: await this.relays(pointer), filters: getIdFilters([pointer.id])}), + ) + + // The note itself. Its hints are asked by the derived store and its author's relays by the + // load beside it, since a pointer carrying no hints would otherwise never resolve. + deriveEvent = (pointer: NotePointer) => { + this.load(pointer) + + return this.app.use(Events).one(pointer.id, pointer.relays ?? []).$ + } + + // The conversation below a note. The filters reach past its direct replies to catch deeper + // ones, which brings back events belonging to other threads, so the tree is built from what + // actually hangs off the note. + deriveReplies = (event: TrustedEvent) => + readable([], set => { + const filters = getThreadFilters(event) + const controller = new AbortController() + + this.relays(event).then(relays => + this.app.use(Network).request({relays, filters, signal: controller.signal}), + ) + + const unsubscribe = this.app + .use(Events) + .asc(filters) + .$.subscribe($events => set(buildCommentTree(event, getDescendants(event, $events)))) + + return () => { + controller.abort() + unsubscribe() + } + }) +} + +export const notes = usePlugin(Notes) diff --git a/src/routes/notes/[nevent]/+layout.svelte b/src/routes/notes/[nevent]/+layout.svelte new file mode 100644 index 00000000..3ad37d50 --- /dev/null +++ b/src/routes/notes/[nevent]/+layout.svelte @@ -0,0 +1,9 @@ + + +{#key params.nevent} + {@render children?.()} +{/key} diff --git a/src/routes/notes/[nevent]/+page.svelte b/src/routes/notes/[nevent]/+page.svelte new file mode 100644 index 00000000..d6ca63fc --- /dev/null +++ b/src/routes/notes/[nevent]/+page.svelte @@ -0,0 +1,21 @@ + + +{#if pointer} + +{/if}