+
{@render body()}
{/if}
diff --git a/src/app/components/NotePage.svelte b/src/app/components/NotePage.svelte
new file mode 100644
index 00000000..de390877
--- /dev/null
+++ b/src/app/components/NotePage.svelte
@@ -0,0 +1,85 @@
+
+
+
+
+ {#if $event}
+ {@const note = $event}
+ {@const replyToNote = () => setReplyTo(note)}
+
+
+
+ {#if replyTo?.id !== note.id}
+
+ {/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}