From 974c6413af1937913a44841e961c09c574c1fab8 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 17 Aug 2026 08:42:14 -0700 Subject: [PATCH] Add article support --- package.json | 2 + pnpm-lock.yaml | Bin 381500 -> 382072 bytes src/app/components/ArticleActions.svelte | 55 +++++ src/app/components/ArticleCreate.svelte | 215 ++++++++++++++++++ src/app/components/ArticleItem.svelte | 57 +++++ src/app/components/ArticleSidebar.svelte | 80 +++++++ src/app/components/CommentActions.svelte | 6 +- src/app/components/CommentCompose.svelte | 121 ++++++++++ src/app/components/CommentTree.svelte | 62 +++++ src/app/components/ComposeMenu.svelte | 10 + src/app/components/ContentMarkdown.svelte | 123 ++++++++++ src/app/components/EventActivity.svelte | 4 +- src/app/components/EventComments.svelte | 66 ++++++ src/app/components/NoteContent.svelte | 14 +- src/app/components/NoteContentArticle.svelte | 25 ++ src/app/components/NoteContentMinimal.svelte | 14 +- .../NoteContentMinimalArticle.svelte | 17 ++ src/app/components/RecentItem.svelte | 5 +- src/app/components/RoomItem.svelte | 15 +- src/app/components/SpaceMenuNavItems.svelte | 9 +- src/app/components/ThreadBoardItem.svelte | 5 +- src/app/content.ts | 3 +- src/app/routes.ts | 6 + src/app/social.ts | 51 ++++- src/app/title.ts | 6 + src/lib/components/content-markdown.css | 72 ++++++ src/lib/components/theme.css | 1 + .../spaces/[relay]/articles/+page.svelte | 114 ++++++++++ .../[relay]/articles/[address]/+page.svelte | 62 +++++ .../[relay]/calendar/[address]/+page.svelte | 17 +- .../classifieds/[address]/+page.svelte | 17 +- .../spaces/[relay]/goals/[id]/+page.svelte | 17 +- .../spaces/[relay]/polls/[id]/+page.svelte | 21 +- .../spaces/[relay]/threads/[id]/+page.svelte | 17 +- 34 files changed, 1251 insertions(+), 58 deletions(-) create mode 100644 src/app/components/ArticleActions.svelte create mode 100644 src/app/components/ArticleCreate.svelte create mode 100644 src/app/components/ArticleItem.svelte create mode 100644 src/app/components/ArticleSidebar.svelte create mode 100644 src/app/components/CommentCompose.svelte create mode 100644 src/app/components/CommentTree.svelte create mode 100644 src/app/components/ContentMarkdown.svelte create mode 100644 src/app/components/EventComments.svelte create mode 100644 src/app/components/NoteContentArticle.svelte create mode 100644 src/app/components/NoteContentMinimalArticle.svelte create mode 100644 src/lib/components/content-markdown.css create mode 100644 src/routes/spaces/[relay]/articles/+page.svelte create mode 100644 src/routes/spaces/[relay]/articles/[address]/+page.svelte diff --git a/package.json b/package.json index 3eb07e02..720941da 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de31a8f8fd09330190ee922366faca1bce854e30..24b0f1f5fd6d8994be796ae7e6973afc846e7b16 100644 GIT binary patch delta 380 zcmdn9MEu7J@eLej@i!#$HCvRjHV>8w>(K9rjT)-p30%C05FB`(dmYZ0VotiTF zptd-xg`R<)<>WwRVF;_4M`1gU0^{?YTo5xHfcmF5PG;1IFe=N9^vo~Js!BCV(auiH z4zLK;&kHs!&PXfHF|{zyt}1uUN_Ni)Ny*GE4h>6p^T=~^F7}CZ3Cq&=@+A~@NlA~VoE$g;{gpvWyfC@jd- zC)7BzGT1-UGpo|TGAgjl(IvCUaC%}clk7A-M$YLL%1i?7TkDyCn0fowdX|t-|qE`<&YOYk{hiQs%xijtY?ziuHecl(+dF0&44Wc delta 64 zcmeydLVV8>@eLW0C;VZ#?6uv)oz + 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}) + + +
+ {#if h && showRoom} + + Posted in # + + {/if} +
+ {#each uniq(topics) as topic (topic)} + + {/each} +
+ + + {#if showActivity} + + {/if} + +
diff --git a/src/app/components/ArticleCreate.svelte b/src/app/components/ArticleCreate.svelte new file mode 100644 index 00000000..0d1ab32c --- /dev/null +++ b/src/app/components/ArticleCreate.svelte @@ -0,0 +1,215 @@ + + + + + + Write an Article + Publish a long-form post to this space. + +
+ + {#snippet label()} +

Title*

+ {/snippet} + {#snippet input()} + + {/snippet} +
+ + {#snippet label()} +

Summary

+ {/snippet} + {#snippet input()} + + {/snippet} +
+ + {#snippet label()} +

Topics

+ {/snippet} + {#snippet input()} + + {/snippet} +
+ + {#snippet label()} +

Content*

+ {/snippet} + {#snippet input()} +
+ +
+ {/snippet} +
+ +
+
+ + + + +
diff --git a/src/app/components/ArticleItem.svelte b/src/app/components/ArticleItem.svelte new file mode 100644 index 00000000..53f4f9ad --- /dev/null +++ b/src/app/components/ArticleItem.svelte @@ -0,0 +1,57 @@ + + + + {#if image} + + {/if} +
+

{title || "Untitled"}

+

+ {formatTimestamp(article.publishedAt())} +

+
+ +
+ + Written by + + {#if h} + in + {/if} + + +
+ diff --git a/src/app/components/ArticleSidebar.svelte b/src/app/components/ArticleSidebar.svelte new file mode 100644 index 00000000..304336dc --- /dev/null +++ b/src/app/components/ArticleSidebar.svelte @@ -0,0 +1,80 @@ + + + diff --git a/src/app/components/CommentActions.svelte b/src/app/components/CommentActions.svelte index 4c379480..b675888f 100644 --- a/src/app/components/CommentActions.svelte +++ b/src/app/components/CommentActions.svelte @@ -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 @@
- {#if showActivity} + {#if showActivity && path} {/if} diff --git a/src/app/components/CommentCompose.svelte b/src/app/components/CommentCompose.svelte new file mode 100644 index 00000000..63f1c200 --- /dev/null +++ b/src/app/components/CommentCompose.svelte @@ -0,0 +1,121 @@ + + +
+
+
+ +
+ +
+
+ + +
+
diff --git a/src/app/components/CommentTree.svelte b/src/app/components/CommentTree.svelte new file mode 100644 index 00000000..427b6e5a --- /dev/null +++ b/src/app/components/CommentTree.svelte @@ -0,0 +1,62 @@ + + +
+ +
+ + {#if url} +
+ + +
+ {/if} +
+
+ {#if composing && url} +
+ +
+ {/if} + {#if node.children.length > 0} +
+ {#each node.children as child (child.comment.id)} + + {/each} +
+ {/if} +
diff --git a/src/app/components/ComposeMenu.svelte b/src/app/components/ComposeMenu.svelte index cc55fada..0fba2249 100644 --- a/src/app/components/ComposeMenu.svelte +++ b/src/app/components/ComposeMenu.svelte @@ -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 +
  • + +
  • +
  • + {/if} +{/if} diff --git a/src/app/components/NoteContent.svelte b/src/app/components/NoteContent.svelte index 406e40b8..64cd8108 100644 --- a/src/app/components/NoteContent.svelte +++ b/src/app/components/NoteContent.svelte @@ -1,9 +1,19 @@ + +
    + {#if image} + + {/if} + {#if title} +

    {title}

    + {/if} + {#if props.event.content} + + {/if} +
    diff --git a/src/app/components/NoteContentMinimal.svelte b/src/app/components/NoteContentMinimal.svelte index d715d4df..a9c18eb5 100644 --- a/src/app/components/NoteContentMinimal.svelte +++ b/src/app/components/NoteContentMinimal.svelte @@ -1,9 +1,19 @@ + +{#if title} + {title} +{/if} +{#if props.event.content} + +{/if} diff --git a/src/app/components/RecentItem.svelte b/src/app/components/RecentItem.svelte index cc19e22c..5ec1e9af 100644 --- a/src/app/components/RecentItem.svelte +++ b/src/app/components/RecentItem.svelte @@ -1,8 +1,9 @@ + + + {#snippet leading()} + + {/snippet} + {#snippet title()} + Articles + {/snippet} + {#snippet action()} + + {/snippet} + + + +
    + {#each filtered as event (getAddress(event))} +
    + +
    + {/each} +

    + + {#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} + +

    +
    + +
    diff --git a/src/routes/spaces/[relay]/articles/[address]/+page.svelte b/src/routes/spaces/[relay]/articles/[address]/+page.svelte new file mode 100644 index 00000000..c78886cf --- /dev/null +++ b/src/routes/spaces/[relay]/articles/[address]/+page.svelte @@ -0,0 +1,62 @@ + + + + {#snippet title()} +

    {$article?.title() ?? ""}

    + {/snippet} +
    + + + {#if $event && $article} + +
    + {#if $article.image()} + + {/if} +
    +

    {$article.title() ?? "Untitled"}

    +

    + Published {formatTimestamp($article.publishedAt())} +

    +
    + {#if $article.summary()} +

    {$article.summary()}

    + {/if} + + +
    +
    + + {:else} +
    + {#await sleep(5000)} + Loading article... + {:then} +

    Failed to load article.

    + {/await} +
    + {/if} +
    diff --git a/src/routes/spaces/[relay]/calendar/[address]/+page.svelte b/src/routes/spaces/[relay]/calendar/[address]/+page.svelte index 50a47ff5..3d5c288d 100644 --- a/src/routes/spaces/[relay]/calendar/[address]/+page.svelte +++ b/src/routes/spaces/[relay]/calendar/[address]/+page.svelte @@ -1,10 +1,9 @@ diff --git a/src/routes/spaces/[relay]/classifieds/[address]/+page.svelte b/src/routes/spaces/[relay]/classifieds/[address]/+page.svelte index 049bed8b..1048d2a2 100644 --- a/src/routes/spaces/[relay]/classifieds/[address]/+page.svelte +++ b/src/routes/spaces/[relay]/classifieds/[address]/+page.svelte @@ -1,10 +1,9 @@ diff --git a/src/routes/spaces/[relay]/goals/[id]/+page.svelte b/src/routes/spaces/[relay]/goals/[id]/+page.svelte index 8ce2c71e..7ae10b5f 100644 --- a/src/routes/spaces/[relay]/goals/[id]/+page.svelte +++ b/src/routes/spaces/[relay]/goals/[id]/+page.svelte @@ -1,10 +1,9 @@ diff --git a/src/routes/spaces/[relay]/polls/[id]/+page.svelte b/src/routes/spaces/[relay]/polls/[id]/+page.svelte index fa593438..4fda5fab 100644 --- a/src/routes/spaces/[relay]/polls/[id]/+page.svelte +++ b/src/routes/spaces/[relay]/polls/[id]/+page.svelte @@ -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 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() + } + }) diff --git a/src/routes/spaces/[relay]/threads/[id]/+page.svelte b/src/routes/spaces/[relay]/threads/[id]/+page.svelte index 5628b1ff..05c10621 100644 --- a/src/routes/spaces/[relay]/threads/[id]/+page.svelte +++ b/src/routes/spaces/[relay]/threads/[id]/+page.svelte @@ -1,5 +1,4 @@