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 {writable} from "svelte/store"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {tagSpec, tagValue} 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 {isMobile, preventDefault} from "@lib/html"
|
||||||
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
|
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
|
@ -20,14 +20,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
parent?: TrustedEvent
|
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onSubmit: () => 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))
|
const h = $derived(tagValue(tagSpec("h"), event.tags))
|
||||||
|
|
||||||
|
|
@ -37,6 +38,34 @@
|
||||||
|
|
||||||
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
|
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 () => {
|
const submit = async () => {
|
||||||
if ($uploading || loading) {
|
if ($uploading || loading) {
|
||||||
return
|
return
|
||||||
|
|
@ -52,24 +81,8 @@
|
||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const eventWriter = writer(Comment)
|
const reply = await renderReply(content, ed.storage.nostr.getEditorTags())
|
||||||
.setContent(content)
|
const thunk = $thunks.publish({...reply, delay: getSetting("send_delay")})
|
||||||
.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 error = await thunk.waitForError()
|
const error = await thunk.waitForError()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
@ -99,7 +112,7 @@
|
||||||
onChange,
|
onChange,
|
||||||
content,
|
content,
|
||||||
empty,
|
empty,
|
||||||
placeholder: parent ? "Write a reply..." : "Write a comment...",
|
placeholder: parent ? "Write a reply..." : `Write a ${noun.toLowerCase()}...`,
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
@ -137,7 +150,7 @@
|
||||||
{#if parent}
|
{#if parent}
|
||||||
Reply
|
Reply
|
||||||
{:else}
|
{:else}
|
||||||
Comment
|
{noun}
|
||||||
{/if}
|
{/if}
|
||||||
</Spinner>
|
</Spinner>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
import CommentActions from "@app/components/CommentActions.svelte"
|
import CommentActions from "@app/components/CommentActions.svelte"
|
||||||
|
import EventReactions from "@app/components/EventReactions.svelte"
|
||||||
import type {FeedContext} from "@app/feeds"
|
import type {FeedContext} from "@app/feeds"
|
||||||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
@ -69,7 +70,6 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Content showEntire event={node.comment} {url} />
|
<Content showEntire event={node.comment} {url} />
|
||||||
{#if url}
|
|
||||||
<div class="mt-1 flex flex-wrap items-center justify-end gap-2">
|
<div class="mt-1 flex flex-wrap items-center justify-end gap-2">
|
||||||
{#if setReplyTo}
|
{#if setReplyTo}
|
||||||
<Button class="button button-neutral button-xs mr-auto" onclick={reply}>
|
<Button class="button button-neutral button-xs mr-auto" onclick={reply}>
|
||||||
|
|
@ -77,13 +77,16 @@
|
||||||
Reply
|
Reply
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if url}
|
||||||
<CommentActions event={node.comment} {url} {context} />
|
<CommentActions event={node.comment} {url} {context} />
|
||||||
</div>
|
{:else}
|
||||||
|
<EventReactions event={node.comment} {context} reactionClass="tip-left" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if composing && url}
|
{#if composing}
|
||||||
<div class="ml-11 pb-3">
|
<div class="ml-11 pb-3">
|
||||||
<CommentCompose
|
<CommentCompose
|
||||||
{url}
|
{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>
|
</script>
|
||||||
|
|
||||||
<div class="card card-interactive flex flex-col gap-3">
|
<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}>
|
<Button class="button button-neutral button-xs rounded-full" onclick={goToReplies}>
|
||||||
<Icon icon={Reply} size={4} />
|
<Icon icon={Reply} size={4} />
|
||||||
{replyCount}
|
{replyCount}
|
||||||
|
|
|
||||||
|
|
@ -16,23 +16,40 @@
|
||||||
children?: Snippet
|
children?: Snippet
|
||||||
minimal?: boolean
|
minimal?: boolean
|
||||||
hideProfile?: boolean
|
hideProfile?: boolean
|
||||||
|
interactive?: boolean
|
||||||
url?: string
|
url?: string
|
||||||
class?: string
|
class?: string
|
||||||
style?: 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 = () => {
|
const ignoreMute = () => {
|
||||||
muted = false
|
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))
|
let muted = $state($isEventMuted(event))
|
||||||
</script>
|
</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}
|
{#if muted}
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex gap-2 relative">
|
<div class="flex gap-2 relative">
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
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 NoteContent from "@app/components/NoteContent.svelte"
|
import NoteContent from "@app/components/NoteContent.svelte"
|
||||||
import {publishReaction, retractReaction} from "@app/reactions"
|
|
||||||
import NoteCard from "@app/components/NoteCard.svelte"
|
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 type {FeedContext} from "@app/feeds"
|
||||||
import {router} from "@app/core"
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
|
|
@ -20,48 +13,35 @@
|
||||||
context: FeedContext
|
context: FeedContext
|
||||||
url?: string
|
url?: string
|
||||||
card?: boolean
|
card?: boolean
|
||||||
|
interactive?: boolean
|
||||||
|
showEntire?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, event, children, context, card = true}: Props = $props()
|
const {
|
||||||
|
url,
|
||||||
const getRelays = () => (url ? [url] : $router.resolver.relays([seen(event)]))
|
event,
|
||||||
|
children,
|
||||||
const deleteReaction = async (reaction: TrustedEvent) =>
|
context,
|
||||||
retractReaction(reaction, {url, urls: await getRelays()})
|
card = true,
|
||||||
|
interactive = card,
|
||||||
const createReaction = async (values: EventContent) =>
|
showEntire = false,
|
||||||
publishReaction(event, values, {url, urls: await getRelays()})
|
}: Props = $props()
|
||||||
|
|
||||||
const onEmoji = (emoji: NativeEmoji) => createReaction({content: emoji.unicode, tags: []})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet body()}
|
{#snippet body()}
|
||||||
<NoteContent {event} expandMode="inline" />
|
<NoteContent {event} {showEntire} expandMode="inline" />
|
||||||
<div class="flex w-full justify-between gap-2">
|
<div class="flex w-full justify-between gap-2">
|
||||||
<ReactionSummary
|
<EventReactions {event} {context} {url} reactionClass="tip-right" />
|
||||||
{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>
|
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if card}
|
{#if card}
|
||||||
<Cv tag={NoteCard} {event} {url} class="card card-interactive">
|
<Cv tag={NoteCard} {event} {url} {interactive} class="card card-interactive">
|
||||||
{@render body()}
|
{@render body()}
|
||||||
</Cv>
|
</Cv>
|
||||||
{:else}
|
{:else}
|
||||||
<NoteCard {event} {url}>
|
<NoteCard {event} {url} {interactive}>
|
||||||
{@render body()}
|
{@render body()}
|
||||||
</NoteCard>
|
</NoteCard>
|
||||||
{/if}
|
{/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">
|
<script lang="ts">
|
||||||
import {onDestroy} from "svelte"
|
import {onDestroy} from "svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import NoteItem from "@app/components/NoteItem.svelte"
|
import NoteItem from "@app/components/NoteItem.svelte"
|
||||||
import {makeFeedContext} from "@app/feeds"
|
import {makeFeedContext} from "@app/feeds"
|
||||||
import {deriveEvent} from "@app/repository"
|
import {deriveEvent} from "@app/repository"
|
||||||
import {goToEvent} from "@app/routes"
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url?: string
|
url?: string
|
||||||
|
|
@ -22,9 +20,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $event}
|
{#if $event}
|
||||||
<Button onclick={() => goToEvent($event)}>
|
|
||||||
<NoteItem {url} {context} event={$event} />
|
<NoteItem {url} {context} event={$event} />
|
||||||
</Button>
|
|
||||||
{:else}
|
{:else}
|
||||||
<p class="flex justify-center py-8">
|
<p class="flex justify-center py-8">
|
||||||
<Spinner loading>Loading event...</Spinner>
|
<Spinner loading>Loading event...</Spinner>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
EVENT_TIME,
|
EVENT_TIME,
|
||||||
LONG_FORM,
|
LONG_FORM,
|
||||||
MESSAGE,
|
MESSAGE,
|
||||||
|
NOTE,
|
||||||
PINBOARD,
|
PINBOARD,
|
||||||
POLL,
|
POLL,
|
||||||
THREAD,
|
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}))
|
return entityLink(nip19.neventEncode({id: event.id, relays: urls}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,44 @@
|
||||||
import {derived} from "svelte/store"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
import {noop, pushToMapKey, removeUndefined, shuffle, sortBy, uniqBy} from "@welshman/lib"
|
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 {
|
import {
|
||||||
COMMENT,
|
COMMENT,
|
||||||
|
NOTE,
|
||||||
addressTags,
|
addressTags,
|
||||||
|
eventOutbox,
|
||||||
|
fromNostrURI,
|
||||||
getAddress,
|
getAddress,
|
||||||
|
getCommentFiltersForRoot,
|
||||||
getIdAndAddress,
|
getIdAndAddress,
|
||||||
|
getIdFilters,
|
||||||
hexTags,
|
hexTags,
|
||||||
|
relays as relaySelections,
|
||||||
|
seen,
|
||||||
tagSpec,
|
tagSpec,
|
||||||
tagValues,
|
tagValues,
|
||||||
topicTags,
|
topicTags,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {EventRef, TrustedEvent} from "@welshman/util"
|
||||||
import {withGetter} from "@welshman/store"
|
import {withGetter} from "@welshman/store"
|
||||||
import {displayPubkey, getCommentTagValues, getReplyTagValues} from "@welshman/domain"
|
import {displayPubkey, getCommentTagValues, getReplyTagValues, getReplyTags} from "@welshman/domain"
|
||||||
import {FollowLists, MuteLists, Profiles} from "@welshman/app"
|
import {Events, FollowLists, MuteLists, Network, Profiles, Router} from "@welshman/app"
|
||||||
import {deriveUserItem, fromApp, profiles, user} from "@app/core"
|
import type {IApp} from "@welshman/app"
|
||||||
|
import {deriveUserItem, fromApp, profiles, user, usePlugin} from "@app/core"
|
||||||
import {DEFAULT_PUBKEYS} from "@app/env"
|
import {DEFAULT_PUBKEYS} from "@app/env"
|
||||||
|
|
||||||
|
// People
|
||||||
|
|
||||||
const profileIndex = fromApp($app => $app.use(Profiles).index.$)
|
const profileIndex = fromApp($app => $app.use(Profiles).index.$)
|
||||||
|
|
||||||
export const deriveDisplaysByPubkey = (pubkeys: string[], url?: string) => {
|
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]
|
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.
|
// 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)
|
const {roots, replies} = kind === COMMENT ? getCommentTagValues(tags) : getReplyTagValues(tags)
|
||||||
|
|
||||||
return replies.length > 0 ? replies : roots
|
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 = {
|
export type CommentNode = {
|
||||||
comment: TrustedEvent
|
comment: TrustedEvent
|
||||||
children: CommentNode[]
|
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
|
// 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.
|
// 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 build = (parent: TrustedEvent): CommentNode[] => {
|
||||||
const children = uniqBy(
|
const children = uniqBy(
|
||||||
e => e.id,
|
e => e.id,
|
||||||
getIdAndAddress(parent).flatMap(value => byParent.get(value) ?? []),
|
getIdAndAddress(parent).flatMap(value => byParent.get(value) ?? []),
|
||||||
).filter(e => !seen.has(e.id))
|
).filter(e => !seenIds.has(e.id))
|
||||||
|
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
seen.add(child.id)
|
seenIds.add(child.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
return children.map(comment => ({comment, children: build(comment)}))
|
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.
|
// 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.
|
// `comments` is oldest first, so a parent is always adopted before its own children.
|
||||||
for (const comment of comments) {
|
for (const comment of comments) {
|
||||||
if (!seen.has(comment.id)) {
|
if (!seenIds.has(comment.id)) {
|
||||||
seen.add(comment.id)
|
seenIds.add(comment.id)
|
||||||
nodes.push({comment, children: build(comment)})
|
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)
|
return sortBy(node => node.comment.created_at, nodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Muting
|
||||||
|
|
||||||
export const isEventMuted = withGetter(
|
export const isEventMuted = withGetter(
|
||||||
derived([user, deriveUserItem(MuteLists)], ([$user, $muteList]) => {
|
derived([user, deriveUserItem(MuteLists)], ([$user, $muteList]) => {
|
||||||
const tags = $muteList?.tags() ?? []
|
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