diff --git a/.agents/skills/flotilla-model/SKILL.md b/.agents/skills/flotilla-model/SKILL.md index 96dfc941..593e7841 100644 --- a/.agents/skills/flotilla-model/SKILL.md +++ b/.agents/skills/flotilla-model/SKILL.md @@ -57,12 +57,14 @@ plugin, read through `deriveRelaySignedEvents(url, filters)` in `src/app/reposit `src/app/featured.ts` does, rather than a bare `deriveEventsForUrl`. Content the space owns is published as the relay: `command.publishAsRelay(url)` has the relay -sign the event with its own key through the NIP-86 `signevent` method, then sends it back. -Featured content (`setFeaturedContent` in `src/app/featured.ts`) and library shelves and pins -(`PinboardEdit`, `PinAdd`, `PinEdit`, `PinMenu`, `BoardMenu`) are written this way, which is why the -library lists boards with `Pinboards.forAuthor($relay.self)`. Only users the relay allows to call -`signevent` can do it, so `SpaceMenuNavItems` shows the library when the space already has boards or -lists `signevent` among its supported methods. +sign the event with its own key through the NIP-86 `signevent` method, then sends it back. Featured +content (`setFeaturedContent` in `src/app/featured.ts`) is written this way, and only users the +relay allows to call `signevent` can write it. + +The library is written by its members. A shelf or a pin is signed with the member's own key and +published to the space like any other space content, so the library reads every `PINBOARD` seen on +the relay rather than only the relay's own. Whoever signed a shelf is the only one who can edit or +delete it, and anyone can pin to it. ## NIP-29 rooms diff --git a/.agents/skills/flotilla-model/kinds.md b/.agents/skills/flotilla-model/kinds.md index cc850a04..ffa97031 100644 --- a/.agents/skills/flotilla-model/kinds.md +++ b/.agents/skills/flotilla-model/kinds.md @@ -16,7 +16,7 @@ and NIP-43 tables in [SKILL.md](SKILL.md). Routes are under `src/routes/`. | Classifieds | `CLASSIFIED` (30402) | `Classified` | `src/app/classifieds.ts` | `spaces/[relay]/classifieds`, `classifieds/[address]` (`ClassifiedForm`) | | Goals | `ZAP_GOAL` (9041) | `ZapGoal` | none | `spaces/[relay]/goals`, `goals/[id]` (`GoalCreate`) | | Polls | `POLL` (1068), `POLL_RESPONSE` (1018) | `Poll`, `PollResponse` | none | `spaces/[relay]/polls`, `polls/[id]` (`PollCreate`, `PollVotes`) | -| Library | `PINBOARD` (30067), `PIN` (39067) | `Pinboard`, `Pin` | `src/app/pinboards.ts` | `spaces/[relay]/library` (`PinboardEdit`, `PinAdd`); published as the relay | +| Library | `PINBOARD` (30067), `PIN` (39067) | `Pinboard`, `Pin` | `src/app/pinboards.ts` | `spaces/[relay]/library` (`PinboardEdit`, `PinAdd`); published by any member | | Room pins | `ROOM_PINS` (39005), `ROOM_UPDATE_PINS` (9010) | `RoomPins`, `RoomUpdatePins` | `src/app/roomPins.ts` | `RoomItemMenu`, `RoomPinnedMessagesAll` | | Featured content | `APP_DATA` (30078), `d` = `flotilla/featured-content` | `AppData` | `src/app/featured.ts` | `SpaceFeaturedContent`; published as the relay | | Bot commands (NIP-CD) | `COMMAND` (31992) | `Command` | `src/app/commands.ts` | `RoomCompose`, `ContentCommand` | diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index 0b4f8575..8da7a707 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -847,11 +847,12 @@ Acceptance: topics match. - Selecting a shelf shows its pins as a gallery; an empty shelf shows a message instead. -- As a non-admin, alice sees no "Create Shelf" or "Add a link" controls. +- Alice is offered "Create Shelf" and "Add a link", and "Add link" on someone + else's shelf, but not that shelf's "Edit shelf" or "Delete shelf". ### US-054 — Curate the library -As admin, I want to organize shelves and the links on them, so that members find +As bob, I want to organize shelves and the links on them, so that members find good material first. Acceptance: diff --git a/e2e/specs/community.spec.ts b/e2e/specs/community.spec.ts index 7560369d..c3fff7b3 100644 --- a/e2e/specs/community.spec.ts +++ b/e2e/specs/community.spec.ts @@ -1,10 +1,20 @@ import * as nip19 from "nostr-tools/nip19" import {DAY, HOUR, MINUTE, bech32ToHex, int} from "@welshman/lib" -import {MESSAGE, POLL_RESPONSE, getLnUrl, tagSpec, tagValues, toMsats} from "@welshman/util" +import { + MESSAGE, + POLL_RESPONSE, + getAddress, + getLnUrl, + tagSpec, + tagValues, + toMsats, +} from "@welshman/util" import type {SignedEvent} from "@welshman/util" import {ClientMessageType} from "@welshman/net" import { Comment, + Pin, + Pinboard, Poll, PollResponse, Profile, @@ -836,6 +846,8 @@ test("US-052 comment on and react to community posts", async ({seed, as}) => { }) test("US-053 browse and search the library", async ({seed, as}) => { + let gettingStarted!: Seeded + const scenario = await seed(({relay, user, at}) => { const space = relay("space") @@ -845,43 +857,53 @@ test("US-053 browse and search the library", async ({seed, as}) => { space.profile(user.admin, {name: "Ada Admin"}) space.profile(user.alice, {name: "Alice Anderson"}) space.message(user.admin, "general", "welcome to the space", at(2, HOUR)) + + gettingStarted = space.event( + user.admin, + () => + space + .kind(Pinboard) + .writer() + .setIdentifier() + .setTitle("Getting Started") + .setDescription("Reading for new members") + .setCollaborative(true) + .renderTemplate(), + at(2, HOUR), + ) + + space.event( + user.admin, + () => + space + .kind(Pinboard) + .writer() + .setIdentifier() + .setTitle("Recipes") + .setDescription("Food and drink from the kitchen") + .setCollaborative(true) + .renderTemplate(), + at(2, HOUR), + ) + + space.event( + user.admin, + () => + space + .kind(Pin) + .writer() + .setIdentifier() + .addBoard(getAddress(gettingStarted.event)) + .setTitle("The Handbook") + .setExternal("https://handbook.test/start-here") + .renderTemplate(), + at(1, HOUR), + ) }) const {url} = scenario.space("space") const libraryPath = `${spacePath(url)}/library` - // A shelf is signed by the relay itself, so nothing this process holds a key for can seed one - // and the only way to stand one up is through the admin's own ui. - const admin = await as(users.admin, libraryPath) - - const createShelf = async (title: string, description: string) => { - await admin.getByRole("button", {name: "Create Shelf"}).click() - - const form = dialog(admin, "Create Shelf") - - await form.getByPlaceholder("Shelf title").fill(title) - await form.getByPlaceholder("What's this shelf about?").fill(description) - await form.getByRole("button", {name: "Save changes"}).click() - await expect(admin.getByRole("alert")).toContainText("Shelf created!") - } - - await createShelf("Getting Started", "Reading for new members") - await createShelf("Recipes", "Food and drink from the kitchen") - - await expect(admin.getByRole("button", {name: /Getting Started/})).toBeVisible() - await expect(admin.getByRole("button", {name: /Recipes/})).toBeVisible() - - await shelfCard(admin, "Getting Started").getByRole("button", {name: "More options"}).click() - await admin.getByRole("button", {name: "Add link", exact: true}).click() - - const linkForm = dialog(admin, "Add Link") - - await linkForm.getByPlaceholder("URL or nevent...").fill("https://handbook.test/start-here") - await linkForm.getByPlaceholder("Optional title").fill("The Handbook") - await linkForm.getByRole("button", {name: "Add link"}).click() - - await expect(admin.getByRole("alert")).toContainText("Link added!") - const alice = await as(users.alice, libraryPath) const term = alice.getByPlaceholder("Search library...") @@ -910,10 +932,17 @@ test("US-053 browse and search the library", async ({seed, as}) => { await expect(alice.getByText("This shelf doesn't have any links yet.")).toBeVisible() - // Curating is the admin's, so none of it is offered to an ordinary member — not even on the - // empty shelf where the admin is offered it directly. - await expect(alice.getByRole("button", {name: "Create Shelf"})).toHaveCount(0) - await expect(alice.getByRole("button", {name: "Add a link"})).toHaveCount(0) + // The library is the whole space's, so an ordinary member is offered the same controls the + // shelves were made with. + await expect(alice.getByRole("button", {name: "Create Shelf"})).toBeVisible() + await expect(alice.getByRole("button", {name: "Add a link"})).toBeVisible() + + // Someone else's shelf is hers to add to and not to rewrite. + await shelfCard(alice, "Recipes").getByRole("button", {name: "More options"}).click() + + await expect(alice.getByRole("button", {name: "Add link", exact: true})).toBeVisible() + await expect(alice.getByRole("button", {name: "Edit shelf"})).toHaveCount(0) + await expect(alice.getByRole("button", {name: "Delete shelf"})).toHaveCount(0) }) test("US-054 curate the library", async ({seed, as}) => { @@ -950,7 +979,9 @@ test("US-054 curate the library", async ({seed, as}) => { const {url} = scenario.space("space") const libraryPath = `${spacePath(url)}/library` const pollPath = `${spacePath(url)}/polls/${poll.id}` - const page = await as(users.admin, pollPath) + + // Curating is nobody's privilege here, so this is an ordinary member doing all of it. + const page = await as(users.bob, pollPath) const pollCard = page .locator(".card.z-feature") @@ -973,7 +1004,7 @@ test("US-054 curate the library", async ({seed, as}) => { await shelfForm.getByPlaceholder("What's this shelf about?").fill("Things worth reading") await shelfForm.getByRole("button", {name: "Save changes"}).click() - // Creating a shelf lists it and drops the admin straight into it. + // Creating a shelf lists it and drops its author straight into it. await expect(page.getByRole("alert")).toContainText("Shelf created!") await expect(page).toHaveURL(/[?&]board=/) await expect(page.getByRole("button", {name: /Reading List/})).toHaveAttribute( diff --git a/src/app/components/BoardMenu.svelte b/src/app/components/BoardMenu.svelte index f9d84b5f..be90f00c 100644 --- a/src/app/components/BoardMenu.svelte +++ b/src/app/components/BoardMenu.svelte @@ -13,8 +13,7 @@ import EventInfo from "@app/components/EventInfo.svelte" import PinAdd from "@app/components/PinAdd.svelte" import PinboardEdit from "@app/components/PinboardEdit.svelte" - import {deletes} from "@app/core" - import {deriveUserIsSpaceAdmin} from "@app/management" + import {deletes, relays, user} from "@app/core" import {shareEvent} from "@app/share" import {pushModal} from "@app/modal" import {pushToast} from "@app/toast" @@ -27,8 +26,6 @@ const {url, board, onClick}: Props = $props() - const canManage = deriveUserIsSpaceAdmin(url) - const showInfo = () => pushModal(EventInfo, {url, event: board.event}) const share = () => shareEvent(url, "Shelf", board.event) @@ -39,8 +36,11 @@ const deleteBoard = async () => { try { - const command = await $deletes.deleteEvent(board.event) - const error = await command.publishAsRelay(url).then(thunk => thunk.waitForError()) + const protect = await $relays.hasNip(url, 70) + const command = await $deletes.deleteEvent(board.event, writer => + writer.setProtected(protect), + ) + const error = await command.publishToRelays([url]).waitForError() if (error) { pushToast({theme: "error", message: error}) @@ -80,13 +80,13 @@ Share to chat - {#if $canManage} -