From dd922e29d61a3d306d19a44d33fcfc90faf33e3e Mon Sep 17 00:00:00 2001 From: Bretton Date: Fri, 3 Jul 2026 21:12:50 -0700 Subject: [PATCH] feat(comments): add comment permalink route and continue-thread navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add /c/[handle=handle]/post/[rkey]/comment/[crkey] — a permalink page rendering the thread re-rooted at one comment, with a single-thread banner, "View all comments" (returns via #comment- anchor), and "Show context" (hops to the parent comment's permalink). Changes: - fetchChildren fetches only the parent's subtree via getComments parentRkey, following response.cursor up to 4 pages so wide threads load fully; depth is derived from MAX_INLINE_DEPTH (subtreeFetchDepth) so deeper replies render a "Continue this thread" permalink anchor - #comment- deep links mount the target's top-level row in the virtualized list (scrollToComment/scrollToRow), poll against a 2s deadline, then scroll + highlight; unresolvable targets surface a commentNotFound toast instead of no-oping - postLink/commentLink/communityLink fall back to the community DID when the handle is missing — bare names aren't [handle=handle] matcher-valid and would 404; PostLinkRef centralizes the link shape - communityIdentifier now returns handle-or-DID for URLs; new communityHandleOrName keeps display copy (e.g. !handle) readable - permalink loader 404s with couldnt_find_community on unknown slugs and derives SUBTREE_DEPTH from MAX_INLINE_DEPTH - route hygiene: [handle=handle] matcher on /c/ and /u/, i18n keys for hardcoded strings, matcher-valid test fixtures, URL-level test for getComments parentRkey omission Co-Authored-By: Claude Fable 5 --- src/lib/api/coves/client.test.ts | 42 +++ src/lib/api/coves/types.ts | 8 + src/lib/app/i18n/en.json | 14 +- src/lib/app/util.svelte.test.ts | 24 +- src/lib/app/util.svelte.ts | 11 +- src/lib/feature/comment/Comment.svelte | 9 +- .../comment/CommentListVirtualizer.svelte | 23 +- .../feature/comment/CommentProvider.svelte | 29 +- src/lib/feature/comment/CommentTree.svelte | 147 +++++--- src/lib/feature/comment/comments.svelte.ts | 49 +++ src/lib/feature/comment/comments.test.ts | 161 ++++++++- .../feature/community/CommunityCard.svelte | 8 +- .../feature/community/CommunityForm.svelte | 14 +- .../feature/community/CommunityHeader.svelte | 12 +- .../feature/community/CommunityItem.svelte | 8 +- .../feature/community/CommunityItemBig.svelte | 8 +- src/lib/feature/community/helpers.ts | 16 +- src/lib/feature/feeds/feed.svelte.ts | 20 +- src/lib/feature/post/helpers.test.ts | 112 +++++- src/lib/feature/post/helpers.ts | 61 +++- .../+page.svelte | 0 .../c/{[handle] => [handle=handle]}/+page.ts | 0 .../[handle=handle]/post/[rkey]/+page.svelte | 271 ++++++++++++++ .../post/[rkey]/+page.ts | 19 +- .../post/[rkey]/comment/[crkey]/+page.svelte | 124 +++++++ .../post/[rkey]/comment/[crkey]/+page.ts | 127 +++++++ .../post/[rkey]/comment/[crkey]/page.test.ts | 340 ++++++++++++++++++ .../post/[rkey]/page.test.ts | 4 +- .../settings/+layout.svelte | 0 .../settings/+layout.ts | 0 .../settings/+page.svelte | 0 .../settings/team/+page.svelte | 0 .../c/[handle]/post/[rkey]/+page.svelte | 135 ------- src/routes/create/post/+page.svelte | 4 +- src/routes/profile/[handle=handle]/+page.ts | 2 +- .../u/{[handle] => [handle=handle]}/+page.ts | 0 .../page.test.ts | 34 +- 37 files changed, 1575 insertions(+), 261 deletions(-) rename src/routes/c/{[handle] => [handle=handle]}/+page.svelte (100%) rename src/routes/c/{[handle] => [handle=handle]}/+page.ts (100%) create mode 100644 src/routes/c/[handle=handle]/post/[rkey]/+page.svelte rename src/routes/c/{[handle] => [handle=handle]}/post/[rkey]/+page.ts (87%) create mode 100644 src/routes/c/[handle=handle]/post/[rkey]/comment/[crkey]/+page.svelte create mode 100644 src/routes/c/[handle=handle]/post/[rkey]/comment/[crkey]/+page.ts create mode 100644 src/routes/c/[handle=handle]/post/[rkey]/comment/[crkey]/page.test.ts rename src/routes/c/{[handle] => [handle=handle]}/post/[rkey]/page.test.ts (98%) rename src/routes/c/{[handle] => [handle=handle]}/settings/+layout.svelte (100%) rename src/routes/c/{[handle] => [handle=handle]}/settings/+layout.ts (100%) rename src/routes/c/{[handle] => [handle=handle]}/settings/+page.svelte (100%) rename src/routes/c/{[handle] => [handle=handle]}/settings/team/+page.svelte (100%) delete mode 100644 src/routes/c/[handle]/post/[rkey]/+page.svelte rename src/routes/u/{[handle] => [handle=handle]}/+page.ts (100%) rename src/routes/u/{[handle] => [handle=handle]}/page.test.ts (52%) diff --git a/src/lib/api/coves/client.test.ts b/src/lib/api/coves/client.test.ts index c7b1fe72..aefca299 100644 --- a/src/lib/api/coves/client.test.ts +++ b/src/lib/api/coves/client.test.ts @@ -70,6 +70,48 @@ describe('Comment methods', () => { }) }) + it('getComments() passes parentRkey through to the query params', async () => { + await client.getComments({ + post: 'at://did:plc:abc/post/1' as AtUri, + parentRkey: '3jui7kd2xs22a', + depth: 6, + }) + + expect(querySpy).toHaveBeenCalledWith(NSID.getComments, { + post: 'at://did:plc:abc/post/1', + parentRkey: '3jui7kd2xs22a', + depth: 6, + }) + }) + + it('getComments() omits parentRkey from the request URL when undefined', async () => { + // Asserting through the query spy can't distinguish `{ parentRkey: + // undefined }` from the key being absent (toHaveBeenCalledWith treats + // them as equal), so pin the behavior at the serialized-URL level with + // an unmocked query() and a fake fetch instead. + querySpy.mockRestore() + const fetchSpy = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ comments: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + const urlClient = new CovesClient({ + fetchFn: fetchSpy as unknown as typeof fetch, + baseUrl: 'https://api.coves.social', + }) + + await urlClient.getComments({ + post: 'at://did:plc:abc/post/1' as AtUri, + parentRkey: undefined, + }) + + expect(fetchSpy).toHaveBeenCalledTimes(1) + const url = new URL(String(fetchSpy.mock.calls[0][0])) + expect(url.searchParams.has('parentRkey')).toBe(false) + expect(url.searchParams.get('post')).toBe('at://did:plc:abc/post/1') + }) + it('createComment() calls procedure with correct NSID', async () => { const input = { reply: { diff --git a/src/lib/api/coves/types.ts b/src/lib/api/coves/types.ts index 52d3aa8d..6c36176c 100644 --- a/src/lib/api/coves/types.ts +++ b/src/lib/api/coves/types.ts @@ -458,6 +458,14 @@ export interface GetCommentsParams { depth?: number limit?: number cursor?: string + /** + * Scopes the response to a single subtree: when set, `comments` contains + * exactly one top-level ThreadViewComment — the comment with this rkey — + * with its descendants nested beneath it. `depth` is relative to that + * comment and `cursor` paginates its direct replies. An unknown rkey + * yields an HTTP 404 with error name `ParentNotFound`. + */ + parentRkey?: string } export interface GetCommentsResponse { diff --git a/src/lib/app/i18n/en.json b/src/lib/app/i18n/en.json index 4f07c58d..31140fea 100644 --- a/src/lib/app/i18n/en.json +++ b/src/lib/app/i18n/en.json @@ -630,8 +630,18 @@ "locked": "This post is locked.", "banned": "You are banned from this community.", "more": "{{comments:number}} more", + "thread": "Continue this thread", "collapse": "Collapse", - "expand": "Expand" + "expand": "Expand", + "permalink": { + "title": "Comment thread", + "single": "You're viewing a single comment's thread.", + "allComments": "View all comments", + "context": "Show context", + "postFallback": "Post", + "loading": "Loading comments", + "failed": "Failed to load comments." + } }, "moderation": { "report": "Report", @@ -744,6 +754,7 @@ }, "toast": { "noComments": "The API returned no comments.", + "commentNotFound": "That comment could not be found.", "loginVoteGate": "You must be logged in to vote.", "blockedCommunity": "Blocked that community.", "unblockedCommunity": "Unblocked that community.", @@ -995,6 +1006,7 @@ "incorrect_login_12": "...", "registration_application_is_pending": "The registration application for that account hasn't been accepted yet.", "couldnt_find_post": "That post doesn't exist, is not on this server, or was removed.", + "couldnt_find_comment": "That comment doesn't exist, is not on this server, or was removed.", "cant_block_yourself": "You cannot block yourself. You shouldn't either, you're (probably) a great person!", "email_not_verified": "Your account's email is not verified.", "passwords_do_not_match": "Your input password and the verification password do not match.", diff --git a/src/lib/app/util.svelte.test.ts b/src/lib/app/util.svelte.test.ts index 88dd9bc3..405d7b93 100644 --- a/src/lib/app/util.svelte.test.ts +++ b/src/lib/app/util.svelte.test.ts @@ -28,13 +28,15 @@ describe('communityLink', () => { expect(communityLink(community)).toBe('/c/tech.coves.social') }) - it('returns /c/{name} for CommunityRef without handle', () => { + it('falls back to /c/{did} for CommunityRef without handle', () => { + // A bare name is not matcher-valid for the [handle=handle] route; + // the DID keeps the generated URL routable. const noHandle: CommunityRef = { did: 'did:plc:abc123' as DID, handle: '' as Handle, name: 'tech', } - expect(communityLink(noHandle)).toBe('/c/tech') + expect(communityLink(noHandle)).toBe('/c/did%3Aplc%3Aabc123') }) it('prepends prefix when provided', () => { @@ -114,6 +116,24 @@ describe('communityHandleFromSlug', () => { it('handles an empty string by prepending c-', () => { expect(communityHandleFromSlug('')).toBe('c-') }) + + it('passes a did:plc DID through unchanged', () => { + expect(communityHandleFromSlug('did:plc:abc123xyz')).toBe( + 'did:plc:abc123xyz', + ) + }) + + it('passes a did:web DID through unchanged', () => { + expect(communityHandleFromSlug('did:web:coves.social')).toBe( + 'did:web:coves.social', + ) + }) + + it('still prefixes a handle-shaped slug that merely contains "did"', () => { + expect(communityHandleFromSlug('did.coves.social')).toBe( + 'c-did.coves.social', + ) + }) }) // --------------------------------------------------------------------------- diff --git a/src/lib/app/util.svelte.ts b/src/lib/app/util.svelte.ts index 8b275ba6..c939060f 100644 --- a/src/lib/app/util.svelte.ts +++ b/src/lib/app/util.svelte.ts @@ -58,8 +58,13 @@ export function communitySlug(handle: string): string { * Coves community handles use a "c-" prefix convention (e.g. "c-mycommunity") * to distinguish community actors from user actors in the ATProto namespace. * This reverses {@link communitySlug} for API calls that expect the full handle. + * + * DIDs (e.g. "did:plc:abc123") are passed through unchanged: the route param + * matcher accepts URL-encoded DIDs as well as handles, and a DID already + * identifies the community actor without any handle prefix. */ export function communityHandleFromSlug(slug: string): string { + if (slug.startsWith('did:')) return slug return slug.startsWith('c-') ? slug : `c-${slug}` } @@ -214,6 +219,10 @@ export const isVideo = (url: string | undefined): boolean => { /** * Generate a link path for a community. * Accepts a Coves CommunityRef or CommunityView. + * + * Falls back to the community DID when the handle is missing — the + * `[handle=handle]` route matcher accepts handles and DIDs but not bare + * community names, so a `name`-based URL would 404 at routing. */ export function communityLink( community: CommunityRef | CovesCommunityView, @@ -222,7 +231,7 @@ export function communityLink( if ('handle' in community && community.handle) { return `${prefix}/c/${encodeURIComponent(communitySlug(community.handle))}` } - return `${prefix}/c/${encodeURIComponent(community.name)}` + return `${prefix}/c/${encodeURIComponent(community.did)}` } /** diff --git a/src/lib/feature/comment/Comment.svelte b/src/lib/feature/comment/Comment.svelte index 7617a5f2..781f2cdb 100644 --- a/src/lib/feature/comment/Comment.svelte +++ b/src/lib/feature/comment/Comment.svelte @@ -2,6 +2,7 @@ import { page } from '$app/state' import { coves } from '$lib/api/client.svelte' import type { StrongRef } from '$lib/api/coves/types' + import { parseAtUri } from '$lib/api/coves/types' import type { DID } from '$lib/types/atproto' import { profile } from '$lib/app/auth.svelte' import { errorMessage } from '$lib/app/error' @@ -55,6 +56,10 @@ let newComment = $state(node.comment.record.content) let editingLoad = $state(false) + // Stable anchor id (`comment-`) so permalinks can deep-link to this + // comment via a `#comment-` URL fragment. + const domId = $derived(`comment-${parseAtUri(node.comment.uri).rkey}`) + async function save() { if (node.comment.isDeleted) return if (!profile.current?.jwt) { @@ -123,7 +128,7 @@ {/if} -
  • +
  • {#if meta} {@const creatorIsOp = postAuthorDid !== undefined && node.comment.author.did === postAuthorDid} @@ -223,7 +228,7 @@ noStyle class={[ 'text-[15px] sm:text-base text-slate-700 dark:text-zinc-300 *:leading-[1.6] break-words space-y-3', - page.url.hash.slice(1) === (node.comment.uri as string) && + page.url.hash.slice(1) === domId && 'material-info px-3 py-1.5 rounded-xl max-w-max', ]} /> diff --git a/src/lib/feature/comment/CommentListVirtualizer.svelte b/src/lib/feature/comment/CommentListVirtualizer.svelte index d58a15e9..95635035 100644 --- a/src/lib/feature/comment/CommentListVirtualizer.svelte +++ b/src/lib/feature/comment/CommentListVirtualizer.svelte @@ -1,5 +1,6 @@ + + + {#if data.data.value?.post} + + {data.data.value.post.record?.title ?? 'Post'} + + + + + {:else if data.data.value?.unavailable} + Post unavailable + {/if} + + +
    + {#if data.data.value?.post} + {@const post = data.data.value.post} + + + + + + +
    +

    + + Comments + {#if post.stats?.commentCount} + + ({post.stats.commentCount}) + + {/if} +

    + + {#await data.data.value.comments} +
    + +
    + {:then comments} + + {#if comments.length === 0} +

    + No comments yet. Be the first to comment! +

    + {/if} + {:catch} +

    + Failed to load comments. +

    + {/await} +
    + {:else if data.data.value?.unavailable} + + + + {:else} +
    + +
    + {/if} +
    + + diff --git a/src/routes/c/[handle]/post/[rkey]/+page.ts b/src/routes/c/[handle=handle]/post/[rkey]/+page.ts similarity index 87% rename from src/routes/c/[handle]/post/[rkey]/+page.ts rename to src/routes/c/[handle=handle]/post/[rkey]/+page.ts index 2b17a2c5..119e2cce 100644 --- a/src/routes/c/[handle]/post/[rkey]/+page.ts +++ b/src/routes/c/[handle=handle]/post/[rkey]/+page.ts @@ -15,26 +15,14 @@ import { feeds, type FeedTypes, } from '$lib/feature/feeds/feed.svelte' - -const POST_COLLECTION = 'social.coves.community.post' - -/** - * Constructs the canonical DID-based AT-URI for a post. - * Used as a fallback when navigating directly to a post URL without a cache hit - * or an explicit `?uri=` param. Building it from the community's DID (rather than - * its handle) keeps the hydration path stable across community renames — the one - * handle→DID hop is resolved up front via `getCommunity`. - */ -function buildPostAtUri(communityDid: string, rkey: string): AtUri { - return `at://${communityDid}/${POST_COLLECTION}/${rkey}` as AtUri -} +import { buildPostAtUri } from '$lib/feature/post/helpers' /** * Searches the feed cache for a post matching the given rkey. * This provides instant display when the user navigated from a feed page. */ function findInFeed( - id: '/' | '/c/[handle]', + id: '/' | '/c/[handle=handle]', rkey: string, ): CovesPostView | undefined { const cached = ( @@ -75,7 +63,8 @@ export async function load({ params, url, fetch, route }) { // Try feed cache for instant display const cachedPost = - findInFeed('/', params.rkey) ?? findInFeed('/c/[handle]', params.rkey) + findInFeed('/', params.rkey) ?? + findInFeed('/c/[handle=handle]', params.rkey) const feedData = feed(route.id, async (p) => { // If we have a preloaded post from cache, use it. Otherwise fetch from API. diff --git a/src/routes/c/[handle=handle]/post/[rkey]/comment/[crkey]/+page.svelte b/src/routes/c/[handle=handle]/post/[rkey]/comment/[crkey]/+page.svelte new file mode 100644 index 00000000..27cf6a5a --- /dev/null +++ b/src/routes/c/[handle=handle]/post/[rkey]/comment/[crkey]/+page.svelte @@ -0,0 +1,124 @@ + + + + + {post.record?.title ?? $t('comment.permalink.postFallback')} · {$t( + 'comment.permalink.title', + )} + + + + + + +
    + + + + + + + +

    + {$t('comment.permalink.single')} +

    +
    + + {#if focused.parentUri} + + {/if} +
    +
    + + +
    + {#await data.data.value.comments} +
    + +
    + {:then comments} + + {:catch} +

    + {$t('comment.permalink.failed')} +

    + {/await} +
    +
    diff --git a/src/routes/c/[handle=handle]/post/[rkey]/comment/[crkey]/+page.ts b/src/routes/c/[handle=handle]/post/[rkey]/comment/[crkey]/+page.ts new file mode 100644 index 00000000..c4a4e9b0 --- /dev/null +++ b/src/routes/c/[handle=handle]/post/[rkey]/comment/[crkey]/+page.ts @@ -0,0 +1,127 @@ +import { error } from '@sveltejs/kit' +import { coves } from '$lib/api/client.svelte' +import { type AtUri, isHydratedPost, parseAtUri } from '$lib/api/coves/types' +import { XrpcError } from '$lib/api/coves/xrpc' +import type { Handle } from '$lib/types/atproto' +import { settings } from '$lib/app/settings.svelte' +import { mapSort } from '$lib/app/sort' +import { communityHandleFromSlug, ReactiveState } from '$lib/app/util.svelte' +import { MAX_INLINE_DEPTH } from '$lib/feature/comment/comments.svelte' +import CommunityCard from '$lib/feature/community/CommunityCard.svelte' +import { feed } from '$lib/feature/feeds/feed.svelte' +import { buildPostAtUri } from '$lib/feature/post/helpers' + +/** + * Depth of descendants fetched below the focused comment. The focused comment + * renders at depth 0 and CommentTree routes nodes deeper than + * MAX_INLINE_DEPTH to their own permalink pages, so this fills the inline + * capacity plus the row that shows the "continue this thread" link. + */ +const SUBTREE_DEPTH = MAX_INLINE_DEPTH + 1 + +export async function load({ params, url, fetch, route }) { + const communityHandle = communityHandleFromSlug(params.handle) + const commentSort = + url.searchParams.get('sort') ?? settings?.defaultSort?.comments ?? 'hot' + const { sort } = mapSort(commentSort) + + const feedData = feed(route.id, async (p) => { + const client = coves({ func: fetch }) + + // Fetch the post and the focused comment's subtree in parallel. Unlike the + // post page (which streams comments), the subtree is awaited here: the + // focused comment IS the page's content, and an unknown rkey must surface + // as a routable 404 — which is impossible after load has already returned + // a streamed promise. + const [postResult, subtree] = await Promise.all([ + client.getPost(p.postUri as AtUri), + client.getComments(p.comments).catch((err: unknown) => { + if (err instanceof XrpcError && err.errorName === 'ParentNotFound') { + error(404, 'couldnt_find_comment') + } + throw err + }), + ]) + + // Both unavailable sentinels (deleted/unindexed and blocked-author) 404 + // here: unlike the post page there is no partial page worth rendering + // when the post itself cannot be shown on a single comment's thread. + if (!isHydratedPost(postResult)) { + error(404, 'couldnt_find_post') + } + + // Contract: with `parentRkey` set, `comments` contains exactly one + // top-level ThreadViewComment — the focused comment. An empty array means + // it vanished between requests (or an indexing race); treat it as gone. + const root = subtree.comments[0] + if (!root) { + error(404, 'couldnt_find_comment') + } + + const parent = root.comment.parent + return { + post: postResult, + // Already resolved, but kept as a promise so the page shares the post + // page's {#await}/reload shape. + comments: Promise.resolve(subtree.comments), + focused: { + uri: root.comment.uri, + rkey: parseAtUri(root.comment.uri).rkey, + // Only a comment parent yields a "show context" hop — a parent ref + // pointing at the post itself means the focused comment is top-level. + parentUri: + parent && parent.uri !== root.comment.post.uri + ? parent.uri + : undefined, + }, + params: p, + } + }) + + // Prefer the DID-based `?uri=` override (rename-stable, no extra hop); + // otherwise resolve the community handle → DID once and build the URI. + let postUri = url.searchParams.get('uri') as AtUri | null + if (!postUri) { + const community = await coves({ func: fetch }) + .getCommunity({ community: communityHandle as Handle }) + .catch((err: unknown) => { + // An unknown community slug (typo'd/stale pasted permalink) comes back + // from the backend as HTTP 404 with error name "NotFound" + // (internal/api/handlers/community/errors.go). Surface it as a + // routable 404 rather than an unhandled rejection. + if ( + err instanceof XrpcError && + err.status === 404 && + err.errorName === 'NotFound' + ) { + error(404, 'couldnt_find_community') + } + throw err + }) + postUri = buildPostAtUri(community.did, params.rkey) + } + + const loaded = new ReactiveState( + await feedData.load({ + postUri: postUri as string, + comments: { + post: postUri, + parentRkey: params.crkey, + sort, + depth: SUBTREE_DEPTH, + limit: 50, + }, + }), + ) + + return { + data: loaded, + communityHandle, + slots: { + sidebar: { + component: CommunityCard, + props: { community: loaded.value.post.community }, + }, + }, + } +} diff --git a/src/routes/c/[handle=handle]/post/[rkey]/comment/[crkey]/page.test.ts b/src/routes/c/[handle=handle]/post/[rkey]/comment/[crkey]/page.test.ts new file mode 100644 index 00000000..b6ab2004 --- /dev/null +++ b/src/routes/c/[handle=handle]/post/[rkey]/comment/[crkey]/page.test.ts @@ -0,0 +1,340 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// --------------------------------------------------------------------------- +// Mocks +// +// Mirrors the post page's loader test (../..(rkey)/page.test.ts): the Coves +// XRPC client, the `feed()` factory, and `ReactiveState` (a `$state` rune +// wrapper) are replaced with lightweight fakes so the load function can be +// exercised in a plain node test environment. +// --------------------------------------------------------------------------- + +const mockCovesMethods = vi.hoisted(() => ({ + getPost: vi.fn(), + getComments: vi.fn(), + getCommunity: vi.fn(), +})) + +vi.mock('$lib/api/client.svelte', () => ({ + coves: () => mockCovesMethods, +})) + +// `feed(id, init)` returns an object whose `.load(params)` simply runs `init`. +// This makes the loader's init callback the unit under test. +vi.mock('$lib/feature/feeds/feed.svelte', () => ({ + feed: ( + _id: string, + init: (params: unknown) => Promise, + ): { load: (params: unknown) => Promise } => ({ + load: async (params: unknown) => init(params), + }), +})) + +// `ReactiveState` is a `$state`-backed wrapper; in a plain `.test.ts` (node env, +// no runes transform) we stub it with a trivial value holder. +vi.mock('$lib/app/util.svelte', async () => { + const actual = await vi.importActual( + '$lib/app/util.svelte', + ) + return { + ...actual, + ReactiveState: class { + value: T + constructor(initialValue: T) { + this.value = initialValue + } + }, + } +}) + +// `settings.svelte` and `sort` pull in env/runes side effects unrelated to the +// branches under test; stub them to the minimum the loader touches. +vi.mock('$lib/app/settings.svelte', () => ({ + settings: { defaultSort: { comments: 'hot' } }, +})) + +vi.mock('$lib/app/sort', () => ({ + mapSort: () => ({ sort: 'hot' }), +})) + +import { XrpcError } from '$lib/api/coves/xrpc' +import CommunityCard from '$lib/feature/community/CommunityCard.svelte' +import { load } from './+page' + +const COMMUNITY_DID = 'did:plc:community123' +const COMMENTER_DID = 'did:plc:commenter456' +const POST_COLLECTION = 'social.coves.community.post' +const COMMENT_COLLECTION = 'social.coves.community.comment' + +const POST_URI = `at://${COMMUNITY_DID}/${POST_COLLECTION}/abc123` + +// The loader only destructures { params, url, fetch, route }; supplying those +// four is sufficient at runtime. Cast to the full LoadEvent for the type, the +// same way the post page's loader test does. +function makeArgs(overrides?: { + handle?: string + rkey?: string + crkey?: string + url?: string +}): Parameters[0] { + const handle = overrides?.handle ?? 'testcommunity' + const rkey = overrides?.rkey ?? 'abc123' + const crkey = overrides?.crkey ?? 'comment1' + return { + params: { handle, rkey, crkey }, + url: new URL( + overrides?.url ?? + `https://coves.test/c/${handle}/post/${rkey}/comment/${crkey}`, + ), + fetch: globalThis.fetch, + route: { id: '/c/[handle=handle]/post/[rkey]/comment/[crkey]' }, + } as unknown as Parameters[0] +} + +function hydratedPost(rkey: string) { + const uri = `at://${COMMUNITY_DID}/${POST_COLLECTION}/${rkey}` + return { + uri, + cid: 'bafyreigh2akiscaildc', + rkey, + indexedAt: '2026-01-01T00:00:00.000Z', + createdAt: '2026-01-01T00:00:00.000Z', + author: { did: 'did:plc:author', handle: 'author.coves.test' }, + community: { + did: COMMUNITY_DID, + handle: 'c-testcommunity', + name: 'testcommunity', + }, + record: { title: 'Hello', content: 'World' }, + } +} + +/** + * Builds the single-root subtree `getComments` returns for a `parentRkey` + * request. `parentUri` controls the focused comment's parent ref: the post + * URI models a top-level comment, a comment URI models a nested reply, and + * undefined models a backend response with no parent ref at all. + */ +function subtree(crkey: string, parentUri?: string) { + const uri = `at://${COMMENTER_DID}/${COMMENT_COLLECTION}/${crkey}` + return { + post: hydratedPost('abc123'), + comments: [ + { + comment: { + uri, + cid: 'bafyreicomment', + createdAt: '2026-01-02T00:00:00.000Z', + indexedAt: '2026-01-02T00:00:00.000Z', + record: { + $type: COMMENT_COLLECTION, + content: 'A comment', + reply: { + root: { uri: POST_URI, cid: 'bafyreigh2akiscaildc' }, + parent: { + uri: parentUri ?? POST_URI, + cid: 'bafyreiparent', + }, + }, + createdAt: '2026-01-02T00:00:00.000Z', + }, + author: { did: COMMENTER_DID, handle: 'commenter.coves.test' }, + post: { uri: POST_URI, cid: 'bafyreigh2akiscaildc' }, + stats: { upvotes: 0, downvotes: 0, score: 0, replyCount: 0 }, + ...(parentUri === undefined + ? {} + : { parent: { uri: parentUri, cid: 'bafyreiparent' } }), + }, + replies: [], + }, + ], + } +} + +// The loaded `ReactiveState` is mocked to a plain `{ value }` holder; this +// narrows the unknown return for assertions. +function loadedValue( + result: Awaited>, +): Record { + return (result.data as { value: Record }).value +} + +describe('comment permalink loader', () => { + beforeEach(() => { + mockCovesMethods.getPost.mockReset() + mockCovesMethods.getComments.mockReset() + mockCovesMethods.getCommunity.mockReset() + + // Sensible defaults; individual tests override as needed. + mockCovesMethods.getCommunity.mockResolvedValue({ did: COMMUNITY_DID }) + mockCovesMethods.getPost.mockResolvedValue(hydratedPost('abc123')) + mockCovesMethods.getComments.mockResolvedValue( + subtree('comment1', POST_URI), + ) + }) + + it('returns the post, focused comment, subtree, and a CommunityCard slot on the happy path', async () => { + const post = hydratedPost('abc123') + const tree = subtree('comment1', POST_URI) + mockCovesMethods.getPost.mockResolvedValue(post) + mockCovesMethods.getComments.mockResolvedValue(tree) + + const result = await load(makeArgs()) + const value = loadedValue(result) + + expect(value.post).toEqual(post) + await expect(value.comments).resolves.toEqual(tree.comments) + expect(value.focused).toEqual({ + uri: `at://${COMMENTER_DID}/${COMMENT_COLLECTION}/comment1`, + rkey: 'comment1', + // Parent ref points at the post itself ⇒ top-level comment, no context hop. + parentUri: undefined, + }) + + expect(mockCovesMethods.getComments).toHaveBeenCalledTimes(1) + // Exact params object — dropping sort/depth/limit must fail this test. + // depth pins SUBTREE_DEPTH (MAX_INLINE_DEPTH + 1); sort comes from the + // mocked mapSort. + expect(mockCovesMethods.getComments).toHaveBeenCalledWith({ + post: POST_URI, + parentRkey: 'comment1', + sort: 'hot', + depth: 5, + limit: 50, + }) + + const slots = result.slots as { + sidebar: { component: unknown; props: { community: unknown } } + } + expect(slots.sidebar.component).toBe(CommunityCard) + expect(slots.sidebar.props.community).toEqual(post.community) + }) + + it('exposes focused.parentUri when the focused comment replies to another comment', async () => { + const parentUri = `at://${COMMENTER_DID}/${COMMENT_COLLECTION}/parentc` + mockCovesMethods.getComments.mockResolvedValue( + subtree('comment1', parentUri), + ) + + const result = await load(makeArgs()) + const value = loadedValue(result) as { + focused: { parentUri?: string } + } + + expect(value.focused.parentUri).toBe(parentUri) + }) + + it('leaves focused.parentUri undefined when the backend omits the parent ref', async () => { + mockCovesMethods.getComments.mockResolvedValue( + subtree('comment1', undefined), + ) + + const result = await load(makeArgs()) + const value = loadedValue(result) as { + focused: { parentUri?: string } + } + + expect(value.focused.parentUri).toBeUndefined() + }) + + it('404s with couldnt_find_comment on a ParentNotFound XRPC error', async () => { + mockCovesMethods.getComments.mockRejectedValue( + new XrpcError(404, 'ParentNotFound', 'parent comment not found'), + ) + + await expect(load(makeArgs())).rejects.toMatchObject({ + status: 404, + body: { message: 'couldnt_find_comment' }, + }) + }) + + it('propagates non-ParentNotFound getComments errors unchanged', async () => { + const serverError = new XrpcError(500, 'InternalServerError', 'boom') + mockCovesMethods.getComments.mockRejectedValue(serverError) + + await expect(load(makeArgs())).rejects.toBe(serverError) + }) + + it('404s with couldnt_find_post for a notFound post sentinel', async () => { + mockCovesMethods.getPost.mockResolvedValue({ + uri: POST_URI, + notFound: true, + }) + + await expect(load(makeArgs())).rejects.toMatchObject({ + status: 404, + body: { message: 'couldnt_find_post' }, + }) + }) + + it('404s with couldnt_find_post for a blocked post sentinel', async () => { + mockCovesMethods.getPost.mockResolvedValue({ + uri: POST_URI, + blocked: true, + }) + + await expect(load(makeArgs())).rejects.toMatchObject({ + status: 404, + body: { message: 'couldnt_find_post' }, + }) + }) + + it('404s with couldnt_find_comment when the subtree response is empty (contract violation / race)', async () => { + mockCovesMethods.getComments.mockResolvedValue({ + post: hydratedPost('abc123'), + comments: [], + }) + + await expect(load(makeArgs())).rejects.toMatchObject({ + status: 404, + body: { message: 'couldnt_find_comment' }, + }) + }) + + it('URI precedence: uses ?uri= verbatim and does NOT call getCommunity', async () => { + const explicitUri = `at://${COMMUNITY_DID}/${POST_COLLECTION}/from-query` + mockCovesMethods.getPost.mockResolvedValue(hydratedPost('from-query')) + + await load( + makeArgs({ + url: `https://coves.test/c/testcommunity/post/abc123/comment/comment1?uri=${encodeURIComponent(explicitUri)}`, + }), + ) + + expect(mockCovesMethods.getCommunity).not.toHaveBeenCalled() + expect(mockCovesMethods.getPost).toHaveBeenCalledWith(explicitUri) + expect(mockCovesMethods.getComments).toHaveBeenCalledWith( + expect.objectContaining({ post: explicitUri, parentRkey: 'comment1' }), + ) + }) + + it('URI precedence: without ?uri=, resolves the community DID and builds the post URI', async () => { + await load(makeArgs()) + + expect(mockCovesMethods.getCommunity).toHaveBeenCalledTimes(1) + expect(mockCovesMethods.getCommunity).toHaveBeenCalledWith({ + community: 'c-testcommunity', + }) + expect(mockCovesMethods.getPost).toHaveBeenCalledWith(POST_URI) + }) + + it('404s with couldnt_find_community when the community lookup (no ?uri=) hits a NotFound XRPC error', async () => { + // Backend contract: an unknown community slug yields HTTP 404 with error + // name "NotFound" (internal/api/handlers/community/errors.go). + mockCovesMethods.getCommunity.mockRejectedValue( + new XrpcError(404, 'NotFound', 'community not found'), + ) + + await expect(load(makeArgs())).rejects.toMatchObject({ + status: 404, + body: { message: 'couldnt_find_community' }, + }) + }) + + it('propagates non-NotFound getCommunity errors unchanged', async () => { + const serverError = new XrpcError(500, 'InternalServerError', 'boom') + mockCovesMethods.getCommunity.mockRejectedValue(serverError) + + await expect(load(makeArgs())).rejects.toBe(serverError) + }) +}) diff --git a/src/routes/c/[handle]/post/[rkey]/page.test.ts b/src/routes/c/[handle=handle]/post/[rkey]/page.test.ts similarity index 98% rename from src/routes/c/[handle]/post/[rkey]/page.test.ts rename to src/routes/c/[handle=handle]/post/[rkey]/page.test.ts index 8a8833e4..dfcab6a0 100644 --- a/src/routes/c/[handle]/post/[rkey]/page.test.ts +++ b/src/routes/c/[handle=handle]/post/[rkey]/page.test.ts @@ -70,7 +70,7 @@ const POST_COLLECTION = 'social.coves.community.post' // The loader only destructures { params, url, fetch, route }; supplying those // four is sufficient at runtime. Cast to the full LoadEvent for the type, the -// same way the repo's other load tests do (see u/[handle]/page.test.ts). +// same way the repo's other load tests do (see u/[handle=handle]/page.test.ts). function makeArgs(overrides?: { handle?: string rkey?: string @@ -86,7 +86,7 @@ function makeArgs(overrides?: { `https://coves.test/c/${overrides?.handle ?? 'testcommunity'}/post/${overrides?.rkey ?? 'abc123'}`, ), fetch: globalThis.fetch, - route: { id: '/c/[handle]/post/[rkey]' }, + route: { id: '/c/[handle=handle]/post/[rkey]' }, } as unknown as Parameters[0] } diff --git a/src/routes/c/[handle]/settings/+layout.svelte b/src/routes/c/[handle=handle]/settings/+layout.svelte similarity index 100% rename from src/routes/c/[handle]/settings/+layout.svelte rename to src/routes/c/[handle=handle]/settings/+layout.svelte diff --git a/src/routes/c/[handle]/settings/+layout.ts b/src/routes/c/[handle=handle]/settings/+layout.ts similarity index 100% rename from src/routes/c/[handle]/settings/+layout.ts rename to src/routes/c/[handle=handle]/settings/+layout.ts diff --git a/src/routes/c/[handle]/settings/+page.svelte b/src/routes/c/[handle=handle]/settings/+page.svelte similarity index 100% rename from src/routes/c/[handle]/settings/+page.svelte rename to src/routes/c/[handle=handle]/settings/+page.svelte diff --git a/src/routes/c/[handle]/settings/team/+page.svelte b/src/routes/c/[handle=handle]/settings/team/+page.svelte similarity index 100% rename from src/routes/c/[handle]/settings/team/+page.svelte rename to src/routes/c/[handle=handle]/settings/team/+page.svelte diff --git a/src/routes/c/[handle]/post/[rkey]/+page.svelte b/src/routes/c/[handle]/post/[rkey]/+page.svelte deleted file mode 100644 index 7a74bf2e..00000000 --- a/src/routes/c/[handle]/post/[rkey]/+page.svelte +++ /dev/null @@ -1,135 +0,0 @@ - - - - {#if data.data.value?.post} - - {data.data.value.post.record?.title ?? 'Post'} - - - - - {:else if data.data.value?.unavailable} - Post unavailable - {/if} - - -
    - {#if data.data.value?.post} - {@const post = data.data.value.post} - - - - - - -
    -

    - - Comments - {#if post.stats?.commentCount} - - ({post.stats.commentCount}) - - {/if} -

    - - {#await data.data.value.comments} -
    - -
    - {:then comments} - - {#if comments.length === 0} -

    - No comments yet. Be the first to comment! -

    - {/if} - {:catch} -

    - Failed to load comments. -

    - {/await} -
    - {:else if data.data.value?.unavailable} - - - - {:else} -
    - -
    - {/if} -
    diff --git a/src/routes/create/post/+page.svelte b/src/routes/create/post/+page.svelte index a9176816..d8a0b26d 100644 --- a/src/routes/create/post/+page.svelte +++ b/src/routes/create/post/+page.svelte @@ -53,9 +53,11 @@ // feed cache, and this avoids a backend handle→DID round-trip. goto(postLink(result, true)) } catch (err) { + // DID fallback keeps the URL routable: the [handle=handle] matcher + // accepts handles and DIDs, but not bare community names. const slug = result.community.handle ? communitySlug(result.community.handle) - : result.community.name + : result.community.did console.warn( '[create/post] Failed to parse post URI, falling back to community page:', err, diff --git a/src/routes/profile/[handle=handle]/+page.ts b/src/routes/profile/[handle=handle]/+page.ts index 864a7519..04da818b 100644 --- a/src/routes/profile/[handle=handle]/+page.ts +++ b/src/routes/profile/[handle=handle]/+page.ts @@ -36,7 +36,7 @@ export async function load({ params, url, fetch, route }) { } } catch (err) { if (err instanceof Error && err.message.includes('not found')) { - error(404, 'User not found') + error(404, 'couldnt_find_person') } error(500, 'Failed to load profile') } diff --git a/src/routes/u/[handle]/+page.ts b/src/routes/u/[handle=handle]/+page.ts similarity index 100% rename from src/routes/u/[handle]/+page.ts rename to src/routes/u/[handle=handle]/+page.ts diff --git a/src/routes/u/[handle]/page.test.ts b/src/routes/u/[handle=handle]/page.test.ts similarity index 52% rename from src/routes/u/[handle]/page.test.ts rename to src/routes/u/[handle=handle]/page.test.ts index 0c3e5a19..85d49b5a 100644 --- a/src/routes/u/[handle]/page.test.ts +++ b/src/routes/u/[handle=handle]/page.test.ts @@ -6,10 +6,13 @@ interface RedirectError { location: string } -describe('/u/[handle] redirect', () => { +describe('/u/[handle=handle] redirect', () => { + // All fixture params must be matcher-valid — the [handle=handle] matcher + // only routes handles (dotted domains) and DIDs, so a bare name like + // "alice" would never reach this load function. it('redirects to /profile/{handle} with 301 status', () => { - const params = { handle: 'alice' } - const url = new URL('http://localhost/u/alice') + const params = { handle: 'alice.coves.social' } + const url = new URL('http://localhost/u/alice.coves.social') try { load({ params, url } as Parameters[0]) @@ -17,13 +20,13 @@ describe('/u/[handle] redirect', () => { } catch (e: unknown) { const redirect = e as RedirectError expect(redirect.status).toBe(301) - expect(redirect.location).toBe('/profile/alice') + expect(redirect.location).toBe('/profile/alice.coves.social') } }) it('preserves query parameters in redirect', () => { - const params = { handle: 'alice' } - const url = new URL('http://localhost/u/alice?sort=top&page=2') + const params = { handle: 'alice.coves.social' } + const url = new URL('http://localhost/u/alice.coves.social?sort=top&page=2') try { load({ params, url } as Parameters[0]) @@ -31,13 +34,16 @@ describe('/u/[handle] redirect', () => { } catch (e: unknown) { const redirect = e as RedirectError expect(redirect.status).toBe(301) - expect(redirect.location).toBe('/profile/alice?sort=top&page=2') + expect(redirect.location).toBe( + '/profile/alice.coves.social?sort=top&page=2', + ) } }) - it('encodes special characters in handle', () => { - const params = { handle: 'user@example.com' } - const url = new URL('http://localhost/u/user@example.com') + it('encodes special characters in a DID param', () => { + // DIDs are the matcher-valid param form that needs URL encoding (colons). + const params = { handle: 'did:plc:abc123' } + const url = new URL('http://localhost/u/did:plc:abc123') try { load({ params, url } as Parameters[0]) @@ -45,13 +51,13 @@ describe('/u/[handle] redirect', () => { } catch (e: unknown) { const redirect = e as RedirectError expect(redirect.status).toBe(301) - expect(redirect.location).toBe('/profile/user%40example.com') + expect(redirect.location).toBe('/profile/did%3Aplc%3Aabc123') } }) it('redirects with empty query string when no params', () => { - const params = { handle: 'bob' } - const url = new URL('http://localhost/u/bob') + const params = { handle: 'bob.example.com' } + const url = new URL('http://localhost/u/bob.example.com') try { load({ params, url } as Parameters[0]) @@ -59,7 +65,7 @@ describe('/u/[handle] redirect', () => { } catch (e: unknown) { const redirect = e as RedirectError expect(redirect.status).toBe(301) - expect(redirect.location).toBe('/profile/bob') + expect(redirect.location).toBe('/profile/bob.example.com') } }) }) -- 2.51.2