diff --git a/web/src/lib/components/comment/Comment.stories.svelte b/web/src/lib/components/comment/Comment.stories.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/comment/Comment.stories.svelte @@ -0,0 +1,37 @@ + + + + diff --git a/web/src/lib/components/comment/Comment.svelte b/web/src/lib/components/comment/Comment.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/comment/Comment.svelte @@ -0,0 +1,51 @@ + + +
+
+ +
+
+
+ {authorHandle} + + + +
+ {#if editor} +
{@render editor()}
+ {:else if bodyHtml} + +
{@html bodyHtml}
+ {:else if body} +
{body}
+ {/if} +
+
diff --git a/web/src/lib/components/comment/CommentBox.stories.svelte b/web/src/lib/components/comment/CommentBox.stories.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/comment/CommentBox.stories.svelte @@ -0,0 +1,42 @@ + + + + + + { + const canvas = within(canvasElement); + await userEvent.type( + canvas.getByPlaceholderText(/add to the discussion/i), + "This will fail{Control>}{Enter}{/Control}" + ); + await waitFor(() => + expect(canvas.getByRole("alert")).toHaveTextContent(/network request failed/i) + ); + }} +> + {#snippet template(args)} + + + + {/snippet} + diff --git a/web/src/lib/components/comment/CommentBox.svelte b/web/src/lib/components/comment/CommentBox.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/comment/CommentBox.svelte @@ -0,0 +1,83 @@ + + +{#snippet editor()} + +{/snippet} + +{#if isThread} + +
+
+ +
+
+
+ {authorHandle} +
+ {@render editor()} +
+
+{:else} +
+
+ +
+ {@render editor()} +
+{/if} diff --git a/web/src/lib/components/comment/CommentCard.stories.svelte b/web/src/lib/components/comment/CommentCard.stories.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/comment/CommentCard.stories.svelte @@ -0,0 +1,73 @@ + + + + + + + + { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Leave a reply..." })); + const textarea = await waitFor(() => canvas.getByPlaceholderText(/write a reply/i)); + await expect(canvas.getByRole("button", { name: "Reply" })).toBeInTheDocument(); + await expect(canvas.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); + // the editor should be focused so the user can type immediately + await waitFor(() => expect(textarea).toHaveFocus()); + }} +> + {#snippet template(args)} + + + + {/snippet} + + + + { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Edit comment" })); + const textarea = await waitFor(() => canvas.getByPlaceholderText(/edit your comment/i)); + await expect(textarea).toHaveValue(sampleComments.mine.body); + await expect(canvas.getByRole("button", { name: "Save" })).toBeInTheDocument(); + await expect(canvas.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); + }} +> + {#snippet template(args)} + + + + {/snippet} + diff --git a/web/src/lib/components/comment/CommentCard.svelte b/web/src/lib/components/comment/CommentCard.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/comment/CommentCard.svelte @@ -0,0 +1,200 @@ + + +{#snippet commentActions(comment: CommentView)} + {#if currentUser?.did === comment.authorDid} +
+ + +
+ {/if} +{/snippet} + +{#snippet commentEditor( + comment: CommentView, + replyUri: string | undefined, + replyCid: string | undefined +)} + { + editingUri = null; + onedited?.(edited); + }} + oncancel={() => (editingUri = null)} + /> +{/snippet} + +
+ {#snippet selfEditor()} + {@render commentEditor(thread.self, undefined, undefined)} + {/snippet} +
+ + {#if editingUri !== thread.self.uri} + {@render commentActions(thread.self)} + {/if} +
+ + {#if thread.replies.length} +
+ {#each thread.replies as reply, i (reply.uri)} + {#snippet replyEditor()} + {@render commentEditor(reply, thread.self.uri, thread.self.cid)} + {/snippet} +
+ +
+ + {#if editingUri !== reply.uri} + {@render commentActions(reply)} + {/if} +
+ {/each} +
+ {/if} + + {#if deleteError} +
+ +
+ {/if} + + {#if replying && currentUser} +
+ { + replying = false; + onsubmitted?.(submitted); + }} + oncancel={() => (replying = false)} + /> +
+ {:else} +
+ {#if currentUser} + + {:else} + + Login to leave a reply + + {/if} +
+ {/if} +
diff --git a/web/src/lib/components/comment/CommentEditor.stories.svelte b/web/src/lib/components/comment/CommentEditor.stories.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/comment/CommentEditor.stories.svelte @@ -0,0 +1,69 @@ + + + + + + + + + + { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Save" })); + await waitFor(() => + expect(canvas.getByRole("alert")).toHaveTextContent(/network request failed/i) + ); + }} +> + {#snippet template(args)} + + + + {/snippet} + diff --git a/web/src/lib/components/comment/CommentEditor.svelte b/web/src/lib/components/comment/CommentEditor.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/comment/CommentEditor.svelte @@ -0,0 +1,160 @@ + + +
+ + + {#if error} + + {/if} + +
+ + {#if oncancel} + + {/if} +
+ diff --git a/web/src/lib/components/comment/CommentList.stories.svelte b/web/src/lib/components/comment/CommentList.stories.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/comment/CommentList.stories.svelte @@ -0,0 +1,52 @@ + + + + + + + + { + const canvas = within(canvasElement); + // only alice's (middle) reply exposes an edit control + await userEvent.click(canvas.getByRole("button", { name: "Edit comment" })); + const textarea = await waitFor(() => canvas.getByPlaceholderText(/edit your comment/i)); + await expect(textarea).toHaveValue(sampleComments.mine.body); + await expect(canvas.getByRole("button", { name: "Save" })).toBeInTheDocument(); + await expect(canvas.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); + }} +> + {#snippet template(args)} + + + + {/snippet} + diff --git a/web/src/lib/components/comment/CommentList.svelte b/web/src/lib/components/comment/CommentList.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/comment/CommentList.svelte @@ -0,0 +1,34 @@ + + +{#if threads.length} +
+ {#each threads as thread (thread.self.uri)} + + {/each} +
+{/if} diff --git a/web/src/lib/components/comment/comments.ts b/web/src/lib/components/comment/comments.ts new file mode 100644 --- /dev/null +++ b/web/src/lib/components/comment/comments.ts @@ -0,0 +1,60 @@ +// client-side comment threading, ported from NewCommentList in appview/models/comment.go. +// bobbin returns comments flat; we group replies under the top-level comment their +// replyTo strongRef points at, and sort everything oldest-first. + +export interface CommentView { + uri: string; + // content hash, needed to strongRef this comment when replying to it + cid?: string; + rkey: string; + authorDid: string; + authorHandle: string; + createdAt: string; + body: string; + bodyHtml: string | null; +} + +export interface CommentThread { + self: CommentView; + replies: CommentView[]; +} + +// pairs a rendered view with the parent uri it replies to (null for top-level). +export interface ThreadInput { + comment: CommentView; + replyTo: string | null; +} + +const byCreatedAt = (a: CommentView, b: CommentView) => a.createdAt.localeCompare(b.createdAt); + +export function buildCommentThreads(inputs: ThreadInput[]): CommentThread[] { + const threads = new Map(); + const orphanReplies: ThreadInput[] = []; + + for (const input of inputs) { + if (input.replyTo === null) { + threads.set(input.comment.uri, { self: input.comment, replies: [] }); + } + } + + for (const input of inputs) { + if (input.replyTo === null) continue; + const parent = threads.get(input.replyTo); + if (parent) { + parent.replies.push(input.comment); + } else { + // parent not found (e.g. legacy/cross-collection ref): surface as top-level + // so nothing is dropped. + orphanReplies.push(input); + } + } + + for (const orphan of orphanReplies) { + threads.set(orphan.comment.uri, { self: orphan.comment, replies: [] }); + } + + const list = [...threads.values()]; + list.sort((a, b) => byCreatedAt(a.self, b.self)); + for (const thread of list) thread.replies.sort(byCreatedAt); + return list; +} diff --git a/web/src/lib/components/comment/sampleComments.ts b/web/src/lib/components/comment/sampleComments.ts new file mode 100644 --- /dev/null +++ b/web/src/lib/components/comment/sampleComments.ts @@ -0,0 +1,151 @@ +// real comment data pulled from tangled.org/tangled.org/core/issues/245, used across +// the comment stories so the fixtures live in one place. +import type { MarkupContext } from "$lib/markup"; +import type { CommentThread, CommentView } from "./comments"; + +export const sampleMarkup: MarkupContext = { + repo: "tangled.org/core", + ref: "main", + host: "tangled.org" +}; + +export const sampleSubjectUri = "at://did:plc:xan/sh.tangled.repo.issue/245"; +export const sampleSubjectCid = "bafyreib2rxk3rh6kzwq6yhkp6bd4vjxj3xj2xj2xj2xj2xj2xj2xj2xj4"; + +const cid = "bafyreib2rxk3rh6kzwq6yhkp6bd4vjxj3xj2xj2xj2xj2xj2xj2xj2xj2"; + +const c = ( + rkey: string, + authorDid: string, + authorHandle: string, + createdAt: string, + body: string, + bodyHtml: string +): CommentView => ({ + uri: `at://${authorDid}/sh.tangled.feed.comment/${rkey}`, + cid, + rkey, + authorDid, + authorHandle, + createdAt, + body, + bodyHtml +}); + +export const sampleComments = { + byjp: c( + "byjp", + "did:plc:byjp", + "byjp.me", + "2024-10-02T09:15:00Z", + `I'm on the cusp, but I reacted "👎", as: + +- I quite like the "@"! +- it clearly differentiates between user stuff, and site stuff; \`tangled.org/settings\` vs. \`tangled.org/@settings.in\` +- it feels quite "ATProto" to have an AT☺️ + +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.`, + `

I'm on the cusp, but I reacted "👎", as:

+
    +
  • I quite like the "@"!
  • +
  • it clearly differentiates between user stuff, and site stuff; tangled.org/settings vs. tangled.org/@settings.in
  • +
  • it feels quite "ATProto" to have an AT☺️
  • +
+

Plus I think I'd keep the @s in the UI anyway (eg. in the header of a repo page "@tangled.org/core") — so then the URLs would match what's displayed.

` + ), + cam: c( + "cam", + "did:plc:cam", + "camsmith.dev", + "2024-10-02T11:00:00Z", + `I also found these two reasons convincing enough to vote in favor of removal. + +One small, silly side effect of this will be that the address for tangled itself will be tangled.org/tangled.org`, + `

I also found these two reasons convincing enough to vote in favor of removal.

+

One small, silly side effect of this will be that the address for tangled itself will be tangled.org/tangled.org

` + ), + samuel: c( + "samuel", + "did:plc:samuel", + "samuel.fm", + "2024-10-03T08:20:00Z", + "I vote removing them. You can tell if it's a handle by the presence of the `.`s", + "

I vote removing them. You can tell if it's a handle by the presence of the .s

" + ), + moth: c( + "moth", + "did:plc:moth", + "moth11.net", + "2024-10-03T14:05:00Z", + "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", + "

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

" + ), + anirudh: c( + "anirudh", + "did:plc:anirudh", + "anirudh.fi", + "2024-10-04T10:00:00Z", + "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.", + "

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.

" + ), + knowtheory: c( + "knowtheory", + "did:plc:knowtheory", + "knowtheory.net", + "2024-10-05T16:30:00Z", + `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. + +And just personally, it's a URL eyesore for me.`, + `

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.

+

And just personally, it's a URL eyesore for me.

` + ), + anil: c( + "anil", + "did:plc:anil", + "anil.recoil.org", + "2024-11-10T12:00:00Z", + "This has tripped me up quite a few times on the SSH URLs with extraneous @s, so thank you for making this change!", + "

This has tripped me up quite a few times on the SSH URLs with extraneous @s, so thank you for making this change!

" + ), + // authored by the story's logged-in user (MockAuthProvider = did:plc:alice), for edit cases + mine: c( + "mine", + "did:plc:alice", + "alice.pds.tngl.boltless.dev", + "2024-10-06T09:00:00Z", + "I vote removing them. You can tell if it's a handle by the presence of the `.`s", + "

I vote removing them. You can tell if it's a handle by the presence of the .s

" + ) +}; + +// a top-level comment on its own +export const soloThread: CommentThread = { self: sampleComments.byjp, replies: [] }; + +// a thread with a couple of replies +export const sampleThread: CommentThread = { + self: sampleComments.byjp, + replies: [sampleComments.cam, sampleComments.samuel] +}; + +export const sampleThreads: CommentThread[] = [ + sampleThread, + { self: sampleComments.knowtheory, replies: [] } +]; + +// more than one reply, to show the connector line running down the replies +export const multiReplyThread: CommentThread = { + self: sampleComments.byjp, + replies: [sampleComments.cam, sampleComments.samuel, sampleComments.moth] +}; + +// a long thread whose middle reply belongs to the logged-in user (for edit-in-place) +export const longThread: CommentThread = { + self: sampleComments.byjp, + replies: [ + sampleComments.cam, + sampleComments.moth, + sampleComments.mine, + sampleComments.anirudh, + sampleComments.anil + ] +}; diff --git a/web/src/lib/components/ui/SignupPrompt.stories.svelte b/web/src/lib/components/ui/SignupPrompt.stories.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/ui/SignupPrompt.stories.svelte @@ -0,0 +1,14 @@ + + + + diff --git a/web/src/lib/components/ui/SignupPrompt.svelte b/web/src/lib/components/ui/SignupPrompt.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/ui/SignupPrompt.svelte @@ -0,0 +1,19 @@ + + +
+ + or + Login + {message} +