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