Add article support

This commit is contained in:
Jon Staab 2026-08-17 08:42:14 -07:00
parent 4c4b6b50a7
commit 974c6413af
34 changed files with 1251 additions and 58 deletions

View file

@ -88,6 +88,7 @@
"@welshman/util": "^0.9.0-pre5",
"cheerio": "^1.2.0",
"compressorjs-next": "^1.1.2",
"dompurify": "^3.4.13",
"dotenv": "^16.6.1",
"emoji-picker-element": "^1.28.1",
"emoji-picker-element-data": "^1.8.0",
@ -96,6 +97,7 @@
"husky": "^9.1.7",
"idb": "^8.0.3",
"livekit-client": "^2.17.2",
"marked": "^18.0.9",
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
"nostr-tools": "^2.19.4",
"prettier-plugin-tailwindcss": "^0.7.2",

Binary file not shown.

View file

@ -0,0 +1,55 @@
<script lang="ts">
import {uniq} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {getAddress} from "@welshman/util"
import {Article} from "@welshman/domain"
import Link from "@lib/components/Link.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {publishReaction, retractReaction} from "@app/reactions"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import {reader} from "@app/core"
import {makeArticlePath, makeSpacePath} from "@app/routes"
type Props = {
url: string
event: TrustedEvent
showRoom?: boolean
showActivity?: boolean
}
const {url, event, showRoom, showActivity}: Props = $props()
const article = reader(Article)(event)
const h = article.room()
const topics = article.topics()
const path = makeArticlePath(url, getAddress(event))
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
</script>
<div class="flex grow flex-wrap justify-end gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full">
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<div class="flex min-w-0 flex-wrap gap-2">
{#each uniq(topics) as topic (topic)}
<button type="button" class="button button-xs rounded-full font-normal">
#{topic}
</button>
{/each}
</div>
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Article" />
</div>

View file

@ -0,0 +1,215 @@
<script lang="ts">
import {writable} from "svelte/store"
import {now, randomId} from "@welshman/lib"
import {Article} from "@welshman/domain"
import {publish} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import TopicMultiSelect from "@app/components/TopicMultiSelect.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {command, relays, writer} from "@app/core"
import {DraftKey} from "@app/drafts"
import {makeEditor} from "@app/editor"
import {publishRoomQuote} from "@app/rooms"
import {pushToast} from "@app/toast"
type Values = {
d?: string
content?: string | object
title?: string
summary?: string
topics?: string[]
}
type Props = {
url: string
h?: string
shareToChat?: boolean
}
const {url, h, shareToChat = false}: Props = $props()
const draftKey = new DraftKey<Values>(`article:${url}:${h ?? ""}`)
const initialValues = draftKey.get()
const shouldProtect = $relays.hasNip(url, 70)
const uploading = writable(false)
const back = () => history.back()
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => {
if ($uploading || loading) return
if (!title) {
return pushToast({
theme: "error",
message: "Please provide a title for your article.",
})
}
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
if (!content) {
return pushToast({
theme: "error",
message: "Please write something for your article.",
})
}
loading = true
try {
const protect = await shouldProtect
const eventWriter = writer(Article)
.setIdentifier(d)
.setContent(content)
.setTitle(title)
.setTopics(topics)
.setPublishedAt(now())
.setProtected(protect)
.addTags(...ed.storage.nostr.getEditorTags())
.forceRelays(url)
if (summary) {
eventWriter.setSummary(summary)
}
if (h) {
eventWriter.setRoom(url, h)
}
const thunk = await command(eventWriter).then(publish)
const error = await thunk.waitForError()
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: thunk.event, protect})
}
} finally {
loading = false
}
}
let loading = $state(false)
const d = $state(initialValues?.d ?? randomId())
let title = $state(initialValues?.title ?? "")
let summary = $state(initialValues?.summary ?? "")
let topics = $state(initialValues?.topics ?? [])
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
url,
submit,
uploading,
onChange,
placeholder: "Write your article...",
content,
})
$effect(() => {
draftKey.update({d, title, summary, topics, content})
})
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
<ModalBody>
<ModalHeader>
<ModalTitle>Write an Article</ModalTitle>
<ModalSubtitle>Publish a long-form post to this space.</ModalSubtitle>
</ModalHeader>
<div class="flex flex-col gap-8 relative">
<Field>
{#snippet label()}
<p>Title*</p>
{/snippet}
{#snippet input()}
<label class="input flex w-full items-center gap-2">
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus={!isMobile}
bind:value={title}
class="grow"
type="text"
placeholder="What is this article about?" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Summary</p>
{/snippet}
{#snippet input()}
<label class="input flex w-full items-center gap-2">
<input
bind:value={summary}
class="grow"
type="text"
placeholder="A one line teaser for the article list" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Topics</p>
{/snippet}
{#snippet input()}
<TopicMultiSelect bind:value={topics} />
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Content*</p>
{/snippet}
{#snippet input()}
<div class="note-editor grow overflow-hidden">
<EditorContent {editor} />
</div>
{/snippet}
</Field>
<Button
data-tip="Add an image"
class="tip tip-left absolute bottom-1 right-2"
onclick={selectFiles}
disabled={loading}>
{#if $uploading}
<Spinner size="xs" />
{:else}
<Icon icon={Paperclip} size={3} />
{/if}
</Button>
</div>
</ModalBody>
<ModalFooter>
<Button class="button button-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="button button-primary" disabled={$uploading || loading}>
<Spinner {loading}>Publish Article</Spinner>
</Button>
</ModalFooter>
</Modal>

View file

@ -0,0 +1,57 @@
<script lang="ts">
import {formatTimestamp} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {getAddress} from "@welshman/util"
import {Article} from "@welshman/domain"
import Link from "@lib/components/Link.svelte"
import Content from "@app/components/Content.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import ArticleActions from "@app/components/ArticleActions.svelte"
import RoomLink from "@app/components/RoomLink.svelte"
import {reader} from "@app/core"
import {makeArticlePath} from "@app/routes"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const article = reader(Article)(event)
const title = article.title()
const summary = article.summary()
const image = article.image()
const h = article.room()
</script>
<Link
class="cv flex flex-col gap-2 card card-interactive w-full"
href={makeArticlePath(url, getAddress(event))}>
{#if image}
<img src={image} alt="" class="h-40 w-full rounded-2xl object-cover" />
{/if}
<div class="flex w-full items-center justify-between gap-2">
<p class="text-xl">{title || "Untitled"}</p>
<p class="whitespace-nowrap text-sm opacity-75">
{formatTimestamp(article.publishedAt())}
</p>
</div>
<Content
event={{content: summary || event.content, tags: event.tags}}
{url}
expandMode="inline"
minLength={100}
maxLength={300} />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75">
Written by
<ProfileLink pubkey={event.pubkey} {url} />
{#if h}
in <RoomLink {url} {h} />
{/if}
</span>
<ArticleActions showActivity {url} {event} />
</div>
</Link>

View file

@ -0,0 +1,80 @@
<script lang="ts">
import cx from "classnames"
import {countBy, identity, sortBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {Article} from "@welshman/domain"
import Button from "@lib/components/Button.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import {reader} from "@app/core"
type Props = {
url: string
events: TrustedEvent[]
author?: string
topic?: string
}
let {url, events, author = $bindable(), topic = $bindable()}: Props = $props()
const byCount = <T,>(counts: Map<T, number>) =>
sortBy(([, count]) => -count, Array.from(counts.entries()))
const authors = $derived(byCount(countBy(e => e.pubkey, events)))
const topics = $derived(
byCount(
countBy(
identity,
events.flatMap(e => reader(Article)(e).topics()),
),
),
)
const selectAuthor = (pubkey: string) => {
author = author === pubkey ? undefined : pubkey
}
const selectTopic = (name: string) => {
topic = topic === name ? undefined : name
}
</script>
<aside
class="hidden w-64 shrink-0 flex-col gap-4 py-2 pr-2 sm:py-4 sm:pr-4 lg:sticky lg:top-0 lg:flex lg:max-h-full lg:overflow-y-auto">
<section class="card card-sm flex flex-col gap-2">
<h2 class="text-lg">Authors</h2>
{#each authors as [pubkey, count] (pubkey)}
<Button
onclick={() => selectAuthor(pubkey)}
class={cx("button button-xs w-full rounded-full font-normal", {
"button-primary": pubkey === author,
})}>
<ProfileCircle {pubkey} {url} size={5} />
<span class="min-w-0 grow truncate text-left">
<ProfileName {pubkey} {url} />
</span>
<span class="opacity-75">{count}</span>
</Button>
{:else}
<p class="text-sm opacity-75">No authors yet.</p>
{/each}
</section>
{#if topics.length > 0}
<section class="card card-sm flex flex-col gap-2">
<h2 class="text-lg">Topics</h2>
<div class="flex flex-wrap gap-2">
{#each topics as [name, count] (name)}
<Button
onclick={() => selectTopic(name)}
class={cx("button button-xs rounded-full font-normal", {
"button-primary": name === topic,
})}>
#{name}
<span class="opacity-75">{count}</span>
</Button>
{/each}
</div>
</section>
{/if}
</aside>

View file

@ -11,7 +11,7 @@
type Props = {
url: string
event: TrustedEvent
segment: string
segment?: string
showActivity?: boolean
}
@ -19,7 +19,7 @@
const h = tagValue(tagSpec("h"), event.tags)
const path = makeSpacePath(url, segment, event.id)
const path = segment && makeSpacePath(url, segment, event.id)
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
@ -30,7 +30,7 @@
<div class="flex grow flex-wrap justify-end gap-2">
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
{#if showActivity && path}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Comment" />

View file

@ -0,0 +1,121 @@
<script lang="ts">
import {writable} from "svelte/store"
import type {TrustedEvent} from "@welshman/util"
import {publishToRelays} from "@welshman/app"
import {Comment} 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"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {command, relays, writer} from "@app/core"
import {DraftKey} from "@app/drafts"
import {makeEditor} from "@app/editor"
import {pushToast} from "@app/toast"
type Values = {
content?: string | object
}
type Props = {
url: string
event: TrustedEvent
parent?: TrustedEvent
onCancel: () => void
onSubmit: () => void
}
const {url, event, parent, onCancel, onSubmit}: Props = $props()
const draftKey = new DraftKey<Values>(`comment:${event.id}:${parent?.id ?? ""}`)
const initialValues = draftKey.get()
const uploading = writable(false)
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => {
if ($uploading || loading) return
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
if (!content) {
return pushToast({theme: "error", message: "Please write something first."})
}
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))
const thunk = await command(eventWriter).then(publishToRelays([url]))
const error = await thunk.waitForError()
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
onSubmit()
} finally {
loading = false
}
}
let loading = $state(false)
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
url,
submit,
uploading,
onChange,
content,
placeholder: parent ? "Write a reply..." : "Write a comment...",
})
$effect(() => {
draftKey.set({content})
})
</script>
<form onsubmit={preventDefault(submit)} class="card card-sm flex flex-col gap-2">
<div class="relative">
<div class="note-editor grow overflow-hidden">
<EditorContent autofocus={!isMobile} {editor} />
</div>
<Button
data-tip="Add an image"
class="tip tip-left absolute bottom-1 right-2"
onclick={selectFiles}
disabled={loading}>
{#if $uploading}
<Spinner size="xs" />
{:else}
<Icon icon={Paperclip} size={3} />
{/if}
</Button>
</div>
<div class="flex justify-end gap-2">
<Button class="button button-link" onclick={onCancel} disabled={loading}>Cancel</Button>
<Button type="submit" class="button button-primary button-sm" disabled={$uploading || loading}>
<Spinner {loading}>
{#if parent}
Reply
{:else}
Comment
{/if}
</Spinner>
</Button>
</div>
</form>

View file

@ -0,0 +1,62 @@
<script lang="ts">
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 CommentTree from "@app/components/CommentTree.svelte"
import CommentCompose from "@app/components/CommentCompose.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import CommentActions from "@app/components/CommentActions.svelte"
import type {CommentNode} from "@app/social"
type Props = {
node: CommentNode
root: TrustedEvent
replyTo?: TrustedEvent
setReplyTo: (comment?: TrustedEvent) => void
url?: string
}
const {node, root, replyTo, setReplyTo, url}: Props = $props()
const composing = $derived(replyTo?.id === node.comment.id)
const reply = () => setReplyTo(node.comment)
const clearReplyTo = () => setReplyTo(undefined)
</script>
<div class="flex flex-col gap-3">
<NoteCard event={node.comment} {url} class="card z-feature w-full">
<div class="flex flex-col gap-3 ml-12">
<NoteContent showEntire event={node.comment} {url} />
{#if url}
<div class="flex flex-wrap items-center justify-between gap-2">
<Button class="button button-neutral button-sm" onclick={reply}>
<Icon icon={Reply} />
Reply
</Button>
<CommentActions event={node.comment} {url} />
</div>
{/if}
</div>
</NoteCard>
{#if composing && url}
<div class="ml-4 pl-4">
<CommentCompose
{url}
event={root}
parent={node.comment}
onCancel={clearReplyTo}
onSubmit={clearReplyTo} />
</div>
{/if}
{#if node.children.length > 0}
<div class="flex flex-col gap-3 border-l border-solid border-line ml-4 pl-4">
{#each node.children as child (child.comment.id)}
<CommentTree node={child} {root} {replyTo} {setReplyTo} {url} />
{/each}
</div>
{/if}
</div>

View file

@ -4,12 +4,14 @@
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
import DocumentText from "@assets/icons/document-text.svg?dataurl"
import Revote from "@assets/icons/revote.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import {pushModal} from "@app/modal"
import CalendarEventCreate from "@app/components/CalendarEventCreate.svelte"
import ThreadCreate from "@app/components/ThreadCreate.svelte"
import ArticleCreate from "@app/components/ArticleCreate.svelte"
import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte"
import GoalCreate from "@app/components/GoalCreate.svelte"
import PollCreate from "@app/components/PollCreate.svelte"
@ -32,6 +34,8 @@
const createPoll = () => pushModal(PollCreate, {url, h, shareToChat: true})
const createArticle = () => pushModal(ArticleCreate, {url, h, shareToChat: true})
let ul: Element
onMount(() => {
@ -64,6 +68,12 @@
Create Thread
</Button>
</li>
<li>
<Button onclick={createArticle}>
<Icon size={4} icon={DocumentText} />
Write an Article
</Button>
</li>
<li>
<Button onclick={createPoll}>
<Icon size={4} icon={Revote} />

View file

@ -0,0 +1,123 @@
<script lang="ts" module>
import DOMPurify from "dompurify"
// Only the entity links linkEntities builds are internal. Testing for a leading slash would
// also match a protocol-relative `//evil.com`, which is very much not.
const isEntityPath = (href: string) =>
Boolean(href.match(/^\/n(event|ote|pub|profile|addr)1\w+$/))
// DOMPurify's defaults are wider than markdown needs — they keep `style` (overlay and
// click-jacking surface) and `form`. Allow only what marked actually emits.
const SANITIZE_OPTIONS = {
ALLOWED_TAGS: [
"a",
"blockquote",
"br",
"code",
"del",
"em",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"img",
"li",
"ol",
"p",
"pre",
"strong",
"sub",
"sup",
"table",
"tbody",
"td",
"th",
"thead",
"tr",
"ul",
],
ALLOWED_ATTR: ["align", "alt", "href", "rel", "src", "start", "target", "title"],
}
// DOMPurify has no option for target, so its docs point at this hook. It runs once at module
// load rather than per render, since hooks live on the DOMPurify instance.
DOMPurify.addHook("afterSanitizeAttributes", node => {
if (node.tagName === "A" && !isEntityPath(node.getAttribute("href") ?? "")) {
node.setAttribute("target", "_blank")
node.setAttribute("rel", "noopener noreferrer")
}
})
</script>
<script lang="ts">
import {marked} from "marked"
import * as nip19 from "nostr-tools/nip19"
import {goto} from "$app/navigation"
import {removeUndefined} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {fromNostrURI} from "@welshman/util"
import {profiles} from "@app/core"
type Props = {
event: TrustedEvent
url?: string
}
const {event, url}: Props = $props()
const entityPattern = /(nostr:)?n(event|ote|pub|profile|addr)\w{10,1000}/g
// A display name is attacker-controlled, and it goes into markdown link syntax. Without this,
// a name containing `](` re-points its own link (phishing) and a name containing `<` injects
// raw html. Escaping also means a name renders as written rather than as markdown.
const escapeMarkdown = (text: string) => text.replace(/[\\[\]<`*_]/g, "\\$&")
// Bech32 entities aren't markdown, so swap them for links before parsing. Profiles get their
// display name, so an article reads as prose rather than a wall of bech32.
const linkEntities = (markdown: string) =>
markdown.replace(entityPattern, match => {
const entity = fromNostrURI(match)
const hints = removeUndefined([url])
let display = entity.slice(0, 16) + "…"
try {
const {type, data} = nip19.decode(entity)
if (type === "npub") {
display = "@" + $profiles.display(data, hints).get()
}
if (type === "nprofile") {
display = "@" + $profiles.display(data.pubkey, hints).get()
}
} catch {
// An entity we can't decode still reads better truncated than as raw bech32
}
return `[${escapeMarkdown(display)}](/${entity})`
})
const html = $derived(
DOMPurify.sanitize(marked.parse(linkEntities(event.content), {async: false}), SANITIZE_OPTIONS),
)
// Entity links point at the app's own bech32 route, so route them rather than reloading.
const onclick = (clickEvent: MouseEvent) => {
const href = (clickEvent.target as HTMLElement).closest("a")?.getAttribute("href") ?? undefined
if (href && isEntityPath(href)) {
clickEvent.preventDefault()
goto(href)
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div {onclick} class="content-markdown flex flex-col gap-4 overflow-hidden leading-6">
{@html html}
</div>

View file

@ -1,7 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
import {max, gt, formatTimestampRelative} from "@welshman/lib"
import {COMMENT} from "@welshman/util"
import {getCommentFiltersForRoot} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
@ -12,7 +12,7 @@
const {url, path, event}: {url: string; path: string; event: TrustedEvent} = $props()
const checked = deriveChecked(path)
const filters = [{kinds: [COMMENT], "#E": [event.id]}]
const filters = getCommentFiltersForRoot([event])
const replies = deriveEvents(filters)
const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))

View file

@ -0,0 +1,66 @@
<script lang="ts">
import {onMount} from "svelte"
import {removeUndefined} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {getCommentFiltersForRoot} from "@welshman/util"
import {deriveEventsAsc} from "@welshman/store"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import CommentTree from "@app/components/CommentTree.svelte"
import CommentCompose from "@app/components/CommentCompose.svelte"
import {network} from "@app/core"
import {deriveEventsById} from "@app/repository"
import {buildCommentTree} from "@app/social"
type Props = {
event: TrustedEvent
url?: string
}
const {event, url}: Props = $props()
const relays = removeUndefined([url])
const filters = getCommentFiltersForRoot([event])
const comments = deriveEventsAsc(deriveEventsById(filters))
const nodes = $derived(buildCommentTree(event, $comments))
const setReplyTo = (comment?: TrustedEvent) => {
replyTo = comment
}
const commentOnRoot = () => setReplyTo(event)
const clearReplyTo = () => setReplyTo(undefined)
let replyTo: TrustedEvent | undefined = $state()
onMount(() => {
if (relays.length > 0) {
const controller = new AbortController()
$network.request({relays, filters, signal: controller.signal})
return () => controller.abort()
}
})
</script>
<div class="flex flex-col gap-3">
{#each nodes as node (node.comment.id)}
<CommentTree {node} root={event} {replyTo} {setReplyTo} {url} />
{/each}
</div>
{#if url}
{#if replyTo?.id === event.id}
<CommentCompose {url} {event} onCancel={clearReplyTo} onSubmit={clearReplyTo} />
{:else}
<div class="flex justify-end">
<Button class="button button-primary" onclick={commentOnRoot}>
<Icon icon={Reply} />
Add a comment
</Button>
</div>
{/if}
{/if}

View file

@ -1,9 +1,19 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, POLL, PIN, PINBOARD} from "@welshman/util"
import {
EVENT_TIME,
ZAP_GOAL,
THREAD,
CLASSIFIED,
LONG_FORM,
POLL,
PIN,
PINBOARD,
} from "@welshman/util"
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
import NoteContentThread from "@app/components/NoteContentThread.svelte"
import NoteContentClassified from "@app/components/NoteContentClassified.svelte"
import NoteContentArticle from "@app/components/NoteContentArticle.svelte"
import NoteContentGoal from "@app/components/NoteContentGoal.svelte"
import NoteContentPoll from "@app/components/NoteContentPoll.svelte"
import NoteContentPin from "@app/components/NoteContentPin.svelte"
@ -20,6 +30,8 @@
<NoteContentThread {...props} />
{:else if props.event.kind === CLASSIFIED}
<NoteContentClassified {...props} />
{:else if props.event.kind === LONG_FORM}
<NoteContentArticle {...props} />
{:else if props.event.kind === ZAP_GOAL}
<NoteContentGoal {...props} />
{:else if props.event.kind === POLL}

View file

@ -0,0 +1,25 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {Article} from "@welshman/domain"
import Content from "@app/components/Content.svelte"
import {reader} from "@app/core"
const props: ComponentProps<typeof Content> = $props()
const article = reader(Article)(props.event)
const title = article.title()
const image = article.image()
</script>
<div class="flex flex-col gap-2">
{#if image}
<img src={image} alt="" class="h-40 w-full rounded-2xl object-cover" />
{/if}
{#if title}
<p class="text-xl">{title}</p>
{/if}
{#if props.event.content}
<Content {...props} />
{/if}
</div>

View file

@ -1,9 +1,19 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, POLL, PINBOARD, PIN} from "@welshman/util"
import {
EVENT_TIME,
ZAP_GOAL,
THREAD,
CLASSIFIED,
LONG_FORM,
POLL,
PINBOARD,
PIN,
} from "@welshman/util"
import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte"
import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte"
import NoteContentMinimalClassified from "@app/components/NoteContentMinimalClassified.svelte"
import NoteContentMinimalArticle from "@app/components/NoteContentMinimalArticle.svelte"
import NoteContentMinimalGoal from "@app/components/NoteContentMinimalGoal.svelte"
import NoteContentMinimalPoll from "@app/components/NoteContentMinimalPoll.svelte"
import NoteContentMinimalBoard from "@app/components/NoteContentMinimalBoard.svelte"
@ -20,6 +30,8 @@
<NoteContentMinimalThread {...props} />
{:else if props.event.kind === CLASSIFIED}
<NoteContentMinimalClassified {...props} />
{:else if props.event.kind === LONG_FORM}
<NoteContentMinimalArticle {...props} />
{:else if props.event.kind === ZAP_GOAL}
<NoteContentMinimalGoal {...props} />
{:else if props.event.kind === POLL}

View file

@ -0,0 +1,17 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {Article} from "@welshman/domain"
import ContentMinimal from "@app/components/ContentMinimal.svelte"
import {reader} from "@app/core"
const props: ComponentProps<typeof ContentMinimal> = $props()
const title = reader(Article)(props.event).title()
</script>
{#if title}
<span class="text-sm">{title}</span>
{/if}
{#if props.event.content}
<ContentMinimal {...props} />
{/if}

View file

@ -1,8 +1,9 @@
<script lang="ts">
import {THREAD, CLASSIFIED, ZAP_GOAL, EVENT_TIME, POLL} from "@welshman/util"
import {THREAD, CLASSIFIED, LONG_FORM, ZAP_GOAL, EVENT_TIME, POLL} from "@welshman/util"
import NoteItem from "@app/components/NoteItem.svelte"
import ThreadItem from "@app/components/ThreadItem.svelte"
import ClassifiedItem from "@app/components/ClassifiedItem.svelte"
import ArticleItem from "@app/components/ArticleItem.svelte"
import GoalItem from "@app/components/GoalItem.svelte"
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
import PollItem from "@app/components/PollItem.svelte"
@ -23,6 +24,8 @@
<ThreadItem {url} event={item.event} />
{:else if item.event.kind === CLASSIFIED}
<ClassifiedItem {url} event={item.event} />
{:else if item.event.kind === LONG_FORM}
<ArticleItem {url} event={item.event} />
{:else if item.event.kind === ZAP_GOAL}
<GoalItem {url} event={item.event} />
{:else if item.event.kind === EVENT_TIME}

View file

@ -10,7 +10,14 @@
formatTimestampAsDate,
} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {MESSAGE, COMMENT, getIdOrAddress, matchTag, tagSpec, tagValue} from "@welshman/util"
import {
MESSAGE,
getCommentFiltersForParent,
getIdOrAddress,
matchTag,
tagSpec,
tagValue,
} from "@welshman/util"
import {isMobile} from "@lib/html"
import Pen from "@assets/icons/pen.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
@ -57,10 +64,10 @@
const isQuoteOnly = Boolean(
gte(qTag?.length, 2) && event.content.trim().match(/^nostr:n(event|addr)1\w+\s*$/),
)
const innerComments = isQuoteOnly
? deriveEventsForUrl(url, [{kinds: [COMMENT], "#e": [qTag![1]]}])
: readable([])
const innerEvent = isQuoteOnly ? deriveEvent(qTag![1], [url]) : readable(undefined)
const innerComments = $derived(
$innerEvent ? deriveEventsForUrl(url, getCommentFiltersForParent([$innerEvent])) : readable([]),
)
const path = $derived(
$innerEvent && makeContentPath(url, $innerEvent.kind, getIdOrAddress($innerEvent)),

View file

@ -1,7 +1,7 @@
<script lang="ts">
import {derived} from "svelte/store"
import {getJson, setJson} from "@welshman/lib"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, PINBOARD, POLL} from "@welshman/util"
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, PINBOARD, POLL, LONG_FORM} from "@welshman/util"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
import Home from "@assets/icons/home.svg?dataurl"
@ -12,6 +12,7 @@
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
import DocumentText from "@assets/icons/document-text.svg?dataurl"
import Revote from "@assets/icons/revote.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
@ -36,6 +37,7 @@
const goalsPath = makeSpacePath(url, "goals")
const threadsPath = makeSpacePath(url, "threads")
const classifiedsPath = makeSpacePath(url, "classifieds")
const articlesPath = makeSpacePath(url, "articles")
const calendarPath = makeSpacePath(url, "calendar")
const pollsPath = makeSpacePath(url, "polls")
@ -88,6 +90,11 @@
<Icon icon={NotesMinimalistic} /> Threads
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(LONG_FORM)}
<SecondaryNavItem href={articlesPath} notification={$notifications.has(articlesPath)}>
<Icon icon={DocumentText} /> Articles
</SecondaryNavItem>
{/if}
{#if $spaceKinds.has(CLASSIFIED)}
<SecondaryNavItem href={classifiedsPath} notification={$notifications.has(classifiedsPath)}>
<Icon icon={CaseMinimalistic} /> Classifieds

View file

@ -2,7 +2,7 @@
import {goto} from "$app/navigation"
import {formatTimestamp, max} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {COMMENT, tagSpec, tagValue} from "@welshman/util"
import {getCommentFiltersForRoot, tagSpec, tagValue} from "@welshman/util"
import {fade} from "@lib/transition"
import Link from "@lib/components/Link.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
@ -19,8 +19,7 @@
const {url, event, mobile = false}: Props = $props()
const filters = [{kinds: [COMMENT], "#E": [event.id]}]
const replies = deriveEventsForUrl(url, filters)
const replies = deriveEventsForUrl(url, getCommentFiltersForRoot([event]))
const replyCount = $derived($replies.length)
const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))
const title = tagValue(tagSpec("title"), event.tags)

View file

@ -7,6 +7,7 @@ import {
EVENT_TIME,
GENERIC_REPOST,
PINBOARD,
LONG_FORM,
POLL,
REACTION,
REPORT,
@ -41,7 +42,7 @@ if (ENABLE_ZAPS) {
REACTION_KINDS.push(ZAP_RECEIPT)
}
export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED, POLL, PINBOARD]
export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED, POLL, PINBOARD, LONG_FORM]
export const DM_KINDS = [DIRECT_MESSAGE, DIRECT_MESSAGE_FILE]

View file

@ -8,6 +8,7 @@ import type {TrustedEvent} from "@welshman/util"
import {
CLASSIFIED,
EVENT_TIME,
LONG_FORM,
MESSAGE,
PINBOARD,
POLL,
@ -130,6 +131,9 @@ export const makeThreadPath = (url: string, id?: string) => makeSpacePath(url, "
export const makeClassifiedPath = (url: string, address?: string) =>
makeSpacePath(url, "classifieds", address)
export const makeArticlePath = (url: string, address?: string) =>
makeSpacePath(url, "articles", address)
export const makeCalendarPath = (url: string, address?: string) =>
makeSpacePath(url, "calendar", address)
@ -155,6 +159,8 @@ export const makeContentPath = (url: string, kind: number, idOrAddress?: string)
return makeThreadPath(url, idOrAddress)
case CLASSIFIED:
return makeClassifiedPath(url, idOrAddress)
case LONG_FORM:
return makeArticlePath(url, idOrAddress)
case EVENT_TIME:
return makeCalendarPath(url, idOrAddress)
case PINBOARD:

View file

@ -1,9 +1,10 @@
import {derived} from "svelte/store"
import {shuffle} from "@welshman/lib"
import {pushToMapKey, shuffle, sortBy, uniqBy} from "@welshman/lib"
import {
COMMENT,
addressTags,
getAddress,
getIdAndAddress,
hexTags,
tagSpec,
tagValues,
@ -30,6 +31,54 @@ const getParents = ({kind, tags}: TrustedEvent) => {
return replies.length > 0 ? replies : roots
}
export type CommentNode = {
comment: TrustedEvent
children: CommentNode[]
}
export const buildCommentTree = (root: TrustedEvent, comments: TrustedEvent[]) => {
const byParent = new Map<string, TrustedEvent[]>()
// A comment names its parent by id, and by address too when the parent is addressable, so
// index it under every value it gives and dedupe on the way back out.
for (const comment of comments) {
for (const parent of getParents(comment)) {
pushToMapKey(byParent, parent, comment)
}
}
// 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 build = (parent: TrustedEvent): CommentNode[] => {
const children = uniqBy(
e => e.id,
getIdAndAddress(parent).flatMap(value => byParent.get(value) ?? []),
).filter(e => !seen.has(e.id))
for (const child of children) {
seen.add(child.id)
}
return children.map(comment => ({comment, children: build(comment)}))
}
const nodes = build(root)
// A comment can name a parent we don't have — a superseded version of an addressable root,
// 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)
nodes.push({comment, children: build(comment)})
}
}
return sortBy(node => node.comment.created_at, nodes)
}
export const isEventMuted = withGetter(
derived([user, deriveUserItem(MuteLists)], ([$user, $muteList]) => {
const tags = $muteList?.tags() ?? []

View file

@ -20,6 +20,7 @@ const staticTitles = new Map<string, string>([
["/spaces/[relay]/recent", "Recent Activity"],
["/spaces/[relay]/threads", "Threads"],
["/spaces/[relay]/classifieds", "Classifieds"],
["/spaces/[relay]/articles", "Articles"],
["/spaces/[relay]/calendar", "Calendar"],
["/spaces/[relay]/goals", "Goals"],
["/spaces/[relay]/polls", "Polls"],
@ -43,6 +44,7 @@ const eventRoutes = new Set([
"/spaces/[relay]/goals/[id]",
"/spaces/[relay]/calendar/[address]",
"/spaces/[relay]/classifieds/[address]",
"/spaces/[relay]/articles/[address]",
"/spaces/[relay]/polls/[id]",
])
@ -150,6 +152,10 @@ export const getPageTitle = ({page, pubkey}: PageTitleContext) => {
return makeTitle(tagValue(tagSpec("title"), event?.tags || []) || "Listing")
}
if (routeId === "/spaces/[relay]/articles/[address]") {
return makeTitle(tagValue(tagSpec("title"), event?.tags || []) || "Article")
}
if (routeId === "/spaces/[relay]/goals/[id]") {
return makeTitle(event?.content || tagValue(tagSpec("summary"), event?.tags || []) || "Goal")
}

View file

@ -0,0 +1,72 @@
@layer components {
/* Rendered markdown is raw html, so it can't carry utility classes — style the tags. */
.content-markdown {
h1 {
@apply text-2xl leading-tight;
font-family: var(--font-heading);
}
h2 {
@apply text-xl leading-tight;
font-family: var(--font-heading);
}
h3 {
@apply text-lg leading-snug;
font-family: var(--font-heading);
}
ul {
@apply list-inside list-disc;
}
ol {
@apply list-inside list-decimal;
}
li {
@apply my-2;
}
/* Markdown wraps loose list items in a paragraph, which would break the line */
li p {
@apply inline;
}
a {
@apply text-primary underline;
}
img {
@apply max-w-full rounded-2xl;
}
hr {
@apply bg-line rounded-full;
border: none;
height: var(--border-thin);
}
code {
@apply bg-surface-less rounded px-1;
box-decoration-break: clone;
}
pre code {
@apply block overflow-x-auto p-3;
}
blockquote {
@apply border-line text-content-muted border-l-4 border-solid pl-4;
}
table {
@apply block w-full border-collapse overflow-x-auto;
}
th,
td {
@apply border-line border border-solid px-3 py-2 text-left;
}
}
}

View file

@ -24,6 +24,7 @@
@import "./card.css";
@import "./card-button.css";
@import "./chat.css";
@import "./content-markdown.css";
@import "./content-search.css";
@import "./controls.css";
@import "./divider.css";

View file

@ -0,0 +1,114 @@
<script lang="ts">
import {onMount} from "svelte"
import {readable} from "svelte/store"
import type {Readable} from "svelte/store"
import {page} from "$app/stores"
import {sortBy, partition, spec, max, pushToMapKey} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {LONG_FORM, getAddress, tagSpec, tagValue} from "@welshman/util"
import {Article} from "@welshman/domain"
import {fly} from "@lib/transition"
import DocumentText from "@assets/icons/document-text.svg?dataurl"
import Add from "@assets/icons/add.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import ArticleItem from "@app/components/ArticleItem.svelte"
import ArticleCreate from "@app/components/ArticleCreate.svelte"
import ArticleSidebar from "@app/components/ArticleSidebar.svelte"
import {reader} from "@app/core"
import {decodeRelay} from "@app/relays"
import {makeCommentFilter} from "@app/content"
import {makeFeed} from "@app/feeds"
import {pushModal} from "@app/modal"
const url = decodeRelay($page.params.relay!)
let loading = $state(true)
let author: string | undefined = $state()
let topic: string | undefined = $state()
let element: HTMLElement | undefined = $state()
let events: Readable<TrustedEvent[]> = $state(readable([]))
const createArticle = () => pushModal(ArticleCreate, {url})
const articles = $derived.by(() => {
const scores = new Map<string, number[]>()
const [items, comments] = partition(spec({kind: LONG_FORM}), $events)
for (const comment of comments) {
const address = tagValue(tagSpec("A"), comment.tags)
if (address) {
pushToMapKey(scores, address, comment.created_at)
}
}
return sortBy(e => -max([...(scores.get(getAddress(e)) || []), e.created_at]), items)
})
const filtered = $derived(
articles.filter(
e =>
(!author || e.pubkey === author) && (!topic || reader(Article)(e).topics().includes(topic)),
),
)
onMount(() => {
const feed = makeFeed({
relays: [url],
element: element!,
filters: [{kinds: [LONG_FORM]}, makeCommentFilter([LONG_FORM])],
onBackwardExhausted: () => {
loading = false
},
})
events = feed.events
return () => {
feed.cleanup()
}
})
</script>
<SpaceBar>
{#snippet leading()}
<Icon icon={DocumentText} />
{/snippet}
{#snippet title()}
<strong>Articles</strong>
{/snippet}
{#snippet action()}
<Button class="button button-primary button-sm" onclick={createArticle}>
<Icon icon={Add} />
Write
</Button>
{/snippet}
</SpaceBar>
<PageContent bind:element class="flex flex-col gap-2 sm:gap-4 lg:flex-row lg:items-start !p-0">
<div class="flex min-w-0 grow flex-col gap-2 sm:gap-4 p-2 sm:p-4 lg:pr-0">
{#each filtered as event (getAddress(event))}
<div in:fly>
<ArticleItem {url} {event} />
</div>
{/each}
<p class="flex h-10 items-center justify-center py-20">
<Spinner {loading}>
{#if loading}
Looking for articles...
{:else if articles.length === 0}
No articles found.
{:else if filtered.length === 0}
No articles match that filter.
{:else}
That's all!
{/if}
</Spinner>
</p>
</div>
<ArticleSidebar {url} events={articles} bind:author bind:topic />
</PageContent>

View file

@ -0,0 +1,62 @@
<script lang="ts">
import {derived} from "svelte/store"
import {page} from "$app/stores"
import {formatTimestamp, sleep} from "@welshman/lib"
import type {MakeNonOptional} from "@welshman/lib"
import {Article} from "@welshman/domain"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import ContentMarkdown from "@app/components/ContentMarkdown.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import ArticleActions from "@app/components/ArticleActions.svelte"
import EventComments from "@app/components/EventComments.svelte"
import {reader} from "@app/core"
import {deriveEvent} from "@app/repository"
import {decodeRelay} from "@app/relays"
const {relay, address} = $page.params as MakeNonOptional<typeof $page.params>
const url = decodeRelay(relay)
const event = deriveEvent(address, [url])
const article = derived(event, $event => ($event ? reader(Article)($event) : undefined))
const back = () => history.back()
</script>
<SpaceBar {back}>
{#snippet title()}
<h1 class="truncate text-xl">{$article?.title() ?? ""}</h1>
{/snippet}
</SpaceBar>
<PageContent class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
{#if $event && $article}
<NoteCard event={$event} {url} class="card z-feature w-full">
<div class="flex flex-col gap-3 ml-12">
{#if $article.image()}
<img src={$article.image()} alt="" class="h-56 w-full rounded-2xl object-cover" />
{/if}
<div class="flex flex-col gap-1">
<h2 class="text-2xl">{$article.title() ?? "Untitled"}</h2>
<p class="text-sm opacity-75">
Published {formatTimestamp($article.publishedAt())}
</p>
</div>
{#if $article.summary()}
<p class="text-lg opacity-75">{$article.summary()}</p>
{/if}
<ContentMarkdown event={$event} {url} />
<ArticleActions showRoom event={$event} {url} />
</div>
</NoteCard>
<EventComments event={$event} {url} />
{:else}
<div class="flex justify-center py-20">
{#await sleep(5000)}
<Spinner loading>Loading article...</Spinner>
{:then}
<p>Failed to load article.</p>
{/await}
</div>
{/if}
</PageContent>

View file

@ -1,10 +1,9 @@
<script lang="ts">
import {derived} from "svelte/store"
import {onMount} from "svelte"
import {page} from "$app/stores"
import {sortBy, sleep} from "@welshman/lib"
import type {MakeNonOptional} from "@welshman/lib"
import {COMMENT} from "@welshman/util"
import {getCommentFiltersForRoot} from "@welshman/util"
import {TimeEvent} from "@welshman/domain"
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
@ -29,8 +28,8 @@
const url = decodeRelay(relay)
const event = deriveEvent(address, [url])
const timeEvent = derived(event, $event => ($event ? reader(TimeEvent)($event) : undefined))
const filters = [{kinds: [COMMENT], "#A": [address]}]
const replies = deriveEvents(filters)
const filters = $derived($event ? getCommentFiltersForRoot([$event]) : [])
const replies = $derived(deriveEvents(filters))
const back = () => history.back()
@ -49,13 +48,13 @@
let showAll = $state(false)
let showReply = $state(false)
onMount(() => {
const controller = new AbortController()
$effect(() => {
if (filters.length > 0) {
const controller = new AbortController()
$network.request({relays: [url], filters, signal: controller.signal})
$network.request({relays: [url], filters, signal: controller.signal})
return () => {
controller.abort()
return () => controller.abort()
}
})
</script>

View file

@ -1,10 +1,9 @@
<script lang="ts">
import {derived} from "svelte/store"
import {onMount} from "svelte"
import {page} from "$app/stores"
import {sleep} from "@welshman/lib"
import type {MakeNonOptional} from "@welshman/lib"
import {COMMENT} from "@welshman/util"
import {getCommentFiltersForRoot} from "@welshman/util"
import {deriveEventsAsc} from "@welshman/store"
import {Classified} from "@welshman/domain"
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
@ -27,8 +26,8 @@
const url = decodeRelay(relay)
const event = deriveEvent(address, [url])
const classified = derived(event, $event => ($event ? reader(Classified)($event) : undefined))
const filters = [{kinds: [COMMENT], "#A": [address]}]
const replies = deriveEventsAsc(deriveEventsById(filters))
const filters = $derived($event ? getCommentFiltersForRoot([$event]) : [])
const replies = $derived(deriveEventsAsc(deriveEventsById(filters)))
const back = () => history.back()
@ -47,13 +46,13 @@
let showAll = $state(false)
let showReply = $state(false)
onMount(() => {
const controller = new AbortController()
$effect(() => {
if (filters.length > 0) {
const controller = new AbortController()
$network.request({relays: [url], filters, signal: controller.signal})
$network.request({relays: [url], filters, signal: controller.signal})
return () => {
controller.abort()
return () => controller.abort()
}
})
</script>

View file

@ -1,10 +1,9 @@
<script lang="ts">
import {derived} from "svelte/store"
import {onMount} from "svelte"
import {page} from "$app/stores"
import {sleep} from "@welshman/lib"
import type {MakeNonOptional} from "@welshman/lib"
import {COMMENT} from "@welshman/util"
import {getCommentFiltersForRoot} from "@welshman/util"
import {deriveEventsAsc} from "@welshman/store"
import {ZapGoal} from "@welshman/domain"
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
@ -26,8 +25,8 @@
const {relay, id} = $page.params as MakeNonOptional<typeof $page.params>
const url = decodeRelay(relay)
const event = deriveEvent(id, [url])
const filters = [{kinds: [COMMENT], "#E": [id]}]
const replies = deriveEventsAsc(deriveEventsById(filters))
const filters = $derived($event ? getCommentFiltersForRoot([$event]) : [])
const replies = $derived(deriveEventsAsc(deriveEventsById(filters)))
const goal = derived(event, $event => ($event ? reader(ZapGoal)($event) : undefined))
const summary = $derived($goal?.summary() ?? "")
@ -48,13 +47,13 @@
let showAll = $state(false)
let showReply = $state(false)
onMount(() => {
const controller = new AbortController()
$effect(() => {
if (filters.length > 0) {
const controller = new AbortController()
$network.request({relays: [url], filters, signal: controller.signal})
$network.request({relays: [url], filters, signal: controller.signal})
return () => {
controller.abort()
return () => controller.abort()
}
})
</script>

View file

@ -3,7 +3,7 @@
import {page} from "$app/stores"
import {sleep} from "@welshman/lib"
import type {MakeNonOptional} from "@welshman/lib"
import {COMMENT, POLL, POLL_RESPONSE} from "@welshman/util"
import {POLL, POLL_RESPONSE, getCommentFiltersForRoot} from "@welshman/util"
import {deriveEventsAsc} from "@welshman/store"
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
@ -22,8 +22,8 @@
const {relay, id} = $page.params as MakeNonOptional<typeof $page.params>
const url = decodeRelay(relay)
const event = deriveEvent(id, [url])
const filters = [{kinds: [COMMENT], "#E": [id]}]
const comments = deriveEventsAsc(deriveEventsById(filters))
const filters = $derived($event ? getCommentFiltersForRoot([$event]) : [])
const comments = $derived(deriveEventsAsc(deriveEventsById(filters)))
const back = () => history.back()
@ -47,7 +47,10 @@
$network.request({
relays: [url],
filters: [{kinds: [POLL], ids: [id]}, {kinds: [POLL_RESPONSE], "#e": [id]}, ...filters],
filters: [
{kinds: [POLL], ids: [id]},
{kinds: [POLL_RESPONSE], "#e": [id]},
],
signal: controller.signal,
})
@ -55,6 +58,16 @@
controller.abort()
}
})
$effect(() => {
if (filters.length > 0) {
const controller = new AbortController()
$network.request({relays: [url], filters, signal: controller.signal})
return () => controller.abort()
}
})
</script>
<SpaceBar {back}>

View file

@ -1,5 +1,4 @@
<script lang="ts">
import {onMount} from "svelte"
import * as nip19 from "nostr-tools/nip19"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
@ -7,7 +6,7 @@
import type {MakeNonOptional} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {deriveEventsAsc} from "@welshman/store"
import {COMMENT, tagValue, tagSpec} from "@welshman/util"
import {getCommentFiltersForRoot, tagValue, tagSpec} from "@welshman/util"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import PageContent from "@lib/components/PageContent.svelte"
@ -29,8 +28,8 @@
const {relay, id} = $page.params as MakeNonOptional<typeof $page.params>
const url = decodeRelay(relay)
const event = deriveEvent(id, [url])
const filters = [{kinds: [COMMENT], "#E": [id]}]
const replies = deriveEventsAsc(deriveEventsById(filters))
const filters = $derived($event ? getCommentFiltersForRoot([$event]) : [])
const replies = $derived(deriveEventsAsc(deriveEventsById(filters)))
const back = () => history.back()
@ -135,13 +134,13 @@
setTimeout(() => scrollToEvent(posts[index]!.id), 100)
})
onMount(() => {
const controller = new AbortController()
$effect(() => {
if (filters.length > 0) {
const controller = new AbortController()
$network.request({relays: [url], filters, signal: controller.signal})
$network.request({relays: [url], filters, signal: controller.signal})
return () => {
controller.abort()
return () => controller.abort()
}
})
</script>