From 71cd7fbe5520fd99745c613d8de912c20749035e Mon Sep 17 00:00:00 2001 From: Coracle-Bot Date: Wed, 30 Sep 2026 17:21:48 +0000 Subject: [PATCH] Redesign the content library (#677) --- e2e/USER_STORIES.md | 70 ++- e2e/harness/app/boot.ts | 5 +- e2e/harness/app/nip07.ts | 3 +- e2e/harness/app/session.ts | 8 +- e2e/harness/index.ts | 3 +- e2e/specs/community.spec.ts | 406 +++++++++++++++--- src/app/components/BoardMenu.svelte | 53 +-- src/app/components/CommentCompose.svelte | 6 +- src/app/components/ContentLinkBlock.svelte | 5 +- src/app/components/EventComments.svelte | 5 +- src/app/components/ModalContainer.svelte | 26 +- src/app/components/PinContent.svelte | 31 ++ src/app/components/PinContentEvent.svelte | 9 +- src/app/components/PinDetail.svelte | 99 +++++ src/app/components/PinItem.svelte | 125 +++--- src/app/components/PinList.svelte | 184 ++++++++ src/app/components/PinMenu.svelte | 84 ---- src/app/components/PinReference.svelte | 32 ++ src/app/components/PinSave.svelte | 164 +++++-- src/app/components/PinSelectionMenu.svelte | 43 ++ src/app/components/PinboardEdit.svelte | 61 ++- src/app/components/PinboardItem.svelte | 118 +++-- src/app/components/Startup.svelte | 2 +- src/app/content.ts | 4 + src/app/pinboards.ts | 119 ++++- src/app/routes.ts | 12 +- src/lib/components/Masonry.svelte | 25 -- src/lib/components/Notice.svelte | 29 ++ src/lib/components/Spinner.svelte | 12 +- .../spaces/[relay]/library/+page.svelte | 138 +++--- .../[relay]/library/[address]/+page.svelte | 184 ++++++++ 31 files changed, 1637 insertions(+), 428 deletions(-) create mode 100644 src/app/components/PinContent.svelte create mode 100644 src/app/components/PinDetail.svelte create mode 100644 src/app/components/PinList.svelte delete mode 100644 src/app/components/PinMenu.svelte create mode 100644 src/app/components/PinReference.svelte create mode 100644 src/app/components/PinSelectionMenu.svelte delete mode 100644 src/lib/components/Masonry.svelte create mode 100644 src/lib/components/Notice.svelte create mode 100644 src/routes/spaces/[relay]/library/[address]/+page.svelte diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index b4ba3f77..8db80b71 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -885,12 +885,15 @@ resources members have collected. Acceptance: -- Typing a term filters the shelf list to shelves whose title, description, or - topics match. -- Selecting a shelf shows its pins as a gallery; an empty shelf shows a message - instead. -- 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". +- A shelf card carries its title, its description, how many links are on it and + who has contributed. +- Typing a term filters the shelves to those whose title, description or topics + match, and lists the links that match from any shelf. +- Opening a shelf shows its links; one that already holds a link still offers + "Add a link". +- Alice is offered "Create Shelf" and "Add a link" on someone else's shelf, and + not its "Edit shelf". Its "Delete shelf" opens a dialog saying only the author + or an admin can delete a shelf. ### US-054 — Curate the library @@ -899,13 +902,56 @@ good material first. Acceptance: -- Creating a shelf with a title and description adds it to the list and - navigates into it; editing and deleting it from its menu update the list. -- Adding a pin with an external url shows it as a content card, and adding one - with a nostr note link renders that note as a rich embed. +- Creating a shelf with a title, description and topics opens it; editing it + from its menu updates its page, and deleting it returns to the library. +- Several links pasted at once each become a link of its own and are offered no + title or description between them: an external url renders as the link, a + nostr note link as the note it points at. - "Add to Library" on a post (for example a poll) with a shelf selected adds it - as a pin visible in that shelf; with no shelves yet, the dialog prompts to - create one. + as a link on that shelf; with no shelves yet, the dialog prompts to create + one. +- Renaming a link from its menu republishes it under the new title. +- bob signs through a browser extension, so everything he publishes here is + signed by something outside the page. + +### US-137 — Discuss a shelf and a link on it + +As alice, I want to comment on a shelf and on one link on it, so that a question +about what was collected sits with what it is about. + +Acceptance: + +- Opening a link raises its own discussion, and a comment posted there appears + under it. +- A link's card counts the comments on it. +- The shelf has a discussion of its own, for anything about the collection + rather than about one link. + +### US-138 — Manage a shelf as a list + +As bob, I want to work through a shelf as a list, so that tidying up many links +costs one action rather than one each. + +Acceptance: + +- A shelf renders as cards or as a list, and the list gives every link a + checkbox with a select-all above them. +- Moving the selected links to another shelf files them there and takes them off + this one. +- Deleting the selected links for everyone asks for confirmation first. + +### US-139 — See who built a shelf and where its links point + +As alice, I want to see who contributed to a shelf and what each link points at, +so that a collection reads as somebody's work rather than a list of identifiers. + +Acceptance: + +- A shelf header counts its contributors, opens the roster of them, and each name + in it opens that person's profile. +- A link to a post reads as a short entity label in the list view, the same way + rendered content names one. +- Following that label lands on the post. ### US-055 — Create community content from a room diff --git a/e2e/harness/app/boot.ts b/e2e/harness/app/boot.ts index 0353f823..7ff42a2e 100644 --- a/e2e/harness/app/boot.ts +++ b/e2e/harness/app/boot.ts @@ -17,6 +17,8 @@ export type BootOptions = { indexers?: string[] spaces?: string[] user?: TestUser + // Whether this user signs through the window.nostr injectNip07 installed, rather than with a key. + nip07?: boolean // What this user's client already has in local storage, e.g. their room list. events?: TrustedEvent[] // Which paths this user has already read, keyed the way src/app/notifications.ts keys them. @@ -33,6 +35,7 @@ export const boot = async ( indexers = relays, spaces = [], user, + nip07 = false, events = [], checked, path = "/", @@ -73,7 +76,7 @@ export const boot = async ( ) if (user) { - await injectSession(context, user) + await injectSession(context, user, nip07) await injectEvents(context, events) } diff --git a/e2e/harness/app/nip07.ts b/e2e/harness/app/nip07.ts index 4f76d9d9..fa155154 100644 --- a/e2e/harness/app/nip07.ts +++ b/e2e/harness/app/nip07.ts @@ -29,7 +29,8 @@ export const injectNip07 = async (context: BrowserContext, user: TestUser) => { }) await context.addInitScript(key => { - const call = (request: Nip07Call) => Reflect.get(window, key)(request) + // An extension answers over postMessage, which structured-clones every argument it is given. + const call = (request: Nip07Call) => Reflect.get(window, key)(structuredClone(request)) Object.assign(window, { nostr: { diff --git a/e2e/harness/app/session.ts b/e2e/harness/app/session.ts index 803e0c6d..41cb7fd9 100644 --- a/e2e/harness/app/session.ts +++ b/e2e/harness/app/session.ts @@ -8,12 +8,16 @@ const TEST_SESSION_KEY = "__TEST_SESSION__" const TEST_EVENTS_KEY = "__TEST_EVENTS__" // The {method, data} shape @welshman/app's session handlers deserialize, installed before navigating. -export const injectSession = (context: BrowserContext, user: TestUser) => +// A nip07 session carries no key and signs through whatever injectNip07 put on window. +export const injectSession = (context: BrowserContext, user: TestUser, nip07 = false) => context.addInitScript( ([key, session]) => { Object.assign(window, {[key]: session}) }, - [TEST_SESSION_KEY, {method: "nip01", data: {secret: user.secret}}] as const, + [ + TEST_SESSION_KEY, + nip07 ? {method: "nip07", data: {}} : {method: "nip01", data: {secret: user.secret}}, + ] as const, ) // The local cache a returning user would boot with, loaded once the injected session is restored. diff --git a/e2e/harness/index.ts b/e2e/harness/index.ts index 1c3b0571..f4fa16d8 100644 --- a/e2e/harness/index.ts +++ b/e2e/harness/index.ts @@ -129,7 +129,7 @@ export type PageOptions = { context?: BrowserContextOptions // VITE_ values applied over the ones derived from the scenario's relays. See BootOptions in app/boot.ts. env?: Record - // A NIP-07 provider signing as this user, for a login that goes through an extension. + // A NIP-07 provider signing as this user, so that `as` signs the way an extension does. nip07?: TestUser // A WebLN provider on window, for a wallet that gets connected through an extension. webln?: WebLnInfo @@ -276,6 +276,7 @@ export const test = base.extend({ return boot(context, { user, + nip07: Boolean(options.nip07), path, env: options.env, checked: options.checked, diff --git a/e2e/specs/community.spec.ts b/e2e/specs/community.spec.ts index 2124c2ad..8e040f38 100644 --- a/e2e/specs/community.spec.ts +++ b/e2e/specs/community.spec.ts @@ -46,9 +46,9 @@ import type {TestUser} from "../harness" // A handle to a seeded event, which only reads once seed() has drained its queue. type Seeded = {readonly id: string; readonly event: SignedEvent} -// A shelf is a card and its menu button side by side, so the menu is reached through their wrapper. +// A shelf card carries its cover, its counts and its menu, so the menu is reached through it. const shelfCard = (page: Page, title: string) => - page.getByRole("button", {name: new RegExp(title)}).locator("xpath=..") + page.locator('[data-component="PinboardItem"]').filter({hasText: title}) // Button swallows both the default and the propagation of a click, and the middle of a card is one. const openCard = (card: Locator, title: string) => card.getByText(title).click() @@ -672,35 +672,58 @@ test("US-053 browse and search the library", async ({seed, as}) => { const alice = await as(users.alice, libraryPath) const term = alice.getByPlaceholder("Search library...") - await expect(alice.getByRole("button", {name: /Getting Started/})).toBeVisible() - await expect(alice.getByRole("button", {name: /Recipes/})).toBeVisible() + await expect(shelfCard(alice, "Getting Started")).toBeVisible() + await expect(shelfCard(alice, "Recipes")).toBeVisible() + + // A card says how much is on a shelf before anyone opens it. + await expect(shelfCard(alice, "Getting Started")).toContainText("1 link") + await expect(shelfCard(alice, "Recipes")).toContainText("0 links") // A term matches a shelf on its description as well as its title. await term.fill("new members") - await expect(alice.getByRole("button", {name: /Getting Started/})).toBeVisible() - await expect(alice.getByRole("button", {name: /Recipes/})).toHaveCount(0) + await expect(shelfCard(alice, "Getting Started")).toBeVisible() + await expect(shelfCard(alice, "Recipes")).toHaveCount(0) + + // A term also reaches past the shelves into the links on them. + await term.fill("handbook") + + await expect(alice.getByRole("heading", {name: "1 link matches that"})).toBeVisible() await term.fill("") - await alice.getByRole("button", {name: /Getting Started/}).click() + await alice.getByRole("link", {name: "Getting Started", exact: true}).click() + await expect(alice.getByRole("heading", {name: "Getting Started"})).toBeVisible() await expect(alice.getByText("The Handbook")).toBeVisible() - await alice.getByRole("button", {name: /Recipes/}).click() + // A shelf that already holds a link still offers a way to add another. + await expect(alice.getByRole("button", {name: "Add a link"})).toBeVisible() + + await alice.goto(libraryPath) + + // An empty shelf says so rather than drawing a blank page. + await alice.getByRole("link", {name: "Recipes", exact: true}).click() await expect(alice.getByText("This shelf doesn't have any links yet.")).toBeVisible() // The library is the whole space's, so an ordinary member is offered the same controls. + await alice.goto(libraryPath) + 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() + // A shelf is edited from its own page, and someone else's is hers to add to and not to rewrite. + await alice.getByRole("link", {name: "Recipes", exact: true}).click() + await alice.getByRole("button", {name: "Shelf 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) + + // Deleting is offered and says why it is refused, rather than going missing. + await alice.getByRole("button", {name: "Delete shelf"}).click() + + await expect(dialog(alice, "You can't delete this shelf")).toContainText( + "or an admin of this space, can delete it", + ) }) test("US-054 curate the library", async ({seed, as}) => { @@ -738,8 +761,8 @@ test("US-054 curate the library", async ({seed, as}) => { const libraryPath = `${spacePath(url)}/library` const pollPath = `${spacePath(url)}/polls/${poll.id}` - // Curating is nobody's privilege here, so this is an ordinary member doing all of it. - const page = await as(users.bob, libraryPath) + // An ordinary member curates, signing through an extension, which clones what it is handed. + const page = await as(users.bob, libraryPath, {nip07: users.bob}) const pollCard = page .locator(".card.z-feature") @@ -751,41 +774,50 @@ test("US-054 curate the library", async ({seed, as}) => { await shelfForm.getByPlaceholder("Shelf title").fill("Reading List") await shelfForm.getByPlaceholder("What's this shelf about?").fill("Things worth reading") + await shelfForm.getByPlaceholder("Add a topic...").fill("reading") + await shelfForm.getByPlaceholder("Add a topic...").press("Enter") await shelfForm.getByRole("button", {name: "Save changes"}).click() - // Creating a shelf drops its author straight into it. - await expect(page.getByRole("button", {name: /Reading List/})).toHaveAttribute( - "aria-pressed", - "true", - ) + // Creating a shelf drops its author straight onto it. + await expect(page.getByRole("heading", {name: "Reading List"})).toBeVisible() + await expect(page.getByText("#reading")).toBeVisible() - // An external url becomes a card of its own... - await page.getByRole("button", {name: "Add a link"}).click() - - const externalLink = dialog(page, "Add Link") - - await externalLink.getByPlaceholder("URL or nevent...").fill("https://handbook.test/style-guide") - await externalLink.getByPlaceholder("Optional title").fill("The Style Guide") - await externalLink.getByRole("button", {name: "Add link"}).click() - - await expect(page.locator('a[href="https://handbook.test/style-guide"]')).toBeVisible() - - // ...and a nostr link becomes the note it points at. + // Several links go in at once, each as a card of its own. const nevent = nip19.neventEncode({id: message.id, kind: MESSAGE, relays: [url]}) - await shelfCard(page, "Reading List").getByRole("button", {name: "More options"}).click() - await page.getByRole("button", {name: "Add link", exact: true}).click() + await page.getByRole("button", {name: "Add a link"}).click() - const nostrLink = dialog(page, "Add Link") + const links = dialog(page, "Add a Link") - await nostrLink.getByPlaceholder("URL or nevent...").fill(`nostr:${nevent}`) - await nostrLink.getByPlaceholder("Optional title").fill("Yesterday's incident") - await nostrLink.getByRole("button", {name: "Add link"}).click() + await links + .getByPlaceholder("URL or nevent...") + .fill(`https://handbook.test/style-guide\nnostr:${nevent}`) + // A title and a description are about one link, so a batch is offered neither. + await expect(links.getByPlaceholder("Optional title")).toHaveCount(0) + await links.getByRole("button", {name: "Add 2 links"}).click() + + await expect(page.locator('a[href="https://handbook.test/style-guide"]')).toBeVisible() await expect(page.getByText("the deploy broke again")).toBeVisible() - // Editing the shelf from its menu updates the list. - await shelfCard(page, "Reading List").getByRole("button", {name: "More options"}).click() + // Renaming a link republishes the pin the menu was opened from. + const styleGuide = page + .locator('[data-component="PinItem"]') + .filter({hasText: "handbook.test/style-guide"}) + + await menuButton(styleGuide).click() + await page.getByRole("button", {name: "Edit Link"}).click() + + const linkEdit = dialog(page, "Edit Link") + + await linkEdit.getByPlaceholder("Optional title").fill("House style guide") + await linkEdit.getByRole("button", {name: "Save changes"}).click() + + await expect(page.getByRole("alert")).toContainText("Link updated!") + await expect(page.getByText("House style guide")).toBeVisible() + + // Editing the shelf from its menu updates the page it was edited from. + await page.getByRole("button", {name: "Shelf options"}).click() await page.getByRole("button", {name: "Edit shelf"}).click() const shelfEdit = dialog(page, "Edit Shelf") @@ -793,9 +825,7 @@ test("US-054 curate the library", async ({seed, as}) => { await shelfEdit.getByPlaceholder("What's this shelf about?").fill("Things worth reading twice") await shelfEdit.getByRole("button", {name: "Save changes"}).click() - await expect(page.getByRole("button", {name: /Reading List/})).toContainText( - "Things worth reading twice", - ) + await expect(page.getByText("Things worth reading twice")).toBeVisible() // A post filed from its own menu lands on the shelf that was picked for it. await page.goto(pollPath) @@ -803,7 +833,7 @@ test("US-054 curate the library", async ({seed, as}) => { await page.getByRole("button", {name: "Add to Library"}).click() await page.getByRole("button", {name: /Reading List/}).click() - const fromPoll = dialog(page, "Add Link") + const fromPoll = dialog(page, "Add a Link") await fromPoll.getByPlaceholder("Optional title").fill("Standup poll") await fromPoll.getByRole("button", {name: "Add link"}).click() @@ -811,17 +841,297 @@ test("US-054 curate the library", async ({seed, as}) => { await expect(page.getByRole("alert")).toContainText("Link added!") await page.goto(libraryPath) - await page.getByRole("button", {name: /Reading List/}).click() + + await expect(shelfCard(page, "Reading List")).toContainText("3 links") + + await page.getByRole("link", {name: "Reading List", exact: true}).click() await expect(page.getByText("Standup poll")).toBeVisible() await expect(page.getByText("Should we move standup to 10?")).toBeVisible() - // Deleting the shelf from its menu takes it off the list. - await shelfCard(page, "Reading List").getByRole("button", {name: "More options"}).click() + // Deleting a shelf from its own page takes it off the library and puts its curator back there. + await page.getByRole("button", {name: "Shelf options"}).click() await page.getByRole("button", {name: "Delete shelf"}).click() await page.getByRole("button", {name: "Confirm"}).click() - await expect(page.getByRole("button", {name: /Reading List/})).toHaveCount(0) + await expect(page).toHaveURL(libraryPath) + await expect(shelfCard(page, "Reading List")).toHaveCount(0) +}) + +test("US-137 discuss a shelf and a link on it", async ({seed, as}) => { + let shelf!: Seeded + + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.admin, "general") + space.join(user.alice, "general") + space.profile(user.admin, {name: "Ada Admin"}) + space.profile(user.alice, {name: "Alice Anderson"}) + + shelf = 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(Pin) + .writer() + .setIdentifier() + .addBoard(getAddress(shelf.event)) + .setTitle("The Handbook") + .setExternal("https://handbook.test/start-here") + .renderTemplate(), + at(1, HOUR), + ) + }) + + const {url} = scenario.space("space") + const alice = await as(users.alice, `${spacePath(url)}/library`) + + await alice.getByRole("link", {name: "Getting Started", exact: true}).click() + + // A link carries a discussion of its own, so asking about one happens where it sits. + await alice.getByRole("button", {name: "Discuss"}).click() + + const detail = dialog(alice, "Link") + + // The link's own title stands where the heading was, so nothing says "Link" twice. + await expect(detail.getByText("The Handbook")).toBeVisible() + + await detail.getByRole("button", {name: "Comment"}).click() + + const linkComment = detail.locator("form") + + await noteEditor(linkComment).pressSequentially("Is this still the current version?") + await linkComment.getByRole("button", {name: "Comment"}).click() + + await expect( + detail + .locator('[data-component="Comment"]') + .filter({hasText: "Is this still the current version?"}), + ).toBeVisible() + + await detail.getByRole("button", {name: "Close dialog"}).click() + + // The card counts it, so the next person can see there is something to read. + await expect(alice.getByRole("button", {name: "1 comment"})).toBeVisible() + + // The shelf has one too, for anything about the collection rather than about one link. + await alice.getByRole("button", {name: "Add a comment"}).click() + + const shelfComment = alice.locator("form").filter({has: alice.locator(".note-editor")}) + + await noteEditor(shelfComment).pressSequentially("Should we split this in two?") + await shelfComment.getByRole("button", {name: "Comment"}).click() + + await expect( + alice.locator('[data-component="Comment"]').filter({hasText: "Should we split this in two?"}), + ).toBeVisible() +}) + +test("US-138 manage a shelf as a list", async ({seed, as}) => { + let readingList!: Seeded + + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.bob, "general") + space.profile(user.bob, {name: "Bob Barker"}) + + readingList = space.event( + user.bob, + () => + space + .kind(Pinboard) + .writer() + .setIdentifier() + .setTitle("Reading List") + .setCollaborative(true) + .renderTemplate(), + at(3, HOUR), + ) + + space.event( + user.bob, + () => + space + .kind(Pinboard) + .writer() + .setIdentifier() + .setTitle("Archive") + .setCollaborative(true) + .renderTemplate(), + at(3, HOUR), + ) + + for (const [title, href] of [ + ["The Handbook", "https://handbook.test/start-here"], + ["The Style Guide", "https://handbook.test/style-guide"], + ]) { + space.event( + user.bob, + () => + space + .kind(Pin) + .writer() + .setIdentifier() + .addBoard(getAddress(readingList.event)) + .setTitle(title) + .setExternal(href) + .renderTemplate(), + at(1, HOUR), + ) + } + }) + + const {url} = scenario.space("space") + const libraryPath = `${spacePath(url)}/library` + const page = await as(users.bob, libraryPath) + + await page.getByRole("link", {name: "Reading List", exact: true}).click() + await page.getByRole("button", {name: "List"}).click() + + await page.getByRole("checkbox", {name: "Select The Handbook"}).check() + await page.getByRole("checkbox", {name: "Select The Style Guide"}).check() + + await expect(page.getByText("2 selected")).toBeVisible() + + // Moving files them on the destination and takes them off here, in one action. + await page.getByRole("button", {name: "Bulk actions"}).click() + await page.getByRole("button", {name: "Move to Archive"}).click() + + await expect(page.getByRole("alert")).toContainText("2 moved.") + await expect(page.getByRole("button", {name: "The Handbook"})).toHaveCount(0) + + await page.goto(libraryPath) + + await expect(shelfCard(page, "Reading List")).toContainText("0 links") + await expect(shelfCard(page, "Archive")).toContainText("2 links") + + // Select all reaches everything on the shelf, and deleting for everyone asks first. + await page.getByRole("link", {name: "Archive", exact: true}).click() + await page.getByRole("button", {name: "List"}).click() + await page.getByRole("checkbox", {name: "Select all"}).check() + + await expect(page.getByText("2 selected")).toBeVisible() + + await page.getByRole("button", {name: "Bulk actions"}).click() + await page.getByRole("button", {name: "Delete links"}).click() + await page.getByRole("button", {name: "Confirm"}).click() + + await expect(page.getByRole("alert")).toContainText("2 deleted.") +}) + +test("US-139 see who built a shelf and where its links point", async ({seed, as}) => { + let shelf!: Seeded + let message!: Seeded + + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.admin, "general") + space.join(user.alice, "general") + space.profile(user.admin, {name: "Ada Admin"}) + space.profile(user.alice, {name: "Alice Anderson"}) + + message = space.message(user.alice, "general", "the anchor came loose again", at(30, MINUTE)) + + shelf = space.event( + user.admin, + () => + space + .kind(Pinboard) + .writer() + .setIdentifier() + .setTitle("Field Notes") + .setCollaborative(true) + .renderTemplate(), + at(3, HOUR), + ) + + space.event( + user.admin, + () => + space + .kind(Pin) + .writer() + .setIdentifier() + .addBoard(getAddress(shelf.event)) + .setTitle("The Handbook") + .setExternal("https://handbook.test/start-here") + .renderTemplate(), + at(2, HOUR), + ) + + // A link to a post carries no words of its own, so the post is all the row has to show. + space.event( + user.alice, + () => + space + .kind(Pin) + .writer() + .setIdentifier() + .addBoard(getAddress(shelf.event)) + .setEvent(message.id, space.url) + .renderTemplate(), + at(1, HOUR), + ) + }) + + const {url} = scenario.space("space") + const libraryPath = `${spacePath(url)}/library` + const alice = await as(users.alice, libraryPath) + + await alice.getByRole("link", {name: "Field Notes", exact: true}).click() + + // A card embeds the post it points at, which is also proof the post has loaded. + await expect(alice.getByText("the anchor came loose again")).toBeVisible() + + await alice.getByRole("button", {name: "List"}).click() + + const untitled = alice + .locator('[data-component="PinList"] > div') + .filter({hasText: "Untitled link"}) + const pointer = untitled.getByRole("button", {name: /^nevent1/}) + + // A row names a post the way the rest of the app does, rather than printing its bech32. + await expect(pointer).toHaveText(/^nevent1[a-z0-9]{9}…$/) + + await pointer.click() + + await expect(alice).toHaveURL(new RegExp(`${roomPath(url, "general")}\\?`)) + await expect(alice.getByText("the anchor came loose again")).toBeVisible() + + await alice.goBack() + + // Whoever made the shelf or added to it is a contributor, and the header opens the roster. + await alice.getByRole("button", {name: /2 contributors/}).click() + + const contributors = dialog(alice, "Contributors") + + await expect(contributors.getByText("Ada Admin")).toBeVisible() + await expect(contributors.getByText("Alice Anderson")).toBeVisible() + + // A name in the roster is a way through to that person. + await contributors.getByRole("button", {name: "Ada Admin", exact: true}).click() + + await expect(dialog(alice, "Profile details")).toBeVisible() }) test("US-055 create community content from a room", async ({seed, as}) => { diff --git a/src/app/components/BoardMenu.svelte b/src/app/components/BoardMenu.svelte index 4b2f2cda..ae911a92 100644 --- a/src/app/components/BoardMenu.svelte +++ b/src/app/components/BoardMenu.svelte @@ -1,6 +1,6 @@ diff --git a/src/app/components/CommentCompose.svelte b/src/app/components/CommentCompose.svelte index 37850adf..752d0527 100644 --- a/src/app/components/CommentCompose.svelte +++ b/src/app/components/CommentCompose.svelte @@ -4,6 +4,7 @@ import {Note} from "@welshman/domain" import {isMobile, preventDefault} from "@lib/html" import Paperclip from "@assets/icons/paperclip-2.svg?dataurl" + import Plane from "@assets/icons/plane-2.svg?dataurl" import Icon from "@lib/components/Icon.svelte" import Button from "@lib/components/Button.svelte" import Spinner from "@lib/components/Spinner.svelte" @@ -129,7 +130,10 @@
+ {/if} + 0 ? topicLabels : undefined} + {url} + event={pin.event} + {context} /> +
+ {#if replyTo?.id === pin.event.id} + + {/if} + +
+ {#each nodes as node (node.comment.id)} + + {/each} + {#if nodes.length === 0} +

No comments yet.

+ {/if} +
+ + diff --git a/src/app/components/PinItem.svelte b/src/app/components/PinItem.svelte index b3637b3a..a380b20d 100644 --- a/src/app/components/PinItem.svelte +++ b/src/app/components/PinItem.svelte @@ -1,71 +1,84 @@ -
- {#if !minimal} -
- -
- {/if} +{#snippet topicLabels()} + {#each topics as topic (topic)} + #{topic} + {/each} +{/snippet} + +{#snippet body()} +
+ + + + + {formatTimestampRelative(pin.event.created_at)} +
{#if pin.title()} - {pin.title()} + {pin.title()} {/if} - {#if pin.content()} - - {/if} - {#if reference?.type === "event" || reference?.type === "address"} - +
+ + 0 ? topicLabels : undefined} {url} - value={reference.type === "event" ? reference.id : reference.address} - relays={removeUndefined([reference.relay, url])} /> - {:else} - - {/if} - {#if !minimal && pin.topics().length > 0} -
- {#each pin.topics() as topic (topic)} - #{topic} - {/each} -
- {/if} -
+ event={pin.event} + {context} /> +
+{/snippet} + +{#if card} + + {@render body()} + +{:else} + + {@render body()} + +{/if} diff --git a/src/app/components/PinList.svelte b/src/app/components/PinList.svelte new file mode 100644 index 00000000..c67a70b2 --- /dev/null +++ b/src/app/components/PinList.svelte @@ -0,0 +1,184 @@ + + +
+
+ + {#if selected.length > 0} + {selected.length} selected + + {#if loading} + + {:else} + + {/if} + + {/if} +
+ {#each pins as pin (pin.event.id)} +
+ +
+ + {#if pin.title() && pin.content()} +

{pin.content()}

+ {/if} +
+ +
+
+ + + + + + + {formatTimestampRelative(pin.event.created_at)} + {#each uniq(pin.topics().map(normalizeTopic)) as topic (topic)} + #{topic} + {/each} +
+
+
+ {/each} +
diff --git a/src/app/components/PinMenu.svelte b/src/app/components/PinMenu.svelte deleted file mode 100644 index 7a999627..00000000 --- a/src/app/components/PinMenu.svelte +++ /dev/null @@ -1,84 +0,0 @@ - - - diff --git a/src/app/components/PinReference.svelte b/src/app/components/PinReference.svelte new file mode 100644 index 00000000..074a7cf6 --- /dev/null +++ b/src/app/components/PinReference.svelte @@ -0,0 +1,32 @@ + + +{#if reference?.type === "external"} + +{:else if quote} + +{/if} diff --git a/src/app/components/PinSave.svelte b/src/app/components/PinSave.svelte index 8da92b25..ab505fe5 100644 --- a/src/app/components/PinSave.svelte +++ b/src/app/components/PinSave.svelte @@ -1,10 +1,13 @@ @@ -103,7 +166,7 @@ {#if pin} Edit Link {:else} - Add Link + Add a Link {/if} on @@ -113,13 +176,46 @@ Link {/snippet} {#snippet info()} -

A URL or nostr link (note, nevent, naddr, npub, or nprofile).

+

+ A URL or a nostr link (note, nevent, naddr, npub, or nprofile). Drop files here to upload + them. Paste several links, one per line, to file them all at once. +

{/snippet} {#snippet input()} - + {/snippet} - {#if value.trim()} + {#if references.length > 1} +

+ {references.length} links, each filed on its own. A title and a description describe one link, + so add them from its menu once it is on the shelf. +

+ {:else if references.length === 1}
{#snippet label()} @@ -170,11 +266,13 @@ + + {/each} +
  • + +
  • +
  • + +
  • + diff --git a/src/app/components/PinboardEdit.svelte b/src/app/components/PinboardEdit.svelte index 65eb7e80..31911819 100644 --- a/src/app/components/PinboardEdit.svelte +++ b/src/app/components/PinboardEdit.svelte @@ -1,10 +1,10 @@ @@ -93,6 +119,37 @@ placeholder="What's this shelf about?" /> {/snippet}
    + + {#snippet label()} + Cover image + {/snippet} + {#snippet input()} +
    + {#if image} + + {/if} + + + {image ? "Replace" : "Add an image"} + + {#if image} + + {/if} +
    + {/snippet} +
    + + {#snippet label()} + Topics + {/snippet} + {#snippet input()} + + {/snippet} + -
    - +
    -
    +
    +

    {title}

    + {#if board.description()} +

    {board.description()}

    + {/if} + {#if topics.length > 0} +
    + {#each topics.slice(0, 2) as topic (topic)} + #{topic} + {/each} + {#if topics.length > 2} + +{topics.length - 2} + {/if} +
    + {/if} +
    +
    + + {pins.length} {pins.length === 1 ? "link" : "links"} + {#if $comments.length > 0} + + + {$comments.length} + + {/if} + + + + {formatTimestampRelative(lastActive)} + +
    + diff --git a/src/app/components/Startup.svelte b/src/app/components/Startup.svelte index 923fd064..d91dfd30 100644 --- a/src/app/components/Startup.svelte +++ b/src/app/components/Startup.svelte @@ -46,7 +46,7 @@ back to the login screen.

    {/if} diff --git a/src/app/content.ts b/src/app/content.ts index 77fbbca7..85ca54a1 100644 --- a/src/app/content.ts +++ b/src/app/content.ts @@ -63,6 +63,10 @@ export const getUrlContentType = (url: string, event: TrustedEvent) => { return tagValue(tagSpec("m"), tags) || tagValue(tagSpec("file-type"), tags) || "" } +export const isImageUrl = (url: string, event: TrustedEvent) => + Boolean(url.match(/\.(jpe?g|png|gif|webp)$/)) || + IMAGE_CONTENT_TYPES.includes(getUrlContentType(url, event)) + export const decryptUrl = async (url: string, event: TrustedEvent, signal?: AbortSignal) => { const tags = getUrlTags(url, event) const algorithm = tagValue(tagSpec("encryption-algorithm"), tags) diff --git a/src/app/pinboards.ts b/src/app/pinboards.ts index 8da81aa4..6bd28fe2 100644 --- a/src/app/pinboards.ts +++ b/src/app/pinboards.ts @@ -1,8 +1,28 @@ import * as nip19 from "nostr-tools/nip19" -import {Address, fromNostrURI, getAddress, isReplaceable} from "@welshman/util" +import {derived} from "svelte/store" +import type {Readable} from "svelte/store" +import {removeUndefined, sortBy} from "@welshman/lib" +import type {Maybe} from "@welshman/lib" +import {Address, PIN, PINBOARD, fromNostrURI, getAddress, isReplaceable} from "@welshman/util" import type {TrustedEvent} from "@welshman/util" -import type {PinReader, PinReference, PinWriter} from "@welshman/domain" +import {Pin, Pinboard} from "@welshman/domain" +import type {PinReader, PinReference, PinWriter, PinboardReader} from "@welshman/domain" import {isLink, parse} from "@welshman/content" +import {publish} from "@welshman/app" +import { + app, + command, + deletes, + events, + reader, + relayManagement, + relays, + user, + writer, +} from "@app/core" +import {addressWriterToSpace} from "@app/rooms" +import {isImageUrl} from "@app/content" +import {compressFileForUpload, uploadFile} from "@app/uploads" const encodeReference = (reference: PinReference) => { if (reference.type === "event") { @@ -80,3 +100,98 @@ export const setPinReference = (writer: PinWriter, reference: string) => { return false } + +export const deriveShelves = (url: string): Readable => + derived(events.get().forUrl(url, [{kinds: [PINBOARD]}]).$, $events => + $events.map(reader(Pinboard)), + ) + +export const derivePins = (url: string): Readable => + derived(events.get().forUrl(url, [{kinds: [PIN]}]).$, $events => $events.map(reader(Pin))) + +export const deriveShelf = (url: string, address: string): Readable> => + derived(events.get().one(address, [url]).$, $event => + $event ? reader(Pinboard)($event) : undefined, + ) + +const matchesTerm = (term: string, fields: Maybe[]) => + fields.some(field => field?.toLowerCase().includes(term)) + +export const shelfMatchesTerm = (shelf: PinboardReader, term: string) => + matchesTerm(term, [shelf.title(), shelf.description(), ...shelf.topics()]) + +export const pinMatchesTerm = (pin: PinReader, term: string) => + matchesTerm(term, [pin.title(), pin.content(), pinToReference(pin), ...pin.topics()]) + +// A pin's own words. Its reference is rendered as a link rather than named here. +export const displayPin = (pin: PinReader) => pin.title() || pin.content() || "Untitled link" + +const pinImage = (pin: PinReader) => { + const reference = pin.reference() + + if (reference?.type === "external" && isImageUrl(reference.id, pin.event)) { + return reference.id + } + + return undefined +} + +// A shelf with no cover of its own is illustrated by the pictures on it. +export const shelfCoverImages = (pins: PinReader[]) => + removeUndefined(sortBy(pin => -pin.event.created_at, pins).map(pinImage)).slice(0, 3) + +// A library pin is published to the space rather than to an author outbox, so every write goes there. +const publishToSpace = async (url: string, eventWriter: PinWriter) => { + await addressWriterToSpace(eventWriter, url) + + const publication = await command(eventWriter).then(publish) + + return (await publication.settled()).getError() +} + +// One write per pin: a second update built from the same reader would undo the first. +export const movePinsToShelf = (url: string, pins: PinReader[], from: string, to: string) => + Promise.all(pins.map(pin => publishToSpace(url, writer(Pin, pin).addBoard(to).removeBoard(from)))) + +export const removePinsFromShelf = (url: string, pins: PinReader[], address: string) => + Promise.all(pins.map(pin => publishToSpace(url, writer(Pin, pin).removeBoard(address)))) + +// Your own goes as a kind 5; anyone else's is a NIP-86 ban, which the repository never hears about. +export const deleteLibraryEvent = async (url: string, event: TrustedEvent) => { + if (event.pubkey === user.get().pubkey) { + const protect = await relays.get().hasNip(url, 70) + const request = await deletes + .get() + .deleteEvent(event, deleteWriter => deleteWriter.setProtected(protect)) + + return (await request.publishToRelays([url]).settled()).getError() + } + + const {error} = await relayManagement.get().forUrl(url).banEvent(event.id) + + if (!error) { + app.get().repository.removeEvent(event.id) + } + + return error +} + +export const deletePins = (url: string, pins: PinReader[]) => + Promise.all(pins.map(pin => deleteLibraryEvent(url, pin.event))) + +export const uploadFilesAsReferences = async (url: string, files: File[]) => { + const references: string[] = [] + const errors: string[] = [] + + for (const file of files) { + const {result, error} = await uploadFile(await compressFileForUpload(file), {url}) + + if (result) { + references.push(result.url) + } else { + errors.push(error ?? `${file.name} could not be uploaded.`) + } + } + + return {references, errors} +} diff --git a/src/app/routes.ts b/src/app/routes.ts index 8f9ab1c4..d4940b99 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -172,16 +172,8 @@ export const makeCalendarPath = (url: string, address?: string) => export const makePollPath = (url: string, id?: string) => makeSpacePath(url, "polls", id) -// Shelves are selected in place on the library page, so the address goes in a query param. -export const makeLibraryPath = (url: string, address?: string) => { - const path = makeSpacePath(url, "library") - - if (address) { - return path + "?" + new URLSearchParams({board: address}).toString() - } - - return path -} +export const makeLibraryPath = (url: string, address?: string) => + makeSpacePath(url, "library", address) export const makeContentPath = (url: string, kind: number, idOrAddress?: string) => { switch (kind) { diff --git a/src/lib/components/Masonry.svelte b/src/lib/components/Masonry.svelte deleted file mode 100644 index ccb54c2b..00000000 --- a/src/lib/components/Masonry.svelte +++ /dev/null @@ -1,25 +0,0 @@ - - -
    - {#each items as item (getKey(item))} -
    - {@render child(item)} -
    - {/each} -
    diff --git a/src/lib/components/Notice.svelte b/src/lib/components/Notice.svelte new file mode 100644 index 00000000..2b832467 --- /dev/null +++ b/src/lib/components/Notice.svelte @@ -0,0 +1,29 @@ + + + + + + {title} + +

    {message}

    +
    + + + +
    diff --git a/src/lib/components/Spinner.svelte b/src/lib/components/Spinner.svelte index b411d2bb..e7b91a50 100644 --- a/src/lib/components/Spinner.svelte +++ b/src/lib/components/Spinner.svelte @@ -6,18 +6,10 @@ children?: Snippet size?: "md" | "sm" | "xs" loading?: boolean - // Reserves the spinner's slot while idle, so toggling `loading` doesn't reflow the button. - reserveSpace?: boolean class?: string } - const { - children, - size = "md", - loading = true, - reserveSpace = false, - ...restProps - }: Props = $props() + const {children, size = "md", loading = true, ...restProps}: Props = $props() const spinnerClass = $derived( cx("spinner", { @@ -30,8 +22,6 @@
    {#if loading} - {:else if reserveSpace} - {/if} {@render children?.()}
    diff --git a/src/routes/spaces/[relay]/library/+page.svelte b/src/routes/spaces/[relay]/library/+page.svelte index 874d5743..b063bd0d 100644 --- a/src/routes/spaces/[relay]/library/+page.svelte +++ b/src/routes/spaces/[relay]/library/+page.svelte @@ -1,70 +1,71 @@ @@ -87,53 +90,54 @@ Library {/snippet} {#snippet action()} - {/snippet} - + {#if loading}

    Loading library...

    + {:else if $shelves.length === 0} +
    +

    This space has no shelves yet.

    +

    + A shelf collects links, notes and files worth keeping where everyone can find them. +

    + +
    {:else} - {#if filtered.length > 0} -
    - {#each filtered as board (board.address())} - + {#if matchingShelves.length > 0} +
    + {#each matchingShelves as board (board.address())} + {/each}
    {:else} -

    No shelves found.

    +

    No shelves match that.

    {/if} -
    - {#if selected && $pins.length > 0} - pin.id()} columnWidth={80} gap={3}> - {#snippet child(pin)} - - {/snippet} - - {:else if selected} -
    -

    This shelf doesn't have any links yet.

    - + {#if term} +

    + {matchingPins.length === 1 + ? "1 link matches that" + : `${matchingPins.length} links match that`} +

    +
    + {#each matchingPins as pin (pin.event.id)} + + {/each}
    - {:else} -

    Select a shelf to see what's on it.

    {/if} {/if} diff --git a/src/routes/spaces/[relay]/library/[address]/+page.svelte b/src/routes/spaces/[relay]/library/[address]/+page.svelte new file mode 100644 index 00000000..3cd5e514 --- /dev/null +++ b/src/routes/spaces/[relay]/library/[address]/+page.svelte @@ -0,0 +1,184 @@ + + + + {#snippet leading()} + + {/snippet} + {#snippet title()} +

    {$board?.title() || "Shelf"}

    + {/snippet} + {#snippet action()} + + {#if $board} + + {/if} + {/snippet} +
    + + + {#if $board} +
    + {#if $board.image()} + + {/if} +
    + {#if $board.description()} +

    {$board.description()}

    + {/if} + {#if topics.length > 0} +
    + {#each topics as topic (topic)} + #{topic} + {/each} +
    + {/if} +
    + + {shelved.length} {shelved.length === 1 ? "link" : "links"} +
    +
    +
    +
    + + +
    + {#if visible.length === 0} +
    + {#if term} +

    Nothing on this shelf matches that.

    + {:else} +

    This shelf doesn't have any links yet.

    + + {/if} +
    + {:else if layout === "cards"} +
    + {#each visible as pin (pin.event.id)} + + {/each} +
    + {:else} + + {/if} +
    + +
    + {:else} + + {/if} +