Let any member curate the space library
This commit is contained in:
parent
f727f2fcd1
commit
218afd48a5
12 changed files with 152 additions and 114 deletions
|
|
@ -57,12 +57,14 @@ plugin, read through `deriveRelaySignedEvents(url, filters)` in `src/app/reposit
|
||||||
`src/app/featured.ts` does, rather than a bare `deriveEventsForUrl`.
|
`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
|
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.
|
sign the event with its own key through the NIP-86 `signevent` method, then sends it back. Featured
|
||||||
Featured content (`setFeaturedContent` in `src/app/featured.ts`) and library shelves and pins
|
content (`setFeaturedContent` in `src/app/featured.ts`) is written this way, and only users the
|
||||||
(`PinboardEdit`, `PinAdd`, `PinEdit`, `PinMenu`, `BoardMenu`) are written this way, which is why the
|
relay allows to call `signevent` can write it.
|
||||||
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
|
The library is written by its members. A shelf or a pin is signed with the member's own key and
|
||||||
lists `signevent` among its supported methods.
|
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
|
## NIP-29 rooms
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`) |
|
| 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`) |
|
| 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`) |
|
| 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` |
|
| 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 |
|
| 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` |
|
| Bot commands (NIP-CD) | `COMMAND` (31992) | `Command` | `src/app/commands.ts` | `RoomCompose`, `ContentCommand` |
|
||||||
|
|
|
||||||
|
|
@ -847,11 +847,12 @@ Acceptance:
|
||||||
topics match.
|
topics match.
|
||||||
- Selecting a shelf shows its pins as a gallery; an empty shelf shows a message
|
- Selecting a shelf shows its pins as a gallery; an empty shelf shows a message
|
||||||
instead.
|
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
|
### 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.
|
good material first.
|
||||||
|
|
||||||
Acceptance:
|
Acceptance:
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,20 @@
|
||||||
import * as nip19 from "nostr-tools/nip19"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
import {DAY, HOUR, MINUTE, bech32ToHex, int} from "@welshman/lib"
|
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 type {SignedEvent} from "@welshman/util"
|
||||||
import {ClientMessageType} from "@welshman/net"
|
import {ClientMessageType} from "@welshman/net"
|
||||||
import {
|
import {
|
||||||
Comment,
|
Comment,
|
||||||
|
Pin,
|
||||||
|
Pinboard,
|
||||||
Poll,
|
Poll,
|
||||||
PollResponse,
|
PollResponse,
|
||||||
Profile,
|
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}) => {
|
test("US-053 browse and search the library", async ({seed, as}) => {
|
||||||
|
let gettingStarted!: Seeded
|
||||||
|
|
||||||
const scenario = await seed(({relay, user, at}) => {
|
const scenario = await seed(({relay, user, at}) => {
|
||||||
const space = relay("space")
|
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.admin, {name: "Ada Admin"})
|
||||||
space.profile(user.alice, {name: "Alice Anderson"})
|
space.profile(user.alice, {name: "Alice Anderson"})
|
||||||
space.message(user.admin, "general", "welcome to the space", at(2, HOUR))
|
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 {url} = scenario.space("space")
|
||||||
const libraryPath = `${spacePath(url)}/library`
|
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 alice = await as(users.alice, libraryPath)
|
||||||
const term = alice.getByPlaceholder("Search library...")
|
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()
|
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
|
// The library is the whole space's, so an ordinary member is offered the same controls the
|
||||||
// empty shelf where the admin is offered it directly.
|
// shelves were made with.
|
||||||
await expect(alice.getByRole("button", {name: "Create Shelf"})).toHaveCount(0)
|
await expect(alice.getByRole("button", {name: "Create Shelf"})).toBeVisible()
|
||||||
await expect(alice.getByRole("button", {name: "Add a link"})).toHaveCount(0)
|
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}) => {
|
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 {url} = scenario.space("space")
|
||||||
const libraryPath = `${spacePath(url)}/library`
|
const libraryPath = `${spacePath(url)}/library`
|
||||||
const pollPath = `${spacePath(url)}/polls/${poll.id}`
|
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
|
const pollCard = page
|
||||||
.locator(".card.z-feature")
|
.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.getByPlaceholder("What's this shelf about?").fill("Things worth reading")
|
||||||
await shelfForm.getByRole("button", {name: "Save changes"}).click()
|
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.getByRole("alert")).toContainText("Shelf created!")
|
||||||
await expect(page).toHaveURL(/[?&]board=/)
|
await expect(page).toHaveURL(/[?&]board=/)
|
||||||
await expect(page.getByRole("button", {name: /Reading List/})).toHaveAttribute(
|
await expect(page.getByRole("button", {name: /Reading List/})).toHaveAttribute(
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,7 @@
|
||||||
import EventInfo from "@app/components/EventInfo.svelte"
|
import EventInfo from "@app/components/EventInfo.svelte"
|
||||||
import PinAdd from "@app/components/PinAdd.svelte"
|
import PinAdd from "@app/components/PinAdd.svelte"
|
||||||
import PinboardEdit from "@app/components/PinboardEdit.svelte"
|
import PinboardEdit from "@app/components/PinboardEdit.svelte"
|
||||||
import {deletes} from "@app/core"
|
import {deletes, relays, user} from "@app/core"
|
||||||
import {deriveUserIsSpaceAdmin} from "@app/management"
|
|
||||||
import {shareEvent} from "@app/share"
|
import {shareEvent} from "@app/share"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
@ -27,8 +26,6 @@
|
||||||
|
|
||||||
const {url, board, onClick}: Props = $props()
|
const {url, board, onClick}: Props = $props()
|
||||||
|
|
||||||
const canManage = deriveUserIsSpaceAdmin(url)
|
|
||||||
|
|
||||||
const showInfo = () => pushModal(EventInfo, {url, event: board.event})
|
const showInfo = () => pushModal(EventInfo, {url, event: board.event})
|
||||||
|
|
||||||
const share = () => shareEvent(url, "Shelf", board.event)
|
const share = () => shareEvent(url, "Shelf", board.event)
|
||||||
|
|
@ -39,8 +36,11 @@
|
||||||
|
|
||||||
const deleteBoard = async () => {
|
const deleteBoard = async () => {
|
||||||
try {
|
try {
|
||||||
const command = await $deletes.deleteEvent(board.event)
|
const protect = await $relays.hasNip(url, 70)
|
||||||
const error = await command.publishAsRelay(url).then(thunk => thunk.waitForError())
|
const command = await $deletes.deleteEvent(board.event, writer =>
|
||||||
|
writer.setProtected(protect),
|
||||||
|
)
|
||||||
|
const error = await command.publishToRelays([url]).waitForError()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
pushToast({theme: "error", message: error})
|
pushToast({theme: "error", message: error})
|
||||||
|
|
@ -80,13 +80,13 @@
|
||||||
Share to chat
|
Share to chat
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
{#if $canManage}
|
<li>
|
||||||
<li>
|
<Button onclick={addLink}>
|
||||||
<Button onclick={addLink}>
|
<Icon icon={AddCircle} />
|
||||||
<Icon icon={AddCircle} />
|
Add link
|
||||||
Add link
|
</Button>
|
||||||
</Button>
|
</li>
|
||||||
</li>
|
{#if board.event.pubkey === $user.pubkey}
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={edit}>
|
<Button onclick={edit}>
|
||||||
<Icon icon={Pen} />
|
<Icon icon={Pen} />
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {relay} from "@welshman/util"
|
||||||
import {Pin} from "@welshman/domain"
|
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 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"
|
import {setPinReference} from "@app/pinboards"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -14,7 +15,11 @@
|
||||||
const {url, address, reference}: Props = $props()
|
const {url, address, reference}: Props = $props()
|
||||||
|
|
||||||
const submit = async ({title, topics, value, content}: PinFormValues) => {
|
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)) {
|
if (!setPinReference(eventWriter, value)) {
|
||||||
return "Please enter a valid URL or nostr link."
|
return "Please enter a valid URL or nostr link."
|
||||||
|
|
@ -22,7 +27,7 @@
|
||||||
|
|
||||||
eventWriter.setTitle(title).setTopics(topics).setContent(content)
|
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()
|
return thunk.waitForError()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {relay} from "@welshman/util"
|
||||||
import {Pin} from "@welshman/domain"
|
import {Pin} from "@welshman/domain"
|
||||||
import {publishAsRelay} from "@welshman/app"
|
import {publish} from "@welshman/app"
|
||||||
import type {PinReader} from "@welshman/domain"
|
import type {PinReader} from "@welshman/domain"
|
||||||
import PinForm, {type PinFormValues} from "@app/components/PinForm.svelte"
|
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"
|
import {pinToReference, setPinReference} from "@app/pinboards"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -15,6 +16,8 @@
|
||||||
|
|
||||||
const submit = async ({title, topics, value, content}: PinFormValues) => {
|
const submit = async ({title, topics, value, content}: PinFormValues) => {
|
||||||
const eventWriter = writer(Pin, pin)
|
const eventWriter = writer(Pin, pin)
|
||||||
|
.setProtected(await $relays.hasNip(url, 70))
|
||||||
|
.forceRoutes(relay(url))
|
||||||
|
|
||||||
if (!setPinReference(eventWriter, value)) {
|
if (!setPinReference(eventWriter, value)) {
|
||||||
return "Please enter a valid URL or nostr link."
|
return "Please enter a valid URL or nostr link."
|
||||||
|
|
@ -22,7 +25,7 @@
|
||||||
|
|
||||||
eventWriter.setTitle(title).setTopics(topics).setContent(content)
|
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()
|
return thunk.waitForError()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,7 @@
|
||||||
import Confirm from "@lib/components/Confirm.svelte"
|
import Confirm from "@lib/components/Confirm.svelte"
|
||||||
import EventInfo from "@app/components/EventInfo.svelte"
|
import EventInfo from "@app/components/EventInfo.svelte"
|
||||||
import PinEdit from "@app/components/PinEdit.svelte"
|
import PinEdit from "@app/components/PinEdit.svelte"
|
||||||
import {deletes} from "@app/core"
|
import {deletes, relays, user} from "@app/core"
|
||||||
import {deriveUserIsSpaceAdmin} from "@app/management"
|
|
||||||
import {shareEvent} from "@app/share"
|
import {shareEvent} from "@app/share"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
@ -25,8 +24,6 @@
|
||||||
|
|
||||||
const {url, pin, onClick}: Props = $props()
|
const {url, pin, onClick}: Props = $props()
|
||||||
|
|
||||||
const canManage = deriveUserIsSpaceAdmin(url)
|
|
||||||
|
|
||||||
const showInfo = () => pushModal(EventInfo, {url, event: pin.event})
|
const showInfo = () => pushModal(EventInfo, {url, event: pin.event})
|
||||||
|
|
||||||
const share = () => shareEvent(url, "Link", pin.event)
|
const share = () => shareEvent(url, "Link", pin.event)
|
||||||
|
|
@ -35,8 +32,9 @@
|
||||||
|
|
||||||
const deletePin = async () => {
|
const deletePin = async () => {
|
||||||
try {
|
try {
|
||||||
const command = await $deletes.deleteEvent(pin.event)
|
const protect = await $relays.hasNip(url, 70)
|
||||||
const error = await command.publishAsRelay(url).then(thunk => thunk.waitForError())
|
const command = await $deletes.deleteEvent(pin.event, writer => writer.setProtected(protect))
|
||||||
|
const error = await command.publishToRelays([url]).waitForError()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
pushToast({theme: "error", message: error})
|
pushToast({theme: "error", message: error})
|
||||||
|
|
@ -76,7 +74,7 @@
|
||||||
Share to chat
|
Share to chat
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
{#if $canManage}
|
{#if pin.event.pubkey === $user.pubkey}
|
||||||
<li>
|
<li>
|
||||||
<Button onclick={edit}>
|
<Button onclick={edit}>
|
||||||
<Icon icon={Pen} />
|
<Icon icon={Pen} />
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {navigate} from "@app/modal"
|
import {navigate} from "@app/modal"
|
||||||
import {getAddress} from "@welshman/util"
|
import {getAddress, relay} from "@welshman/util"
|
||||||
import {publishAsRelay} from "@welshman/app"
|
import {publish} from "@welshman/app"
|
||||||
import {Pinboard} from "@welshman/domain"
|
import {Pinboard} from "@welshman/domain"
|
||||||
import type {PinboardReader} from "@welshman/domain"
|
import type {PinboardReader} from "@welshman/domain"
|
||||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
import RelayName from "@app/components/RelayName.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 {makeLibraryPath} from "@app/routes"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
|
|
@ -33,13 +33,17 @@
|
||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
try {
|
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) {
|
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()
|
const error = await thunk.waitForError()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {derived} from "svelte/store"
|
||||||
|
import {PINBOARD} from "@welshman/util"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {Pinboard} from "@welshman/domain"
|
||||||
import type {PinboardReader} 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 AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
|
@ -13,8 +15,9 @@
|
||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
import RelayName from "@app/components/RelayName.svelte"
|
import RelayName from "@app/components/RelayName.svelte"
|
||||||
import PinAdd from "@app/components/PinAdd.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 {eventToReference} from "@app/pinboards"
|
||||||
|
import {deriveEventsForUrl} from "@app/repository"
|
||||||
import {makeSpacePath} from "@app/routes"
|
import {makeSpacePath} from "@app/routes"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
|
|
@ -25,9 +28,9 @@
|
||||||
|
|
||||||
const {url, event}: Props = $props()
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
const relay = $relays.one(url)
|
const boards = derived(deriveEventsForUrl(url, [{kinds: [PINBOARD]}]), $events =>
|
||||||
|
$events.map(reader(Pinboard)),
|
||||||
const boards = $derived($app.use(Pinboards).forAuthor($relay?.self ?? "").$)
|
)
|
||||||
|
|
||||||
const reference = eventToReference(event)
|
const reference = eventToReference(event)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@
|
||||||
import {relays} from "@app/core"
|
import {relays} from "@app/core"
|
||||||
import {ENABLE_ZAPS} from "@app/env"
|
import {ENABLE_ZAPS} from "@app/env"
|
||||||
import {CONTENT_KINDS} from "@app/content"
|
import {CONTENT_KINDS} from "@app/content"
|
||||||
import {deriveSpaceSupportedMethods} from "@app/management"
|
|
||||||
import {deriveEventsForUrl} from "@app/repository"
|
import {deriveEventsForUrl} from "@app/repository"
|
||||||
import {makeSpacePath} from "@app/routes"
|
import {makeSpacePath} from "@app/routes"
|
||||||
import {allNotifications, notifications} from "@app/notifications"
|
import {allNotifications, notifications} from "@app/notifications"
|
||||||
|
|
@ -59,10 +58,6 @@
|
||||||
$spaceKinds.has(kind) || $allNotifications.has(path) || $page.url.pathname.startsWith(path)
|
$spaceKinds.has(kind) || $allNotifications.has(path) || $page.url.pathname.startsWith(path)
|
||||||
|
|
||||||
const hasNip29 = $derived($relay?.hasNip(29) ?? false)
|
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})
|
const openSearch = () => pushModal(SpaceSearch, {url})
|
||||||
|
|
||||||
|
|
@ -80,7 +75,7 @@
|
||||||
<SecondaryNavItem href={makeSpacePath(url, "directory")}>
|
<SecondaryNavItem href={makeSpacePath(url, "directory")}>
|
||||||
<Icon icon={UsersGroup} /> Directory
|
<Icon icon={UsersGroup} /> Directory
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
{#if showLibrary}
|
{#if showSection(PINBOARD, libraryPath)}
|
||||||
<SecondaryNavItem href={libraryPath} notification={$notifications.has(libraryPath)}>
|
<SecondaryNavItem href={libraryPath} notification={$notifications.has(libraryPath)}>
|
||||||
<Icon icon={GalleryWide} /> Library
|
<Icon icon={GalleryWide} /> Library
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {goto} from "$app/navigation"
|
import {goto} from "$app/navigation"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
|
import {derived} from "svelte/store"
|
||||||
import {PIN, PINBOARD} from "@welshman/util"
|
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 type {PinboardReader} from "@welshman/domain"
|
||||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||||
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
|
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
|
||||||
|
|
@ -19,9 +21,9 @@
|
||||||
import PinAdd from "@app/components/PinAdd.svelte"
|
import PinAdd from "@app/components/PinAdd.svelte"
|
||||||
import PinboardItem from "@app/components/PinboardItem.svelte"
|
import PinboardItem from "@app/components/PinboardItem.svelte"
|
||||||
import PinboardEdit from "@app/components/PinboardEdit.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 {decodeRelay} from "@app/relays"
|
||||||
import {deriveUserIsSpaceAdmin} from "@app/management"
|
import {deriveEventsForUrl} from "@app/repository"
|
||||||
import {makeLibraryPath} from "@app/routes"
|
import {makeLibraryPath} from "@app/routes"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import type {PageProps} from "./$types"
|
import type {PageProps} from "./$types"
|
||||||
|
|
@ -30,16 +32,14 @@
|
||||||
|
|
||||||
const url = decodeRelay(params.relay)
|
const url = decodeRelay(params.relay)
|
||||||
|
|
||||||
const relay = $relays.one(url)
|
|
||||||
|
|
||||||
const canManage = deriveUserIsSpaceAdmin(url)
|
|
||||||
|
|
||||||
let term = $state("")
|
let term = $state("")
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
|
|
||||||
// Shelves are signed by the relay itself, so the relay's own pubkey scopes them
|
// A shelf belongs to the space it was published to, so the relay it was seen on
|
||||||
// to this space.
|
// scopes it rather than the pubkey that signed it.
|
||||||
const boards = $derived($app.use(Pinboards).forAuthor($relay?.self ?? "").$)
|
const boards = derived(deriveEventsForUrl(url, [{kinds: [PINBOARD]}]), $events =>
|
||||||
|
$events.map(reader(Pinboard)),
|
||||||
|
)
|
||||||
|
|
||||||
const address = $derived($page.url.searchParams.get("board") ?? "")
|
const address = $derived($page.url.searchParams.get("board") ?? "")
|
||||||
|
|
||||||
|
|
@ -89,12 +89,10 @@
|
||||||
<strong>Library</strong>
|
<strong>Library</strong>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet action()}
|
{#snippet action()}
|
||||||
{#if $canManage}
|
<Button class="button button-primary button-sm" onclick={createBoard}>
|
||||||
<Button class="button button-primary button-sm" onclick={createBoard}>
|
<Icon icon={Add} />
|
||||||
<Icon icon={Add} />
|
Create Shelf
|
||||||
Create Shelf
|
</Button>
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
||||||
|
|
@ -131,12 +129,10 @@
|
||||||
{:else if selected}
|
{:else if selected}
|
||||||
<div class="flex flex-col items-center gap-4 py-20 text-center">
|
<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>
|
<p class="opacity-70">This shelf doesn't have any links yet.</p>
|
||||||
{#if $canManage}
|
<Button class="button button-primary" onclick={addLink}>
|
||||||
<Button class="button button-primary" onclick={addLink}>
|
<Icon icon={AddCircle} />
|
||||||
<Icon icon={AddCircle} />
|
Add a link
|
||||||
Add a link
|
</Button>
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="py-20 text-center opacity-70">Select a shelf to see what's on it.</p>
|
<p class="py-20 text-center opacity-70">Select a shelf to see what's on it.</p>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue