Add a note detail page so a kind 1 note and its thread open in the app (#625)

This commit is contained in:
Coracle-Bot 2026-09-23 02:35:06 +00:00 committed by hodlbod
parent 36cb34613f
commit 3a680033c5
12 changed files with 405 additions and 94 deletions

View file

@ -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}
</Spinner>
</Button>

View file

@ -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 @@
</span>
</div>
<Content showEntire event={node.comment} {url} />
{#if url}
<div class="mt-1 flex flex-wrap items-center justify-end gap-2">
{#if setReplyTo}
<Button class="button button-neutral button-xs mr-auto" onclick={reply}>
<Icon icon={Reply} size={4} />
Reply
</Button>
{/if}
<div class="mt-1 flex flex-wrap items-center justify-end gap-2">
{#if setReplyTo}
<Button class="button button-neutral button-xs mr-auto" onclick={reply}>
<Icon icon={Reply} size={4} />
Reply
</Button>
{/if}
{#if url}
<CommentActions event={node.comment} {url} {context} />
</div>
{/if}
{:else}
<EventReactions event={node.comment} {context} reactionClass="tip-left" />
{/if}
</div>
</div>
</div>
{/if}
{#if composing && url}
{#if composing}
<div class="ml-11 pb-3">
<CommentCompose
{url}

View file

@ -0,0 +1,42 @@
<script lang="ts">
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: []})
</script>
<ReactionSummary {url} {event} {context} {deleteReaction} {createReaction} {reactionClass}>
<EmojiButton
{onEmoji}
aria-label="Add a reaction"
class="button button-neutral button-xs rounded-full">
<Icon icon={SmileCircle} size={4} />
</EmojiButton>
</ReactionSummary>

View file

@ -24,7 +24,7 @@
</script>
<div class="card card-interactive flex flex-col gap-3">
<NoteItem {event} {context} card={false}>
<NoteItem {event} {context} card={false} interactive>
<Button class="button button-neutral button-xs rounded-full" onclick={goToReplies}>
<Icon icon={Reply} size={4} />
{replyCount}

View file

@ -16,23 +16,40 @@
children?: Snippet
minimal?: boolean
hideProfile?: boolean
interactive?: boolean
url?: string
class?: string
style?: string
}
const {event, children, minimal = false, hideProfile = false, url, ...restProps}: Props = $props()
const {
event,
children,
minimal = false,
hideProfile = false,
interactive = false,
url,
...restProps
}: Props = $props()
const ignoreMute = () => {
muted = false
}
const goToNote = () => goToEvent(event)
const goToNote = () => {
// Releasing a text selection inside the card is not a click on it.
if (!window.getSelection()?.toString()) {
goToEvent(event)
}
}
let muted = $state($isEventMuted(event))
</script>
<div {...restProps} class={cx("flex flex-col gap-2", restProps.class)}>
<div
{...restProps}
onclick={interactive ? goToNote : undefined}
class={cx("flex flex-col gap-2", restProps.class)}>
{#if muted}
<div class="flex items-center justify-between">
<div class="flex gap-2 relative">

View file

@ -1,18 +1,11 @@
<script lang="ts">
import type {Snippet} from "svelte"
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 Cv from "@lib/components/Cv.svelte"
import type {TrustedEvent} from "@welshman/util"
import NoteContent from "@app/components/NoteContent.svelte"
import {publishReaction, retractReaction} from "@app/reactions"
import NoteCard from "@app/components/NoteCard.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import EventReactions from "@app/components/EventReactions.svelte"
import Cv from "@lib/components/Cv.svelte"
import type {FeedContext} from "@app/feeds"
import {router} from "@app/core"
type Props = {
event: TrustedEvent
@ -20,48 +13,35 @@
context: FeedContext
url?: string
card?: boolean
interactive?: boolean
showEntire?: boolean
}
const {url, event, children, context, card = true}: Props = $props()
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: []})
const {
url,
event,
children,
context,
card = true,
interactive = card,
showEntire = false,
}: Props = $props()
</script>
{#snippet body()}
<NoteContent {event} expandMode="inline" />
<NoteContent {event} {showEntire} expandMode="inline" />
<div class="flex w-full justify-between gap-2">
<ReactionSummary
{url}
{event}
{context}
{deleteReaction}
{createReaction}
reactionClass="tip-right">
<EmojiButton
{onEmoji}
aria-label="Add a reaction"
class="button button-neutral button-xs rounded-full">
<Icon icon={SmileCircle} size={4} />
</EmojiButton>
</ReactionSummary>
<EventReactions {event} {context} {url} reactionClass="tip-right" />
{@render children?.()}
</div>
{/snippet}
{#if card}
<Cv tag={NoteCard} {event} {url} class="card card-interactive">
<Cv tag={NoteCard} {event} {url} {interactive} class="card card-interactive">
{@render body()}
</Cv>
{:else}
<NoteCard {event} {url}>
<NoteCard {event} {url} {interactive}>
{@render body()}
</NoteCard>
{/if}

View file

@ -0,0 +1,85 @@
<script lang="ts">
import {onDestroy} from "svelte"
import {readable} from "svelte/store"
import {sleep} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Page from "@lib/components/Page.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import CommentCompose from "@app/components/CommentCompose.svelte"
import CommentTree from "@app/components/CommentTree.svelte"
import NoteItem from "@app/components/NoteItem.svelte"
import {makeFeedContext} from "@app/feeds"
import {notes} from "@app/social"
import type {CommentNode, NotePointer} from "@app/social"
type Props = {
pointer: NotePointer
}
const {pointer}: Props = $props()
const relays = $notes.relays(pointer)
const context = makeFeedContext({relays})
const event = $notes.deriveEvent(pointer)
const replies = $derived($event ? $notes.deriveReplies($event) : readable<CommentNode[]>([]))
const setReplyTo = (comment?: TrustedEvent) => {
replyTo = comment
}
const clearReplyTo = () => setReplyTo(undefined)
let replyTo: TrustedEvent | undefined = $state()
onDestroy(context.cleanup)
</script>
<Page>
<PageContent noPad class="flex flex-col">
{#if $event}
{@const note = $event}
{@const replyToNote = () => setReplyTo(note)}
<div class="border-b border-line bg-surface px-4 py-4">
<div class="mx-auto w-full max-w-[68ch]">
<NoteItem event={note} {context} card={false} showEntire>
{#if replyTo?.id !== note.id}
<Button class="button button-neutral button-xs" onclick={replyToNote}>
<Icon icon={Reply} size={4} />
Reply
</Button>
{/if}
</NoteItem>
</div>
</div>
<div class="mx-auto w-full max-w-[68ch] px-4 pt-4">
{#if replyTo?.id === note.id}
<div class="pb-4">
<CommentCompose
event={note}
noun="Reply"
onCancel={clearReplyTo}
onSubmit={clearReplyTo} />
</div>
{/if}
{#each $replies as node (node.comment.id)}
<CommentTree {node} root={note} {replyTo} {setReplyTo} {context} />
{/each}
{#if $replies.length === 0 && replyTo?.id !== note.id}
<p class="py-6 text-sm opacity-75">No replies yet.</p>
{/if}
</div>
{:else}
<div class="flex justify-center py-20">
{#await sleep(5000)}
<Spinner loading>Loading note...</Spinner>
{:then}
<p>Failed to load note.</p>
{/await}
</div>
{/if}
</PageContent>
</Page>

View file

@ -1,11 +1,9 @@
<script lang="ts">
import {onDestroy} from "svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import NoteItem from "@app/components/NoteItem.svelte"
import {makeFeedContext} from "@app/feeds"
import {deriveEvent} from "@app/repository"
import {goToEvent} from "@app/routes"
type Props = {
url?: string
@ -22,9 +20,7 @@
</script>
{#if $event}
<Button onclick={() => goToEvent($event)}>
<NoteItem {url} {context} event={$event} />
</Button>
<NoteItem {url} {context} event={$event} />
{:else}
<p class="flex justify-center py-8">
<Spinner loading>Loading event...</Spinner>

View file

@ -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}))
}

View file

@ -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<NotePointer> => {
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<string>()
const seenIds = new Set<string>()
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<CommentNode[]>([], 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)

View file

@ -0,0 +1,9 @@
<script lang="ts">
import type {LayoutProps} from "./$types"
const {children, params}: LayoutProps = $props()
</script>
{#key params.nevent}
{@render children?.()}
{/key}

View file

@ -0,0 +1,21 @@
<script lang="ts">
import {onMount} from "svelte"
import NotePage from "@app/components/NotePage.svelte"
import {navigate} from "@app/modal"
import {decodeNotePointer} from "@app/social"
import type {PageProps} from "./$types"
const {params}: PageProps = $props()
const pointer = decodeNotePointer(params.nevent)
onMount(() => {
if (!pointer) {
navigate("/", {replaceState: true})
}
})
</script>
{#if pointer}
<NotePage {pointer} />
{/if}