diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81c4e0b..f929138 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,12 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm codegen - run: git diff --exit-code + # Build FIRST, before the checks that read a built package. `@radial/core` resolves through + # `exports.import` to `dist/`, and the UI's `vite.config.ts` reaches it via `client-metadata.ts` + # — so with no `dist/` the config fails to load, and `svelte-check` fails any component that + # has a ` diff --git a/packages/ui/src/lib/components/Community.svelte.test.ts b/packages/ui/src/lib/components/Community.svelte.test.ts index 84b9930..b7caf6e 100644 --- a/packages/ui/src/lib/components/Community.svelte.test.ts +++ b/packages/ui/src/lib/components/Community.svelte.test.ts @@ -28,7 +28,7 @@ const GOAL = 'at://did:plc:member/com.disnetdev.radial.goal/goal' const GUEST = 'did:plc:guest' const HOSTILE = ' **not bold**' -const bless = (rkey: string, body: string, comment: string) => ({ +const bless = (rkey: string, body: string, comment: string, createdAt = '2026-01-01T00:05:00Z') => ({ did: 'did:plc:member', uri: `at://did:plc:member/${COLLECTIONS.blessComment}/${rkey}`, cid: `cid-${rkey}`, @@ -40,7 +40,7 @@ const bless = (rkey: string, body: string, comment: string) => ({ goal: { uri: GOAL, cid: 'cid-goal' }, comment: { uri: `at://${GUEST}/${COLLECTIONS.message}/${comment}`, cid: `cid-${comment}` }, body, - createdAt: '2026-01-01T00:05:00Z', + createdAt, }, }) @@ -78,7 +78,12 @@ afterEach(() => { host.remove() }) -const render = (props: { view: GoalView; space: Space; reader?: ForeignReader }): void => { +const render = (props: { + view: GoalView + space: Space + discover?: boolean + reader?: ForeignReader +}): void => { component = mount(Community, { target: host, props }) as Record flushSync() } @@ -112,6 +117,54 @@ describe('the community section', () => { expect(host.textContent).toContain('No comments from outside this space') }) + it('offers withdrawal, not a third blessing, once a withdrawn comment is blessed again', () => { + render({ + view: view( + [bless('b2', 'Blessed again', 'c1', '2026-01-01T00:09:00Z')], + [bless('b1', 'First blessing', 'c1', '2026-01-01T00:05:00Z')], + ), + space: space(), + }) + const labels = [...host.querySelectorAll('button')].map((entry) => entry.textContent?.trim()) + expect(labels).toContain('Withdraw blessing') + expect(labels).not.toContain('Bless it again') + expect(host.textContent).toContain('blessed') + expect(host.textContent).not.toContain('blessing withdrawn') + }) + + // The switch governs solicitation. Prose a member signed for is in the bundle of every turn on + // this goal until it is withdrawn, so it stays on screen with the control that removes it — + // hiding the section would leave agents reading text nobody could see or retract. + it('keeps a blessed comment and its withdrawal control when discovery is off', () => { + render({ + view: view([bless('b1', 'Still in every bundle', 'c1')]), + space: space(), + discover: false, + }) + expect(host.textContent).toContain('Still in every bundle') + expect( + [...host.querySelectorAll('button')].map((entry) => entry.textContent?.trim()), + ).toContain('Withdraw blessing') + // …and it does not claim a lookup happened. + expect(host.textContent).not.toContain('found through a public backlink index') + expect(host.textContent).toContain('no longer asking for comments') + }) + + it('does not query the index at all when discovery is off', () => { + const fetched = vi.fn(async () => new Response('{}', { status: 200 })) + const read = vi.fn() + vi.stubGlobal('fetch', fetched) + render({ + view: view([bless('b1', 'Blessed', 'c1')]), + space: space(), + discover: false, + reader: { getForeignRecord: read as unknown as ForeignReader['getForeignRecord'] }, + }) + expect(fetched).not.toHaveBeenCalled() + expect(read).not.toHaveBeenCalled() + vi.unstubAllGlobals() + }) + it('makes blessing a two-step, and writes only on the second press', async () => { const rkey = 'c9' const uri = `at://${GUEST}/${COLLECTIONS.message}/${rkey}` diff --git a/packages/ui/src/lib/guests.test.ts b/packages/ui/src/lib/guests.test.ts index 6feb7d2..4ffd13c 100644 --- a/packages/ui/src/lib/guests.test.ts +++ b/packages/ui/src/lib/guests.test.ts @@ -8,6 +8,7 @@ import { didOf, discoverGuestComments, getBacklinks, + showsCommunity, unblessArgs, unblessable, type BacklinkLocator, @@ -180,7 +181,13 @@ describe('discovering guest comments', () => { // ── the section's own rows ────────────────────────────────────────────────────────────────────── -const bless = (did: string, rkey: string, comment: { uri: string; cid: string }, body: string) => ({ +const bless = ( + did: string, + rkey: string, + comment: { uri: string; cid: string }, + body: string, + createdAt = '2026-01-01T00:05:00Z', +) => ({ did, uri: `at://${did}/${COLLECTIONS.blessComment}/${rkey}`, cid: `cid-${rkey}`, @@ -192,7 +199,7 @@ const bless = (did: string, rkey: string, comment: { uri: string; cid: string }, goal: { uri: GOAL, cid: 'cid-goal' }, comment, body, - createdAt: '2026-01-01T00:05:00Z', + createdAt, }, }) @@ -236,6 +243,90 @@ describe('what the section draws', () => { ]) expect(rows[0]?.edited).toBe(false) }) + + // The workflow the section itself offers: bless, withdraw, then "Bless it again". The comment now + // carries a live blessing AND a tombstoned one, and the live blessing is what every turn bundle on + // this goal reads — so a row that reported "withdrawn" would be telling a member their comment is + // out of agent reach while agents were still reading it, and offering the wrong button besides. + it('reports a re-blessed comment as blessed, not as the withdrawal it replaced', () => { + const withdrawn = bless(MEMBER, 'b1', commentRef, 'First blessing', '2026-01-01T00:05:00Z') + const again = bless(MEMBER, 'b2', commentRef, 'Blessed again', '2026-01-01T00:09:00Z') + for (const rows of [ + blessedRows(view([again], [withdrawn])), + communityRows(view([again], [withdrawn])), + ]) { + expect(rows).toHaveLength(1) + expect(rows[0]?.retracted).toBe(false) + // And it names the LIVE blessing, so `Withdraw blessing` writes a tombstone against the record + // that is actually in the bundle rather than re-tombstoning a dead one. + expect(rows[0]?.bless?.uri).toBe(again.uri) + expect(rows[0]?.body).toBe('Blessed again') + } + }) + + it('still reports withdrawn when every blessing on a comment is withdrawn', () => { + const rows = communityRows( + view([], [ + bless(MEMBER, 'b1', commentRef, 'First', '2026-01-01T00:05:00Z'), + bless(MEMBER, 'b2', commentRef, 'Second', '2026-01-01T00:09:00Z'), + ]), + ) + expect(rows).toHaveLength(1) + expect(rows[0]?.retracted).toBe(true) + expect(rows[0]?.bless?.rkey).toBe('b2') + }) + + it('gives two comments two rows, and never merges across them', () => { + const other = { uri: `at://${GUEST}/${COLLECTIONS.message}/two`, cid: 'cid-two' } + const rows = communityRows( + view([bless(MEMBER, 'b2', other, 'Live elsewhere')], [bless(MEMBER, 'b1', commentRef, 'Gone')]), + ) + expect(rows.map((row) => [row.uri, row.retracted])).toEqual( + expect.arrayContaining([ + [commentRef.uri, true], + [other.uri, false], + ]), + ) + expect(rows).toHaveLength(2) + }) + + // Discovery running over a re-blessed comment must not undo the merge either: `communityRows` + // takes the live body for display, and the blessing it carries has to stay the live one. + it('keeps the live blessing when discovery supplies the same comment', () => { + const withdrawn = bless(MEMBER, 'b1', commentRef, 'First blessing', '2026-01-01T00:05:00Z') + const again = bless(MEMBER, 'b2', commentRef, 'Blessed again', '2026-01-01T00:09:00Z') + const rows = communityRows(view([again], [withdrawn]), [ + { ...commentRef, did: GUEST, body: 'Blessed again', createdAt: '2026-01-01T00:00:00Z' }, + ]) + expect(rows[0]?.retracted).toBe(false) + expect(rows[0]?.bless?.uri).toBe(again.uri) + expect(rows[0]?.edited).toBe(false) + }) +}) + +// The switch is solicitation, not revocation, and the section has to outlive it: a blessing stays in +// the bundle of every turn on the goal until a `retractBless` withdraws it, so a page that hid the +// section would leave agents reading prose with nowhere on screen to see it or take it back. +describe('whether the section is drawn at all', () => { + const commentRef = { uri: `at://${GUEST}/${COLLECTIONS.message}/one`, cid: 'cid-one' } + + it('draws it whenever the space is asking for comments', () => { + expect(showsCommunity(view([]), true)).toBe(true) + }) + + it('keeps drawing it with the switch off while a live blessing stands', () => { + expect(showsCommunity(view([bless(MEMBER, 'b1', commentRef, 'In every bundle')]), false)).toBe(true) + }) + + it('keeps drawing it with the switch off for a withdrawn blessing too', () => { + // Not in any bundle any more, but it is the space's own record of a decision, and the row is + // where "this was withdrawn" is said at all. + expect(showsCommunity(view([], [bless(MEMBER, 'b1', commentRef, 'Withdrawn')]), false)).toBe(true) + }) + + it('drops it only when the switch is off and the space blessed nothing here', () => { + expect(showsCommunity(view([]), false)).toBe(false) + }) }) describe('who may, and what they write', () => { diff --git a/packages/ui/src/lib/guests.ts b/packages/ui/src/lib/guests.ts index 0709127..27aa10a 100644 --- a/packages/ui/src/lib/guests.ts +++ b/packages/ui/src/lib/guests.ts @@ -240,19 +240,60 @@ export interface CommunityRow { edited: boolean } -/** The blessings this space holds for a goal, live and withdrawn, as rows. */ +/** + * Whether the goal page draws the Community section at all. + * + * `setGuestComments` governs SOLICITATION: with it off, the page stops querying the backlink index + * and stops the composer inviting anybody. It is deliberately NOT revocation, and this is where the + * difference has teeth — a blessing already in the fold is in the bundle of every turn on this goal + * until a `retractBless` withdraws it. Hiding the section on the switch would take away the only + * screen that prose appears on and the only control that removes it, while agents went on reading + * it. So the section outlives the switch for as long as the space holds a blessing on this goal. + */ +export const showsCommunity = (view: GoalView, enabled: boolean): boolean => + enabled || view.blessedComments.length > 0 || view.retractedBlessings.length > 0 + +/** + * The blessings this space holds for a goal, live and withdrawn, as rows — one row per COMMENT, + * never one per blessing record. + * + * A comment can carry several blessings, and the section itself is what produces them: "Bless it + * again" on a withdrawn row writes a second `blessComment` beside the tombstoned first, and two + * members can independently bless the same words. So the row has to answer one question about the + * comment — is anything the space signed for it still live? — and a LIVE blessing always wins, + * because a live blessing is what the turn bundle carries. Letting a stale tombstone speak for a + * comment that is in every bundle right now would invert the one fact this section exists to report. + * + * Among several of the same kind the last wins: the fold orders blessings (createdAt, uri), so that + * is the space's most recent decision, and it is the one `Withdraw blessing` should name. + */ export function blessedRows(view: GoalView): CommunityRow[] { - const rows = (blessings: Array>, retracted: boolean) => - blessings.map((bless) => ({ - uri: bless.value.comment.uri, + const live = new Map>() + const withdrawn = new Map>() + for (const bless of view.blessedComments) live.set(bless.value.comment.uri, bless) + for (const bless of view.retractedBlessings) withdrawn.set(bless.value.comment.uri, bless) + const rows: CommunityRow[] = [] + for (const uri of new Set([...live.keys(), ...withdrawn.keys()])) { + const bless = live.get(uri) ?? withdrawn.get(uri) + if (!bless) continue + rows.push({ + uri, cid: bless.value.comment.cid, - did: didOf(bless.value.comment.uri), + did: didOf(uri), body: bless.value.body, bless, - retracted, + retracted: !live.has(uri), edited: false, - })) - return [...rows(view.blessedComments, false), ...rows(view.retractedBlessings, true)] + }) + } + // (createdAt, uri) over the BLESSING, since a row built from a snapshot has no guest timestamp of + // its own — enough to make the list stable, and `communityRows` re-sorts once discovery supplies + // the real ones. + return rows.sort( + (left, right) => + (left.bless?.value.createdAt ?? '').localeCompare(right.bless?.value.createdAt ?? '') || + left.uri.localeCompare(right.uri), + ) } /** diff --git a/packages/ui/src/routes/g/[did]/[rkey]/+page.svelte b/packages/ui/src/routes/g/[did]/[rkey]/+page.svelte index c38dc39..7484ef1 100644 --- a/packages/ui/src/routes/g/[did]/[rkey]/+page.svelte +++ b/packages/ui/src/routes/g/[did]/[rkey]/+page.svelte @@ -14,6 +14,7 @@ import UriChip from '$lib/components/UriChip.svelte' import Who from '$lib/components/Who.svelte' import { shortDate } from '$lib/format.js' + import { showsCommunity } from '$lib/guests.js' import { currentSpace } from '$lib/session.svelte.js' import type { ThreadMessage } from '$lib/replies.js' import { goalByRoute, unitsOf } from '$lib/space.js' @@ -34,6 +35,8 @@ const ending = $derived(goal ? endingBadge(goal) : undefined) /** Whether this space asks non-members for comments — an admin's `setGuestComments` overlay. */ const community = $derived(space.index.guestCommentsEnabled) + /** …and whether the section is drawn at all, which the switch alone does not decide. */ + const section = $derived(goal ? showsCommunity(goal, community) : false) /** The message the composer is answering, when Reply pointed it at one. */ let replyTo = $state(undefined) @@ -123,13 +126,14 @@ same record with one more field on it. --> (replyTo = message)} /> (replyTo = undefined)} /> - + + {#if section} + + {/if} {#if community} - {/if}