Let any member curate the space library

This commit is contained in:
Coracle-Bot 2026-09-17 15:42:56 +00:00
parent f727f2fcd1
commit 218afd48a5
12 changed files with 152 additions and 114 deletions

View file

@ -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

View file

@ -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` |

View file

@ -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:

View file

@ -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(

View file

@ -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
</Button>
</li>
{#if $canManage}
<li>
<Button onclick={addLink}>
<Icon icon={AddCircle} />
Add link
</Button>
</li>
<li>
<Button onclick={addLink}>
<Icon icon={AddCircle} />
Add link
</Button>
</li>
{#if board.event.pubkey === $user.pubkey}
<li>
<Button onclick={edit}>
<Icon icon={Pen} />

View file

@ -1,8 +1,9 @@
<script lang="ts">
import {relay} from "@welshman/util"
import {Pin} from "@welshman/domain"
import {publishAsRelay} from "@welshman/app"
import {publish} from "@welshman/app"
import PinForm, {type PinFormValues} from "@app/components/PinForm.svelte"
import {command, writer} from "@app/core"
import {command, relays, writer} from "@app/core"
import {setPinReference} from "@app/pinboards"
type Props = {
@ -14,7 +15,11 @@
const {url, address, reference}: Props = $props()
const submit = async ({title, topics, value, content}: PinFormValues) => {
const eventWriter = writer(Pin).setIdentifier().addBoard(address)
const eventWriter = writer(Pin)
.setIdentifier()
.addBoard(address)
.setProtected(await $relays.hasNip(url, 70))
.forceRoutes(relay(url))
if (!setPinReference(eventWriter, value)) {
return "Please enter a valid URL or nostr link."
@ -22,7 +27,7 @@
eventWriter.setTitle(title).setTopics(topics).setContent(content)
const thunk = await command(eventWriter).then(publishAsRelay(url))
const thunk = await command(eventWriter).then(publish)
return thunk.waitForError()
}

View file

@ -1,9 +1,10 @@
<script lang="ts">
import {relay} from "@welshman/util"
import {Pin} from "@welshman/domain"
import {publishAsRelay} from "@welshman/app"
import {publish} from "@welshman/app"
import type {PinReader} from "@welshman/domain"
import PinForm, {type PinFormValues} from "@app/components/PinForm.svelte"
import {command, writer} from "@app/core"
import {command, relays, writer} from "@app/core"
import {pinToReference, setPinReference} from "@app/pinboards"
type Props = {
@ -15,6 +16,8 @@
const submit = async ({title, topics, value, content}: PinFormValues) => {
const eventWriter = writer(Pin, pin)
.setProtected(await $relays.hasNip(url, 70))
.forceRoutes(relay(url))
if (!setPinReference(eventWriter, value)) {
return "Please enter a valid URL or nostr link."
@ -22,7 +25,7 @@
eventWriter.setTitle(title).setTopics(topics).setContent(content)
const thunk = await command(eventWriter).then(publishAsRelay(url))
const thunk = await command(eventWriter).then(publish)
return thunk.waitForError()
}

View file

@ -11,8 +11,7 @@
import Confirm from "@lib/components/Confirm.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import PinEdit from "@app/components/PinEdit.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"
@ -25,8 +24,6 @@
const {url, pin, onClick}: Props = $props()
const canManage = deriveUserIsSpaceAdmin(url)
const showInfo = () => pushModal(EventInfo, {url, event: pin.event})
const share = () => shareEvent(url, "Link", pin.event)
@ -35,8 +32,9 @@
const deletePin = async () => {
try {
const command = await $deletes.deleteEvent(pin.event)
const error = await command.publishAsRelay(url).then(thunk => thunk.waitForError())
const protect = await $relays.hasNip(url, 70)
const command = await $deletes.deleteEvent(pin.event, writer => writer.setProtected(protect))
const error = await command.publishToRelays([url]).waitForError()
if (error) {
pushToast({theme: "error", message: error})
@ -76,7 +74,7 @@
Share to chat
</Button>
</li>
{#if $canManage}
{#if pin.event.pubkey === $user.pubkey}
<li>
<Button onclick={edit}>
<Icon icon={Pen} />

View file

@ -1,7 +1,7 @@
<script lang="ts">
import {navigate} from "@app/modal"
import {getAddress} from "@welshman/util"
import {publishAsRelay} from "@welshman/app"
import {getAddress, relay} from "@welshman/util"
import {publish} from "@welshman/app"
import {Pinboard} from "@welshman/domain"
import type {PinboardReader} from "@welshman/domain"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
@ -16,7 +16,7 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import RelayName from "@app/components/RelayName.svelte"
import {command, writer} from "@app/core"
import {command, relays, writer} from "@app/core"
import {makeLibraryPath} from "@app/routes"
import {pushToast} from "@app/toast"
@ -33,13 +33,17 @@
loading = true
try {
const eventWriter = writer(Pinboard, board).setTitle(title).setDescription(description)
const eventWriter = writer(Pinboard, board)
.setTitle(title)
.setDescription(description)
.setProtected(await $relays.hasNip(url, 70))
.forceRoutes(relay(url))
if (!board) {
eventWriter.setIdentifier()
eventWriter.setIdentifier().setCollaborative(true)
}
const thunk = await command(eventWriter).then(publishAsRelay(url))
const thunk = await command(eventWriter).then(publish)
const error = await thunk.waitForError()
if (error) {

View file

@ -1,7 +1,9 @@
<script lang="ts">
import {derived} from "svelte/store"
import {PINBOARD} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Pinboard} from "@welshman/domain"
import type {PinboardReader} from "@welshman/domain"
import {Pinboards} from "@welshman/app"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@ -13,8 +15,9 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import RelayName from "@app/components/RelayName.svelte"
import PinAdd from "@app/components/PinAdd.svelte"
import {app, relays} from "@app/core"
import {reader} from "@app/core"
import {eventToReference} from "@app/pinboards"
import {deriveEventsForUrl} from "@app/repository"
import {makeSpacePath} from "@app/routes"
import {pushModal} from "@app/modal"
@ -25,9 +28,9 @@
const {url, event}: Props = $props()
const relay = $relays.one(url)
const boards = $derived($app.use(Pinboards).forAuthor($relay?.self ?? "").$)
const boards = derived(deriveEventsForUrl(url, [{kinds: [PINBOARD]}]), $events =>
$events.map(reader(Pinboard)),
)
const reference = eventToReference(event)

View file

@ -20,7 +20,6 @@
import {relays} from "@app/core"
import {ENABLE_ZAPS} from "@app/env"
import {CONTENT_KINDS} from "@app/content"
import {deriveSpaceSupportedMethods} from "@app/management"
import {deriveEventsForUrl} from "@app/repository"
import {makeSpacePath} from "@app/routes"
import {allNotifications, notifications} from "@app/notifications"
@ -59,10 +58,6 @@
$spaceKinds.has(kind) || $allNotifications.has(path) || $page.url.pathname.startsWith(path)
const hasNip29 = $derived($relay?.hasNip(29) ?? false)
const supportedMethods = deriveSpaceSupportedMethods(url)
const showLibrary = $derived(
showSection(PINBOARD, libraryPath) || $supportedMethods.includes("signevent"),
)
const openSearch = () => pushModal(SpaceSearch, {url})
@ -80,7 +75,7 @@
<SecondaryNavItem href={makeSpacePath(url, "directory")}>
<Icon icon={UsersGroup} /> Directory
</SecondaryNavItem>
{#if showLibrary}
{#if showSection(PINBOARD, libraryPath)}
<SecondaryNavItem href={libraryPath} notification={$notifications.has(libraryPath)}>
<Icon icon={GalleryWide} /> Library
</SecondaryNavItem>

View file

@ -2,8 +2,10 @@
import {onMount} from "svelte"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import {derived} from "svelte/store"
import {PIN, PINBOARD} from "@welshman/util"
import {Pinboards, Pins} from "@welshman/app"
import {Pins} from "@welshman/app"
import {Pinboard} from "@welshman/domain"
import type {PinboardReader} from "@welshman/domain"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
@ -19,9 +21,9 @@
import PinAdd from "@app/components/PinAdd.svelte"
import PinboardItem from "@app/components/PinboardItem.svelte"
import PinboardEdit from "@app/components/PinboardEdit.svelte"
import {app, network, relays} from "@app/core"
import {app, network, reader} from "@app/core"
import {decodeRelay} from "@app/relays"
import {deriveUserIsSpaceAdmin} from "@app/management"
import {deriveEventsForUrl} from "@app/repository"
import {makeLibraryPath} from "@app/routes"
import {pushModal} from "@app/modal"
import type {PageProps} from "./$types"
@ -30,16 +32,14 @@
const url = decodeRelay(params.relay)
const relay = $relays.one(url)
const canManage = deriveUserIsSpaceAdmin(url)
let term = $state("")
let loading = $state(true)
// Shelves are signed by the relay itself, so the relay's own pubkey scopes them
// to this space.
const boards = $derived($app.use(Pinboards).forAuthor($relay?.self ?? "").$)
// A shelf belongs to the space it was published to, so the relay it was seen on
// scopes it rather than the pubkey that signed it.
const boards = derived(deriveEventsForUrl(url, [{kinds: [PINBOARD]}]), $events =>
$events.map(reader(Pinboard)),
)
const address = $derived($page.url.searchParams.get("board") ?? "")
@ -89,12 +89,10 @@
<strong>Library</strong>
{/snippet}
{#snippet action()}
{#if $canManage}
<Button class="button button-primary button-sm" onclick={createBoard}>
<Icon icon={Add} />
Create Shelf
</Button>
{/if}
<Button class="button button-primary button-sm" onclick={createBoard}>
<Icon icon={Add} />
Create Shelf
</Button>
{/snippet}
</SpaceBar>
@ -131,12 +129,10 @@
{:else if selected}
<div class="flex flex-col items-center gap-4 py-20 text-center">
<p class="opacity-70">This shelf doesn't have any links yet.</p>
{#if $canManage}
<Button class="button button-primary" onclick={addLink}>
<Icon icon={AddCircle} />
Add a link
</Button>
{/if}
<Button class="button button-primary" onclick={addLink}>
<Icon icon={AddCircle} />
Add a link
</Button>
</div>
{:else}
<p class="py-20 text-center opacity-70">Select a shelf to see what's on it.</p>