Add article support
This commit is contained in:
parent
4c4b6b50a7
commit
974c6413af
34 changed files with 1251 additions and 58 deletions
|
|
@ -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",
|
||||
|
|
|
|||
BIN
pnpm-lock.yaml
BIN
pnpm-lock.yaml
Binary file not shown.
55
src/app/components/ArticleActions.svelte
Normal file
55
src/app/components/ArticleActions.svelte
Normal 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>
|
||||
215
src/app/components/ArticleCreate.svelte
Normal file
215
src/app/components/ArticleCreate.svelte
Normal 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>
|
||||
57
src/app/components/ArticleItem.svelte
Normal file
57
src/app/components/ArticleItem.svelte
Normal 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>
|
||||
80
src/app/components/ArticleSidebar.svelte
Normal file
80
src/app/components/ArticleSidebar.svelte
Normal 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>
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
121
src/app/components/CommentCompose.svelte
Normal file
121
src/app/components/CommentCompose.svelte
Normal 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>
|
||||
62
src/app/components/CommentTree.svelte
Normal file
62
src/app/components/CommentTree.svelte
Normal 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>
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
123
src/app/components/ContentMarkdown.svelte
Normal file
123
src/app/components/ContentMarkdown.svelte
Normal 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>
|
||||
|
|
@ -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)))
|
||||
|
||||
|
|
|
|||
66
src/app/components/EventComments.svelte
Normal file
66
src/app/components/EventComments.svelte
Normal 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}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
25
src/app/components/NoteContentArticle.svelte
Normal file
25
src/app/components/NoteContentArticle.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
17
src/app/components/NoteContentMinimalArticle.svelte
Normal file
17
src/app/components/NoteContentMinimalArticle.svelte
Normal 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}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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() ?? []
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
72
src/lib/components/content-markdown.css
Normal file
72
src/lib/components/content-markdown.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
114
src/routes/spaces/[relay]/articles/+page.svelte
Normal file
114
src/routes/spaces/[relay]/articles/+page.svelte
Normal 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>
|
||||
62
src/routes/spaces/[relay]/articles/[address]/+page.svelte
Normal file
62
src/routes/spaces/[relay]/articles/[address]/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue