This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

web/components: add comment and thread components

author
oppiliappan
committer
dawn
date (Jul 30, 2026, 7:45 PM +0300) commit 44e507e8 parent 5100b98f change-id xurzomuw
+1045
+37
web/src/lib/components/comment/Comment.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import Comment from "./Comment.svelte"; 4 + import { sampleComments } from "./sampleComments"; 5 + 6 + const { byjp, samuel } = sampleComments; 7 + 8 + const { Story } = defineMeta({ 9 + title: "Comment/Comment", 10 + component: Comment, 11 + tags: ["autodocs"], 12 + argTypes: { 13 + variant: { control: "inline-radio", options: ["top", "reply"] } 14 + }, 15 + args: { 16 + authorHandle: byjp.authorHandle, 17 + authorDid: byjp.authorDid, 18 + createdAt: byjp.createdAt, 19 + body: byjp.body, 20 + bodyHtml: byjp.bodyHtml, 21 + variant: "top" 22 + } 23 + }); 24 + </script> 25 + 26 + <Story name="Top level" /> 27 + <Story 28 + name="Reply" 29 + args={{ 30 + variant: "reply", 31 + authorHandle: samuel.authorHandle, 32 + authorDid: samuel.authorDid, 33 + createdAt: samuel.createdAt, 34 + body: samuel.body, 35 + bodyHtml: samuel.bodyHtml 36 + }} 37 + />
+51
web/src/lib/components/comment/Comment.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from "svelte"; 3 + import Avatar from "$lib/components/ui/Avatar.svelte"; 4 + import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; 5 + 6 + interface Props { 7 + authorHandle: string; 8 + authorDid: string; 9 + createdAt: string; 10 + body: string; 11 + bodyHtml: string | null; 12 + variant?: "top" | "reply"; 13 + // when provided, replaces the body region (e.g. an inline edit form), leaving 14 + // the avatar/handle header untouched so it doesn't shift 15 + editor?: Snippet; 16 + } 17 + 18 + let { 19 + authorHandle, 20 + authorDid, 21 + createdAt, 22 + body, 23 + bodyHtml, 24 + variant = "top", 25 + editor 26 + }: Props = $props(); 27 + </script> 28 + 29 + <div 30 + class={`flex gap-2 ${variant === "top" ? "border-b border-border-default bg-background-default px-6 py-4" : "py-4 pr-4"}`} 31 + > 32 + <div class="shrink-0"> 33 + <Avatar did={authorDid} handle={authorHandle} size="size-8" tiny /> 34 + </div> 35 + <div class="min-w-0 flex-1"> 36 + <div class="flex flex-wrap items-center gap-x-1 gap-y-1 text-sm text-foreground-subtle"> 37 + <span class="text-foreground-default">{authorHandle}</span> 38 + <span class="before:mr-1 before:content-['·']"> 39 + <TimeAgo value={createdAt} /> 40 + </span> 41 + </div> 42 + {#if editor} 43 + <div class="mt-1">{@render editor()}</div> 44 + {:else if bodyHtml} 45 + <!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in $lib/markup --> 46 + <article class="markup mt-1">{@html bodyHtml}</article> 47 + {:else if body} 48 + <article class="mt-1 whitespace-pre-wrap text-foreground-default">{body}</article> 49 + {/if} 50 + </div> 51 + </div>
+42
web/src/lib/components/comment/CommentBox.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import { expect, userEvent, waitFor, within } from "storybook/test"; 4 + import CommentBox from "./CommentBox.svelte"; 5 + import MockAuthProvider from "$lib/components/testing/MockAuthProvider.svelte"; 6 + 7 + const { Story } = defineMeta({ 8 + title: "Comment/CommentBox", 9 + component: CommentBox, 10 + tags: ["autodocs"], 11 + args: { 12 + subjectUri: "at://did:plc:alice/sh.tangled.repo.issue/aaa", 13 + subjectCid: "bafyreib2rxk3rh6kzwq6yhkp6bd4vjxj3xj2xj2xj2xj2xj2xj2xj2xj2", 14 + authorDid: "did:plc:alice", 15 + authorHandle: "alice.pds.tngl.boltless.dev", 16 + markup: { repo: "tangled.org/core", ref: "main", host: "tangled.org" } 17 + } 18 + }); 19 + </script> 20 + 21 + <Story name="Default" /> 22 + 23 + <!-- types a comment and submits with ctrl+enter against a fake agent that rejects, showing the error alert --> 24 + <Story 25 + name="Failed submission" 26 + play={async ({ canvasElement }) => { 27 + const canvas = within(canvasElement); 28 + await userEvent.type( 29 + canvas.getByPlaceholderText(/add to the discussion/i), 30 + "This will fail{Control>}{Enter}{/Control}" 31 + ); 32 + await waitFor(() => 33 + expect(canvas.getByRole("alert")).toHaveTextContent(/network request failed/i) 34 + ); 35 + }} 36 + > 37 + {#snippet template(args)} 38 + <MockAuthProvider> 39 + <CommentBox {...args} /> 40 + </MockAuthProvider> 41 + {/snippet} 42 + </Story>
+83
web/src/lib/components/comment/CommentBox.svelte
··· 1 + <script lang="ts"> 2 + import MessageSquarePlus from "$icon/message-square-plus"; 3 + import Reply from "$icon/reply"; 4 + import Avatar from "$lib/components/ui/Avatar.svelte"; 5 + import User from "$lib/components/ui/User.svelte"; 6 + import { type MarkupContext } from "$lib/markup"; 7 + import CommentEditor from "./CommentEditor.svelte"; 8 + import type { ThreadInput } from "./comments"; 9 + 10 + interface Props { 11 + subjectUri: string; 12 + subjectCid?: string; 13 + replyToUri?: string; 14 + replyToCid?: string; 15 + authorDid: string; 16 + authorHandle: string; 17 + markup: MarkupContext; 18 + // "thread" tucks the box into a comment card, matching its canvas background 19 + variant?: "default" | "thread"; 20 + autofocus?: boolean; 21 + onsubmitted?: (submitted: ThreadInput) => void; 22 + oncancel?: () => void; 23 + } 24 + 25 + let { 26 + subjectUri, 27 + subjectCid, 28 + replyToUri, 29 + replyToCid, 30 + authorDid, 31 + authorHandle, 32 + markup, 33 + variant = "default", 34 + autofocus = false, 35 + onsubmitted, 36 + oncancel 37 + }: Props = $props(); 38 + 39 + const isThread = $derived(variant === "thread"); 40 + </script> 41 + 42 + {#snippet editor()} 43 + <CommentEditor 44 + {subjectUri} 45 + {subjectCid} 46 + {replyToUri} 47 + {replyToCid} 48 + {authorDid} 49 + {authorHandle} 50 + {markup} 51 + rows={isThread ? 4 : 6} 52 + placeholder={isThread 53 + ? "Write a reply. Markdown is supported." 54 + : "Add to the discussion. Markdown is supported."} 55 + submitLabel={isThread ? "Reply" : "Comment"} 56 + submitIcon={isThread ? Reply : MessageSquarePlus} 57 + {autofocus} 58 + {onsubmitted} 59 + {oncancel} 60 + /> 61 + {/snippet} 62 + 63 + {#if isThread} 64 + <!-- two-column, mirroring Comment: avatar left, handle + editor right --> 65 + <div class="flex gap-2 bg-background-default/50 px-6 py-3"> 66 + <div class="shrink-0"> 67 + <Avatar did={authorDid} handle={authorHandle} size="size-8" tiny /> 68 + </div> 69 + <div class="flex min-w-0 flex-1 flex-col gap-2"> 70 + <div class="flex flex-wrap items-center gap-x-1 gap-y-1 text-sm text-foreground-subtle"> 71 + <span class="text-foreground-default">{authorHandle}</span> 72 + </div> 73 + {@render editor()} 74 + </div> 75 + </div> 76 + {:else} 77 + <div class="rounded border border-border-default bg-background-default px-4 py-4 drop-shadow-xs"> 78 + <div class="pb-2 text-sm text-foreground-subtle"> 79 + <User handle={authorHandle} did={authorDid} /> 80 + </div> 81 + {@render editor()} 82 + </div> 83 + {/if}
+73
web/src/lib/components/comment/CommentCard.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import { expect, userEvent, waitFor, within } from "storybook/test"; 4 + import CommentCard from "./CommentCard.svelte"; 5 + import MockAuthProvider from "$lib/components/testing/MockAuthProvider.svelte"; 6 + import { 7 + sampleComments, 8 + sampleMarkup, 9 + sampleSubjectCid, 10 + sampleSubjectUri, 11 + sampleThread, 12 + soloThread 13 + } from "./sampleComments"; 14 + 15 + // a thread the logged-in user owns, so the edit affordance shows 16 + const myThread = { self: sampleComments.mine, replies: [] }; 17 + 18 + const { Story } = defineMeta({ 19 + title: "Comment/CommentCard", 20 + component: CommentCard, 21 + tags: ["autodocs"], 22 + args: { 23 + thread: soloThread, 24 + subjectUri: sampleSubjectUri, 25 + subjectCid: sampleSubjectCid, 26 + markup: sampleMarkup 27 + } 28 + }); 29 + </script> 30 + 31 + <!-- logged out: the reply affordance is a login prompt --> 32 + <Story name="Top level only" /> 33 + <Story name="With replies" args={{ thread: sampleThread }} /> 34 + 35 + <!-- logged in: clicking "Leave a reply" opens an inline reply box --> 36 + <Story 37 + name="Replying" 38 + play={async ({ canvasElement }) => { 39 + const canvas = within(canvasElement); 40 + await userEvent.click(canvas.getByRole("button", { name: "Leave a reply..." })); 41 + const textarea = await waitFor(() => canvas.getByPlaceholderText(/write a reply/i)); 42 + await expect(canvas.getByRole("button", { name: "Reply" })).toBeInTheDocument(); 43 + await expect(canvas.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); 44 + // the editor should be focused so the user can type immediately 45 + await waitFor(() => expect(textarea).toHaveFocus()); 46 + }} 47 + > 48 + {#snippet template(args)} 49 + <MockAuthProvider> 50 + <CommentCard {...args} /> 51 + </MockAuthProvider> 52 + {/snippet} 53 + </Story> 54 + 55 + <!-- logged in as the author: the edit icon opens an inline editor prefilled with the body --> 56 + <Story 57 + name="Editing" 58 + args={{ thread: myThread }} 59 + play={async ({ canvasElement }) => { 60 + const canvas = within(canvasElement); 61 + await userEvent.click(canvas.getByRole("button", { name: "Edit comment" })); 62 + const textarea = await waitFor(() => canvas.getByPlaceholderText(/edit your comment/i)); 63 + await expect(textarea).toHaveValue(sampleComments.mine.body); 64 + await expect(canvas.getByRole("button", { name: "Save" })).toBeInTheDocument(); 65 + await expect(canvas.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); 66 + }} 67 + > 68 + {#snippet template(args)} 69 + <MockAuthProvider> 70 + <CommentCard {...args} /> 71 + </MockAuthProvider> 72 + {/snippet} 73 + </Story>
+200
web/src/lib/components/comment/CommentCard.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from "$app/paths"; 3 + import Pencil from "$icon/pencil"; 4 + import Trash2 from "$icon/trash-2"; 5 + import { deleteComment } from "$lib/api/comment"; 6 + import { getAuth } from "$lib/auth.svelte"; 7 + import { type MarkupContext } from "$lib/markup"; 8 + import ErrorAlert from "$lib/components/ui/Error.svelte"; 9 + import Comment from "./Comment.svelte"; 10 + import CommentBox from "./CommentBox.svelte"; 11 + import CommentEditor from "./CommentEditor.svelte"; 12 + import type { CommentThread, CommentView, ThreadInput } from "./comments"; 13 + 14 + interface Props { 15 + thread: CommentThread; 16 + subjectUri: string; 17 + subjectCid?: string; 18 + markup: MarkupContext; 19 + onsubmitted?: (submitted: ThreadInput) => void; 20 + onedited?: (edited: ThreadInput) => void; 21 + ondeleted?: (uri: string) => void; 22 + } 23 + 24 + let { thread, subjectUri, subjectCid, markup, onsubmitted, onedited, ondeleted }: Props = 25 + $props(); 26 + 27 + const auth = getAuth(); 28 + const currentUser = $derived(auth?.currentUser ?? null); 29 + 30 + let replying = $state(false); 31 + let editingUri = $state<string | null>(null); 32 + let deletingUri = $state<string | null>(null); 33 + let deleteError = $state<string | null>(null); 34 + 35 + const handleDelete = async (comment: CommentView) => { 36 + const agent = auth?.agent; 37 + if (!agent || deletingUri) return; 38 + if (!confirm("Delete this comment? This cannot be undone.")) return; 39 + deletingUri = comment.uri; 40 + deleteError = null; 41 + try { 42 + await deleteComment(agent, comment.rkey); 43 + ondeleted?.(comment.uri); 44 + } catch (err) { 45 + deleteError = err instanceof Error ? err.message : "Failed to delete comment"; 46 + } finally { 47 + deletingUri = null; 48 + } 49 + }; 50 + </script> 51 + 52 + {#snippet commentActions(comment: CommentView)} 53 + {#if currentUser?.did === comment.authorDid} 54 + <div 55 + class="absolute top-4 right-4 flex items-center gap-2 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100" 56 + > 57 + <button 58 + type="button" 59 + aria-label="Edit comment" 60 + class="cursor-pointer text-foreground-subtle hover:text-foreground-default" 61 + onclick={() => (editingUri = comment.uri)} 62 + > 63 + <Pencil class="size-3" /> 64 + </button> 65 + <button 66 + type="button" 67 + aria-label="Delete comment" 68 + disabled={deletingUri === comment.uri} 69 + class="cursor-pointer text-foreground-danger hover:text-foreground-danger-strong disabled:opacity-50" 70 + onclick={() => handleDelete(comment)} 71 + > 72 + <Trash2 class="size-3" /> 73 + </button> 74 + </div> 75 + {/if} 76 + {/snippet} 77 + 78 + {#snippet commentEditor( 79 + comment: CommentView, 80 + replyUri: string | undefined, 81 + replyCid: string | undefined 82 + )} 83 + <CommentEditor 84 + {subjectUri} 85 + {subjectCid} 86 + replyToUri={replyUri} 87 + replyToCid={replyCid} 88 + authorDid={comment.authorDid} 89 + authorHandle={comment.authorHandle} 90 + rkey={comment.rkey} 91 + createdAt={comment.createdAt} 92 + body={comment.body} 93 + {markup} 94 + rows={4} 95 + placeholder="Edit your comment. Markdown is supported." 96 + submitLabel="Save" 97 + submitIcon={Pencil} 98 + autofocus 99 + onsubmitted={(edited) => { 100 + editingUri = null; 101 + onedited?.(edited); 102 + }} 103 + oncancel={() => (editingUri = null)} 104 + /> 105 + {/snippet} 106 + 107 + <div 108 + class="overflow-hidden rounded border border-border-default bg-background-canvas bg-background-default/50 drop-shadow-xs" 109 + > 110 + {#snippet selfEditor()} 111 + {@render commentEditor(thread.self, undefined, undefined)} 112 + {/snippet} 113 + <div class="group relative"> 114 + <Comment 115 + authorHandle={thread.self.authorHandle} 116 + authorDid={thread.self.authorDid} 117 + createdAt={thread.self.createdAt} 118 + body={thread.self.body} 119 + bodyHtml={thread.self.bodyHtml} 120 + variant="top" 121 + editor={editingUri === thread.self.uri ? selfEditor : undefined} 122 + /> 123 + {#if editingUri !== thread.self.uri} 124 + {@render commentActions(thread.self)} 125 + {/if} 126 + </div> 127 + 128 + {#if thread.replies.length} 129 + <div class="ml-10"> 130 + {#each thread.replies as reply, i (reply.uri)} 131 + {#snippet replyEditor()} 132 + {@render commentEditor(reply, thread.self.uri, thread.self.cid)} 133 + {/snippet} 134 + <div class="group relative isolate -ml-4"> 135 + <!-- thread connector; on the last reply it stops at the avatar's centre (h-8) --> 136 + <div 137 + class={`pointer-events-none absolute top-0 left-4 -z-10 w-0.5 bg-border-default ${i === thread.replies.length - 1 ? "h-8" : "bottom-0"}`} 138 + ></div> 139 + <Comment 140 + authorHandle={reply.authorHandle} 141 + authorDid={reply.authorDid} 142 + createdAt={reply.createdAt} 143 + body={reply.body} 144 + bodyHtml={reply.bodyHtml} 145 + variant="reply" 146 + editor={editingUri === reply.uri ? replyEditor : undefined} 147 + /> 148 + {#if editingUri !== reply.uri} 149 + {@render commentActions(reply)} 150 + {/if} 151 + </div> 152 + {/each} 153 + </div> 154 + {/if} 155 + 156 + {#if deleteError} 157 + <div class="border-t border-border-default px-6 py-2"> 158 + <ErrorAlert label={deleteError} /> 159 + </div> 160 + {/if} 161 + 162 + {#if replying && currentUser} 163 + <div class={thread.replies.length ? "border-t border-border-default" : ""}> 164 + <CommentBox 165 + variant="thread" 166 + {subjectUri} 167 + {subjectCid} 168 + replyToUri={thread.self.uri} 169 + replyToCid={thread.self.cid} 170 + authorDid={currentUser.did} 171 + authorHandle={currentUser.handle} 172 + {markup} 173 + autofocus 174 + onsubmitted={(submitted) => { 175 + replying = false; 176 + onsubmitted?.(submitted); 177 + }} 178 + oncancel={() => (replying = false)} 179 + /> 180 + </div> 181 + {:else} 182 + <div 183 + class={`flex items-center gap-2 bg-background-default/50 px-6 py-2 ${thread.replies.length ? "border-t border-border-default" : ""}`} 184 + > 185 + {#if currentUser} 186 + <button 187 + type="button" 188 + class="w-full cursor-text text-left text-foreground-subtle focus:outline-none" 189 + onclick={() => (replying = true)} 190 + > 191 + Leave a reply... 192 + </button> 193 + {:else} 194 + <span class="text-foreground-subtle"> 195 + <a href={resolve("/login")} class="underline">Login</a> to leave a reply 196 + </span> 197 + {/if} 198 + </div> 199 + {/if} 200 + </div>
+69
web/src/lib/components/comment/CommentEditor.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import { expect, userEvent, waitFor, within } from "storybook/test"; 4 + import MessageSquarePlus from "$icon/message-square-plus"; 5 + import Pencil from "$icon/pencil"; 6 + import CommentEditor from "./CommentEditor.svelte"; 7 + import MockAuthProvider from "$lib/components/testing/MockAuthProvider.svelte"; 8 + import { 9 + sampleComments, 10 + sampleMarkup, 11 + sampleSubjectCid, 12 + sampleSubjectUri 13 + } from "./sampleComments"; 14 + 15 + const { mine } = sampleComments; 16 + 17 + // edit-mode args reused by the Editing and Failed edit stories 18 + const editArgs = { 19 + rkey: mine.rkey, 20 + createdAt: mine.createdAt, 21 + body: mine.body, 22 + placeholder: "Edit your comment. Markdown is supported.", 23 + submitLabel: "Save", 24 + submitIcon: Pencil, 25 + oncancel: () => undefined 26 + }; 27 + 28 + const { Story } = defineMeta({ 29 + title: "Comment/CommentEditor", 30 + component: CommentEditor, 31 + tags: ["autodocs"], 32 + args: { 33 + subjectUri: sampleSubjectUri, 34 + subjectCid: sampleSubjectCid, 35 + authorDid: mine.authorDid, 36 + authorHandle: mine.authorHandle, 37 + markup: sampleMarkup, 38 + rows: 4, 39 + placeholder: "Add to the discussion. Markdown is supported.", 40 + submitLabel: "Comment", 41 + submitIcon: MessageSquarePlus 42 + } 43 + }); 44 + </script> 45 + 46 + <!-- composing a new comment: empty, "Comment" label, no cancel --> 47 + <Story name="Compose" /> 48 + 49 + <!-- editing an existing comment: prefilled body, "Save" label, cancel available --> 50 + <Story name="Editing" args={editArgs} /> 51 + 52 + <!-- editing then failing to save against a fake agent that rejects --> 53 + <Story 54 + name="Failed edit" 55 + args={editArgs} 56 + play={async ({ canvasElement }) => { 57 + const canvas = within(canvasElement); 58 + await userEvent.click(canvas.getByRole("button", { name: "Save" })); 59 + await waitFor(() => 60 + expect(canvas.getByRole("alert")).toHaveTextContent(/network request failed/i) 61 + ); 62 + }} 63 + > 64 + {#snippet template(args)} 65 + <MockAuthProvider> 66 + <CommentEditor {...args} /> 67 + </MockAuthProvider> 68 + {/snippet} 69 + </Story>
+160
web/src/lib/components/comment/CommentEditor.svelte
··· 1 + <script lang="ts"> 2 + import { untrack, type Component } from "svelte"; 3 + import type { SvelteHTMLElements } from "svelte/elements"; 4 + import { now as tidNow } from "@atcute/tid"; 5 + import X from "$icon/x"; 6 + import { putComment } from "$lib/api/comment"; 7 + import { getAuth } from "$lib/auth.svelte"; 8 + import Button from "$lib/components/ui/Button.svelte"; 9 + import ErrorAlert from "$lib/components/ui/Error.svelte"; 10 + import MarkdownEditor from "$lib/components/ui/MarkdownEditor.svelte"; 11 + import Spinner from "$lib/components/ui/Spinner.svelte"; 12 + import { renderMarkup, type MarkupContext } from "$lib/markup"; 13 + import type { CommentRecord } from "$lib/api/records"; 14 + import type { ThreadInput } from "./comments"; 15 + 16 + interface Props { 17 + subjectUri: string; 18 + subjectCid?: string; 19 + // when set, the comment strongRefs this parent comment (threaded reply) 20 + replyToUri?: string; 21 + replyToCid?: string; 22 + authorDid: string; 23 + authorHandle: string; 24 + // reused on edit so the record keeps its identity; omitted when composing 25 + rkey?: string; 26 + createdAt?: string; 27 + body?: string; 28 + markup: MarkupContext; 29 + rows?: number; 30 + placeholder?: string; 31 + previewClass?: string; 32 + submitLabel: string; 33 + submitIcon: Component<SvelteHTMLElements["svg"]>; 34 + autofocus?: boolean; 35 + onsubmitted?: (submitted: ThreadInput) => void; 36 + oncancel?: () => void; 37 + } 38 + 39 + let { 40 + subjectUri, 41 + subjectCid, 42 + replyToUri, 43 + replyToCid, 44 + authorDid, 45 + authorHandle, 46 + rkey, 47 + createdAt, 48 + body: initialBody = "", 49 + markup, 50 + rows = 6, 51 + placeholder, 52 + previewClass = "min-h-24", 53 + submitLabel, 54 + submitIcon, 55 + autofocus = false, 56 + onsubmitted, 57 + oncancel 58 + }: Props = $props(); 59 + 60 + const auth = getAuth(); 61 + 62 + let body = $state(untrack(() => initialBody)); 63 + let tab = $state<"write" | "preview">("write"); 64 + let isPublishing = $state(false); 65 + let error = $state<string | null>(null); 66 + 67 + const canSubmit = $derived(body.trim() !== "" && !isPublishing); 68 + 69 + const handleSubmit = async (e: SubmitEvent) => { 70 + e.preventDefault(); 71 + const agent = auth.agent; 72 + if (!agent || !canSubmit) return; 73 + if (!subjectCid) { 74 + error = "Cannot comment: the subject record is missing a cid."; 75 + return; 76 + } 77 + isPublishing = true; 78 + error = null; 79 + try { 80 + const createdAtValue = createdAt ?? new Date().toISOString(); 81 + const targetRkey = rkey ?? tidNow(); 82 + const record: CommentRecord = { 83 + $type: "sh.tangled.feed.comment", 84 + subject: { uri: subjectUri, cid: subjectCid } as CommentRecord["subject"], 85 + body: { $type: "sh.tangled.markup.markdown", text: body }, 86 + createdAt: createdAtValue 87 + }; 88 + if (replyToUri && replyToCid) { 89 + record.replyTo = { uri: replyToUri, cid: replyToCid } as CommentRecord["replyTo"]; 90 + } 91 + const saved = await putComment(agent, targetRkey, record); 92 + // render markdown client-side so the optimistic comment matches a real one 93 + const bodyHtml = await renderMarkup(body, markup).catch(() => null); 94 + onsubmitted?.({ 95 + comment: { 96 + uri: saved.uri, 97 + cid: saved.cid, 98 + rkey: targetRkey, 99 + authorDid, 100 + authorHandle, 101 + createdAt: createdAtValue, 102 + body, 103 + bodyHtml 104 + }, 105 + replyTo: replyToUri ?? null 106 + }); 107 + // keep the prefilled body when editing; reset when composing a fresh comment 108 + if (!rkey) { 109 + body = ""; 110 + tab = "write"; 111 + } 112 + } catch (err) { 113 + error = err instanceof Error ? err.message : "Failed to post comment"; 114 + } finally { 115 + isPublishing = false; 116 + } 117 + }; 118 + </script> 119 + 120 + <form onsubmit={handleSubmit} class="flex flex-col gap-2"> 121 + <MarkdownEditor 122 + name="body" 123 + {rows} 124 + {previewClass} 125 + {placeholder} 126 + {markup} 127 + {autofocus} 128 + transparent 129 + bind:value={body} 130 + bind:tab 131 + disabled={isPublishing} 132 + /> 133 + 134 + {#if error} 135 + <ErrorAlert label={error} /> 136 + {/if} 137 + 138 + <div class="flex items-center gap-2"> 139 + <Button 140 + type="submit" 141 + variant="primary" 142 + icon={isPublishing ? Spinner : submitIcon} 143 + disabled={!canSubmit} 144 + > 145 + {submitLabel} 146 + </Button> 147 + {#if oncancel} 148 + <Button 149 + type="button" 150 + variant="ghost" 151 + icon={X} 152 + disabled={isPublishing} 153 + onclick={oncancel} 154 + class="text-foreground-danger hover:text-foreground-danger" 155 + > 156 + Cancel 157 + </Button> 158 + {/if} 159 + </div> 160 + </form>
+52
web/src/lib/components/comment/CommentList.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import { expect, userEvent, waitFor, within } from "storybook/test"; 4 + import CommentList from "./CommentList.svelte"; 5 + import MockAuthProvider from "$lib/components/testing/MockAuthProvider.svelte"; 6 + import { 7 + longThread, 8 + multiReplyThread, 9 + sampleComments, 10 + sampleMarkup, 11 + sampleSubjectCid, 12 + sampleSubjectUri, 13 + sampleThreads 14 + } from "./sampleComments"; 15 + 16 + const { Story } = defineMeta({ 17 + title: "Comment/CommentList", 18 + component: CommentList, 19 + tags: ["autodocs"], 20 + args: { 21 + threads: sampleThreads, 22 + subjectUri: sampleSubjectUri, 23 + subjectCid: sampleSubjectCid, 24 + markup: sampleMarkup 25 + } 26 + }); 27 + </script> 28 + 29 + <Story name="Populated" /> 30 + <Story name="Empty" args={{ threads: [] }} /> 31 + <Story name="Multiple replies" args={{ threads: [multiReplyThread] }} /> 32 + 33 + <!-- logged in as alice: her reply in the middle of the thread is being edited inline --> 34 + <Story 35 + name="Editing a mid-thread reply" 36 + args={{ threads: [longThread] }} 37 + play={async ({ canvasElement }) => { 38 + const canvas = within(canvasElement); 39 + // only alice's (middle) reply exposes an edit control 40 + await userEvent.click(canvas.getByRole("button", { name: "Edit comment" })); 41 + const textarea = await waitFor(() => canvas.getByPlaceholderText(/edit your comment/i)); 42 + await expect(textarea).toHaveValue(sampleComments.mine.body); 43 + await expect(canvas.getByRole("button", { name: "Save" })).toBeInTheDocument(); 44 + await expect(canvas.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); 45 + }} 46 + > 47 + {#snippet template(args)} 48 + <MockAuthProvider> 49 + <CommentList {...args} /> 50 + </MockAuthProvider> 51 + {/snippet} 52 + </Story>
+34
web/src/lib/components/comment/CommentList.svelte
··· 1 + <script lang="ts"> 2 + import { type MarkupContext } from "$lib/markup"; 3 + import CommentCard from "./CommentCard.svelte"; 4 + import type { CommentThread, ThreadInput } from "./comments"; 5 + 6 + interface Props { 7 + threads: CommentThread[]; 8 + subjectUri: string; 9 + subjectCid?: string; 10 + markup: MarkupContext; 11 + onsubmitted?: (submitted: ThreadInput) => void; 12 + onedited?: (edited: ThreadInput) => void; 13 + ondeleted?: (uri: string) => void; 14 + } 15 + 16 + let { threads, subjectUri, subjectCid, markup, onsubmitted, onedited, ondeleted }: Props = 17 + $props(); 18 + </script> 19 + 20 + {#if threads.length} 21 + <div class="flex flex-col gap-4"> 22 + {#each threads as thread (thread.self.uri)} 23 + <CommentCard 24 + {thread} 25 + {subjectUri} 26 + {subjectCid} 27 + {markup} 28 + {onsubmitted} 29 + {onedited} 30 + {ondeleted} 31 + /> 32 + {/each} 33 + </div> 34 + {/if}
+60
web/src/lib/components/comment/comments.ts
··· 1 + // client-side comment threading, ported from NewCommentList in appview/models/comment.go. 2 + // bobbin returns comments flat; we group replies under the top-level comment their 3 + // replyTo strongRef points at, and sort everything oldest-first. 4 + 5 + export interface CommentView { 6 + uri: string; 7 + // content hash, needed to strongRef this comment when replying to it 8 + cid?: string; 9 + rkey: string; 10 + authorDid: string; 11 + authorHandle: string; 12 + createdAt: string; 13 + body: string; 14 + bodyHtml: string | null; 15 + } 16 + 17 + export interface CommentThread { 18 + self: CommentView; 19 + replies: CommentView[]; 20 + } 21 + 22 + // pairs a rendered view with the parent uri it replies to (null for top-level). 23 + export interface ThreadInput { 24 + comment: CommentView; 25 + replyTo: string | null; 26 + } 27 + 28 + const byCreatedAt = (a: CommentView, b: CommentView) => a.createdAt.localeCompare(b.createdAt); 29 + 30 + export function buildCommentThreads(inputs: ThreadInput[]): CommentThread[] { 31 + const threads = new Map<string, CommentThread>(); 32 + const orphanReplies: ThreadInput[] = []; 33 + 34 + for (const input of inputs) { 35 + if (input.replyTo === null) { 36 + threads.set(input.comment.uri, { self: input.comment, replies: [] }); 37 + } 38 + } 39 + 40 + for (const input of inputs) { 41 + if (input.replyTo === null) continue; 42 + const parent = threads.get(input.replyTo); 43 + if (parent) { 44 + parent.replies.push(input.comment); 45 + } else { 46 + // parent not found (e.g. legacy/cross-collection ref): surface as top-level 47 + // so nothing is dropped. 48 + orphanReplies.push(input); 49 + } 50 + } 51 + 52 + for (const orphan of orphanReplies) { 53 + threads.set(orphan.comment.uri, { self: orphan.comment, replies: [] }); 54 + } 55 + 56 + const list = [...threads.values()]; 57 + list.sort((a, b) => byCreatedAt(a.self, b.self)); 58 + for (const thread of list) thread.replies.sort(byCreatedAt); 59 + return list; 60 + }
+151
web/src/lib/components/comment/sampleComments.ts
··· 1 + // real comment data pulled from tangled.org/tangled.org/core/issues/245, used across 2 + // the comment stories so the fixtures live in one place. 3 + import type { MarkupContext } from "$lib/markup"; 4 + import type { CommentThread, CommentView } from "./comments"; 5 + 6 + export const sampleMarkup: MarkupContext = { 7 + repo: "tangled.org/core", 8 + ref: "main", 9 + host: "tangled.org" 10 + }; 11 + 12 + export const sampleSubjectUri = "at://did:plc:xan/sh.tangled.repo.issue/245"; 13 + export const sampleSubjectCid = "bafyreib2rxk3rh6kzwq6yhkp6bd4vjxj3xj2xj2xj2xj2xj2xj2xj2xj4"; 14 + 15 + const cid = "bafyreib2rxk3rh6kzwq6yhkp6bd4vjxj3xj2xj2xj2xj2xj2xj2xj2xj2"; 16 + 17 + const c = ( 18 + rkey: string, 19 + authorDid: string, 20 + authorHandle: string, 21 + createdAt: string, 22 + body: string, 23 + bodyHtml: string 24 + ): CommentView => ({ 25 + uri: `at://${authorDid}/sh.tangled.feed.comment/${rkey}`, 26 + cid, 27 + rkey, 28 + authorDid, 29 + authorHandle, 30 + createdAt, 31 + body, 32 + bodyHtml 33 + }); 34 + 35 + export const sampleComments = { 36 + byjp: c( 37 + "byjp", 38 + "did:plc:byjp", 39 + "byjp.me", 40 + "2024-10-02T09:15:00Z", 41 + `I'm on the cusp, but I reacted "👎", as: 42 + 43 + - I quite like the "@"! 44 + - it clearly differentiates between user stuff, and site stuff; \`tangled.org/settings\` vs. \`tangled.org/@settings.in\` 45 + - it feels quite "ATProto" to have an AT☺️ 46 + 47 + Plus I think I'd keep the @s in the UI anyway (eg. in the header of a repo page "[@tangled.org](/tangled.org)/core") — so then the URLs would match what's displayed.`, 48 + `<p>I'm on the cusp, but I reacted "👎", as:</p> 49 + <ul> 50 + <li>I quite like the "@"!</li> 51 + <li>it clearly differentiates between user stuff, and site stuff; <code>tangled.org/settings</code> vs. <code>tangled.org/@settings.in</code></li> 52 + <li>it feels quite "ATProto" to have an AT☺️</li> 53 + </ul> 54 + <p>Plus I think I'd keep the @s in the UI anyway (eg. in the header of a repo page "<a href="/tangled.org">@tangled.org</a>/core") — so then the URLs would match what's displayed.</p>` 55 + ), 56 + cam: c( 57 + "cam", 58 + "did:plc:cam", 59 + "camsmith.dev", 60 + "2024-10-02T11:00:00Z", 61 + `I also found these two reasons convincing enough to vote in favor of removal. 62 + 63 + One small, silly side effect of this will be that the address for tangled itself will be tangled.org/tangled.org`, 64 + `<p>I also found these two reasons convincing enough to vote in favor of removal.</p> 65 + <p>One small, silly side effect of this will be that the address for tangled itself will be tangled.org/tangled.org</p>` 66 + ), 67 + samuel: c( 68 + "samuel", 69 + "did:plc:samuel", 70 + "samuel.fm", 71 + "2024-10-03T08:20:00Z", 72 + "I vote removing them. You can tell if it's a handle by the presence of the `.`s", 73 + "<p>I vote removing them. You can tell if it's a handle by the presence of the <code>.</code>s</p>" 74 + ), 75 + moth: c( 76 + "moth", 77 + "did:plc:moth", 78 + "moth11.net", 79 + "2024-10-03T14:05:00Z", 80 + "i vote yes, i'm probably stupid but i dont think go modules like there being @s in the url so i just use github, i'm sure theres a way around it, but its best to minimize friction", 81 + "<p>i vote yes, i'm probably stupid but i dont think go modules like there being @s in the url so i just use github, i'm sure theres a way around it, but its best to minimize friction</p>" 82 + ), 83 + anirudh: c( 84 + "anirudh", 85 + "did:plc:anirudh", 86 + "anirudh.fi", 87 + "2024-10-04T10:00:00Z", 88 + "For what it's worth, we've always supported the non-@ URL as well. We just redirect it to the @'d version—which Go seems to be content with.", 89 + "<p>For what it's worth, we've always supported the non-@ URL as well. We just redirect it to the @'d version—which Go seems to be content with.</p>" 90 + ), 91 + knowtheory: c( 92 + "knowtheory", 93 + "did:plc:knowtheory", 94 + "knowtheory.net", 95 + "2024-10-05T16:30:00Z", 96 + `So i'm a 👍 I dig the poetry of having an @ in the URI, but mostly i think it's confusing. This isn't a practice the other Apps & AppServers use. It's also confusing because an AT:// URI is something distinct and different. 97 + 98 + And just personally, it's a URL eyesore for me.`, 99 + `<p>So i'm a 👍 I dig the poetry of having an @ in the URI, but mostly i think it's confusing. This isn't a practice the other Apps &amp; AppServers use. It's also confusing because an AT:// URI is something distinct and different.</p> 100 + <p>And just personally, it's a URL eyesore for me.</p>` 101 + ), 102 + anil: c( 103 + "anil", 104 + "did:plc:anil", 105 + "anil.recoil.org", 106 + "2024-11-10T12:00:00Z", 107 + "This has tripped me up quite a few times on the SSH URLs with extraneous @s, so thank you for making this change!", 108 + "<p>This has tripped me up quite a few times on the SSH URLs with extraneous @s, so thank you for making this change!</p>" 109 + ), 110 + // authored by the story's logged-in user (MockAuthProvider = did:plc:alice), for edit cases 111 + mine: c( 112 + "mine", 113 + "did:plc:alice", 114 + "alice.pds.tngl.boltless.dev", 115 + "2024-10-06T09:00:00Z", 116 + "I vote removing them. You can tell if it's a handle by the presence of the `.`s", 117 + "<p>I vote removing them. You can tell if it's a handle by the presence of the <code>.</code>s</p>" 118 + ) 119 + }; 120 + 121 + // a top-level comment on its own 122 + export const soloThread: CommentThread = { self: sampleComments.byjp, replies: [] }; 123 + 124 + // a thread with a couple of replies 125 + export const sampleThread: CommentThread = { 126 + self: sampleComments.byjp, 127 + replies: [sampleComments.cam, sampleComments.samuel] 128 + }; 129 + 130 + export const sampleThreads: CommentThread[] = [ 131 + sampleThread, 132 + { self: sampleComments.knowtheory, replies: [] } 133 + ]; 134 + 135 + // more than one reply, to show the connector line running down the replies 136 + export const multiReplyThread: CommentThread = { 137 + self: sampleComments.byjp, 138 + replies: [sampleComments.cam, sampleComments.samuel, sampleComments.moth] 139 + }; 140 + 141 + // a long thread whose middle reply belongs to the logged-in user (for edit-in-place) 142 + export const longThread: CommentThread = { 143 + self: sampleComments.byjp, 144 + replies: [ 145 + sampleComments.cam, 146 + sampleComments.moth, 147 + sampleComments.mine, 148 + sampleComments.anirudh, 149 + sampleComments.anil 150 + ] 151 + };
+14
web/src/lib/components/ui/SignupPrompt.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import SignupPrompt from "./SignupPrompt.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "UI/SignupPrompt", 7 + component: SignupPrompt, 8 + tags: ["autodocs"], 9 + args: { message: "to add to the discussion" } 10 + }); 11 + </script> 12 + 13 + <Story name="Default" /> 14 + <Story name="Custom message" args={{ message: "to open an issue" }} />
+19
web/src/lib/components/ui/SignupPrompt.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from "$app/paths"; 3 + import Button from "./Button.svelte"; 4 + 5 + interface Props { 6 + message?: string; 7 + } 8 + 9 + let { message = "to add to the discussion" }: Props = $props(); 10 + </script> 11 + 12 + <div 13 + class="flex flex-wrap items-center gap-2 rounded border border-foreground-warning bg-background-warning-subtle px-4 py-4 text-sm text-foreground-warning-strong drop-shadow-xs" 14 + > 15 + <Button variant="primary" href={resolve("/signup")}>Sign up</Button> 16 + <span>or</span> 17 + <a href={resolve("/login")} class="text-foreground-warning-strong underline">Login</a> 18 + <span>{message}</span> 19 + </div>