Add a note detail page so a kind 1 note and its thread open in the app (#625)
This commit is contained in:
parent
36cb34613f
commit
3a680033c5
12 changed files with 405 additions and 94 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
42
src/app/components/EventReactions.svelte
Normal file
42
src/app/components/EventReactions.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
85
src/app/components/NotePage.svelte
Normal file
85
src/app/components/NotePage.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
9
src/routes/notes/[nevent]/+layout.svelte
Normal file
9
src/routes/notes/[nevent]/+layout.svelte
Normal 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}
|
||||
21
src/routes/notes/[nevent]/+page.svelte
Normal file
21
src/routes/notes/[nevent]/+page.svelte
Normal 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}
|
||||
Loading…
Reference in a new issue