@@ -54,7 +54,7 @@
{/if}
{/if}
diff --git a/src/app/components/Pin.svelte b/src/app/components/Pin.svelte
new file mode 100644
index 00000000..231fd065
--- /dev/null
+++ b/src/app/components/Pin.svelte
@@ -0,0 +1,120 @@
+
+
+
+ {#if showMenu}
+
+
+ {#if menuOpen}
+
(menuOpen = false)}>
+
+
+ {/if}
+
+ {#if pin.title}
+
{pin.title}
+ {/if}
+ {/if}
+ {#if pin.value[0] === "i"}
+
+ {:else}
+
+ {/if}
+ {#if !minimal && pin.topics.length > 0}
+
+ {#each pin.topics as topic (topic)}
+ #{topic}
+ {/each}
+
+ {/if}
+
diff --git a/src/app/components/PinAdd.svelte b/src/app/components/PinAdd.svelte
new file mode 100644
index 00000000..4ae9d014
--- /dev/null
+++ b/src/app/components/PinAdd.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/src/app/components/PinContentEvent.svelte b/src/app/components/PinContentEvent.svelte
new file mode 100644
index 00000000..32753d05
--- /dev/null
+++ b/src/app/components/PinContentEvent.svelte
@@ -0,0 +1,27 @@
+
+
+{#if $event}
+
+{:else}
+
+ Loading event...
+
+{/if}
diff --git a/src/app/components/PinEdit.svelte b/src/app/components/PinEdit.svelte
new file mode 100644
index 00000000..80caae8b
--- /dev/null
+++ b/src/app/components/PinEdit.svelte
@@ -0,0 +1,34 @@
+
+
+
diff --git a/src/app/components/PinForm.svelte b/src/app/components/PinForm.svelte
new file mode 100644
index 00000000..10a174b4
--- /dev/null
+++ b/src/app/components/PinForm.svelte
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+ {heading}
+ on
+
+
+ {#snippet label()}
+ Title
+ {/snippet}
+ {#snippet input()}
+
+ {/snippet}
+
+
+ {#snippet label()}
+ Topics
+ {/snippet}
+ {#snippet input()}
+
+ {/snippet}
+
+
+ {#snippet label()}
+ Link
+ {/snippet}
+ {#snippet info()}
+ A URL or nostr link (note, nevent, or naddr).
+ {/snippet}
+ {#snippet input()}
+
+ {/snippet}
+
+
+
+
+
+
+
diff --git a/src/app/components/PinboardEdit.svelte b/src/app/components/PinboardEdit.svelte
new file mode 100644
index 00000000..005cbf5d
--- /dev/null
+++ b/src/app/components/PinboardEdit.svelte
@@ -0,0 +1,92 @@
+
+
+
+
+
+ {isNew ? "Create Shelf" : "Edit Shelf"}
+ on
+
+
+ {#snippet label()}
+ Title
+ {/snippet}
+ {#snippet input()}
+
+ {/snippet}
+
+
+ {#snippet label()}
+ Description
+ {/snippet}
+ {#snippet input()}
+
+ {/snippet}
+
+
+
+
+
+
+
diff --git a/src/app/components/PinboardItem.svelte b/src/app/components/PinboardItem.svelte
new file mode 100644
index 00000000..1561265a
--- /dev/null
+++ b/src/app/components/PinboardItem.svelte
@@ -0,0 +1,34 @@
+
+
+
+
+ {board.title || "Untitled shelf"}
+ {#if board.description}
+ {board.description}
+ {/if}
+
+ {#each $pins.slice(0, 1) as pin (pin.id)}
+
+ {/each}
+
+
+
View all content
+
+
diff --git a/src/app/components/ReactionSummary.svelte b/src/app/components/ReactionSummary.svelte
index 55e0df87..3735b1ec 100644
--- a/src/app/components/ReactionSummary.svelte
+++ b/src/app/components/ReactionSummary.svelte
@@ -19,7 +19,7 @@
import {load} from "@welshman/net"
import {Router} from "@welshman/router"
import {pubkey, repository, getValidZap, displayProfileByPubkey} from "@welshman/app"
- import {isMobile} from "@lib/html"
+ import {isMobile, stopPropagation} from "@lib/html"
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -147,7 +147,7 @@
tip: !noTooltip && !isMobile,
},
)}
- onclick={onReportClick}>
+ onclick={stopPropagation(onReportClick)}>
{$reports.length}
@@ -186,7 +186,7 @@
"button-primary": isOwn,
"button-neutral": !isOwn,
})}
- onclick={onClick}>
+ onclick={stopPropagation(onClick)}>
{#if events.length > 1}
{events.length}
diff --git a/src/app/components/SpaceMenu.svelte b/src/app/components/SpaceMenu.svelte
index 715f1b42..87fefb20 100644
--- a/src/app/components/SpaceMenu.svelte
+++ b/src/app/components/SpaceMenu.svelte
@@ -7,6 +7,7 @@
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
import Home from "@assets/icons/home.svg?dataurl"
+ import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
import Danger from "@assets/icons/danger.svg?dataurl"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Exit from "@assets/icons/logout-3.svg?dataurl"
@@ -49,7 +50,8 @@
userSpaceUrls,
displayRoom,
} from "@app/groups"
- import {hasNip29} from "@app/relays"
+ import {hasNip29, deriveSupportedMethods} from "@app/relays"
+ import {BOARD} from "@app/pinboards"
import {deriveEventsForUrl} from "@app/repository"
import {deriveSpaceActionItems} from "@app/actionItems"
import {notificationSettings, deriveShouldNotify, setSpaceNotifications} from "@app/settings"
@@ -77,6 +79,12 @@
$events => new Set($events.map(e => e.kind)),
)
+ const boardEvents = deriveEventsForUrl(url, [{kinds: [BOARD]}])
+ const supportedMethods = deriveSupportedMethods(url)
+ const showLibrary = $derived(
+ $boardEvents.length > 0 || $supportedMethods.some(m => (m as string) === "signevent"),
+ )
+
const roomSearch = derived(otherRooms, $otherRooms =>
createSearch(
$otherRooms.map(h => ({h, name: displayRoom(url, h)})),
@@ -228,6 +236,11 @@
Directory
+ {#if showLibrary}
+
+ Library
+
+ {/if}
{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)}
Goals
diff --git a/src/app/featured.ts b/src/app/featured.ts
index 5621a78a..97409e9d 100644
--- a/src/app/featured.ts
+++ b/src/app/featured.ts
@@ -1,9 +1,9 @@
import {derived} from "svelte/store"
-import {first, now} from "@welshman/lib"
+import {now} from "@welshman/lib"
import {APP_DATA, getTagValues} from "@welshman/util"
import type {ManagementMethod} from "@welshman/util"
-import {deriveRelay, manageRelay} from "@welshman/app"
-import {deriveEventsForUrl} from "@app/repository"
+import {manageRelay} from "@welshman/app"
+import {deriveRelaySignedEvents} from "@app/repository"
// NIP-78 app data published by the relay's self key. Each featured entry is a
// ["content", ] tag (freeform text, intended to be a url or nevent).
@@ -11,13 +11,8 @@ export const FEATURED_CONTENT_D = "flotilla/featured-content"
export const deriveFeaturedContent = (url: string) =>
derived(
- [deriveRelay(url), deriveEventsForUrl(url, [{kinds: [APP_DATA], "#d": [FEATURED_CONTENT_D]}])],
- ([$relay, $events]) => {
- const self = $relay?.self || $relay?.pubkey
- const event = (self && $events.find(e => e.pubkey === self)) || first($events)
-
- return getTagValues("content", event?.tags ?? [])
- },
+ deriveRelaySignedEvents(url, [{kinds: [APP_DATA], "#d": [FEATURED_CONTENT_D]}]),
+ ([event]) => getTagValues("content", event?.tags ?? []),
)
// Publish the featured content list by asking the relay to sign it with its self
diff --git a/src/app/pinboards.ts b/src/app/pinboards.ts
new file mode 100644
index 00000000..31633918
--- /dev/null
+++ b/src/app/pinboards.ts
@@ -0,0 +1,213 @@
+import {derived} from "svelte/store"
+import * as nip19 from "nostr-tools/nip19"
+import {now, randomId} from "@welshman/lib"
+import {
+ Address,
+ DELETE,
+ asDecryptedEvent,
+ fromNostrURI,
+ getAddress,
+ getListTags,
+ getTagValue,
+ getTagValues,
+ readList,
+ sortEventsDesc,
+} from "@welshman/util"
+import type {ManagementMethod, TrustedEvent} from "@welshman/util"
+import {manageRelay} from "@welshman/app"
+import {deriveEventsForUrl, deriveRelaySignedEvents} from "@app/repository"
+
+export const BOARD = 30067
+
+export const PIN = 39067
+
+export type Board = {
+ address: string
+ identifier: string
+ title: string
+ description: string
+ image: string
+ topics: string[]
+ collaborative: boolean
+}
+
+export type Pin = {
+ id: string
+ pubkey: string
+ identifier: string
+ boards: string[]
+ comment: string
+ title: string
+ topics: string[]
+ // The pin's content reference: the first e/a/i tag found, e.g. ["e", ],
+ // ["a", ], or ["i", ]. Empty when the pin has no reference.
+ value: string[]
+}
+
+export const readBoard = (event: TrustedEvent): Board => {
+ const tags = getListTags(readList(asDecryptedEvent(event)))
+
+ return {
+ address: getAddress(event),
+ identifier: getTagValue("d", tags) ?? "",
+ title: getTagValue("title", tags) ?? "",
+ description: getTagValue("description", tags) ?? "",
+ image: getTagValue("image", tags) ?? "",
+ topics: getTagValues("t", tags),
+ collaborative: tags.some(tag => tag[0] === "collaborative"),
+ }
+}
+
+export const readPin = (event: TrustedEvent): Pin => ({
+ id: event.id,
+ pubkey: event.pubkey,
+ identifier: getTagValue("d", event.tags) ?? "",
+ boards: getTagValues("A", event.tags),
+ comment: event.content,
+ title: getTagValue("title", event.tags) ?? "",
+ topics: getTagValues("t", event.tags),
+ value: event.tags.find(tag => ["e", "a", "i"].includes(tag[0])) ?? [],
+})
+
+export const deriveBoards = (url: string) =>
+ derived(deriveEventsForUrl(url, [{kinds: [BOARD]}]), $events => $events.map(readBoard))
+
+export const deriveBoard = (url: string, identifier: string) =>
+ derived(deriveRelaySignedEvents(url, [{kinds: [BOARD], "#d": [identifier]}]), ([event]) =>
+ event ? readBoard(event) : undefined,
+ )
+
+export const deriveBoardByAddress = (url: string, address: string) =>
+ deriveBoard(url, Address.from(address).identifier)
+
+export const derivePins = (url: string, address: string) =>
+ derived(deriveRelaySignedEvents(url, [{kinds: [PIN], "#A": [address]}]), $events =>
+ sortEventsDesc($events).map(readPin),
+ )
+
+const signAsRelay = async (url: string, template: object): Promise => {
+ const {error} = await manageRelay(url, {
+ method: "signevent" as ManagementMethod,
+ params: [template as unknown as string],
+ })
+
+ return error
+}
+
+const makeBoardTemplate = ({
+ identifier,
+ title,
+ description,
+ image,
+ topics,
+ collaborative,
+}: Omit) => ({
+ kind: BOARD,
+ created_at: now(),
+ content: "",
+ tags: [
+ ["d", identifier],
+ ["title", title],
+ ...(description ? [["description", description]] : []),
+ ...(image ? [["image", image]] : []),
+ ...topics.map(topic => ["t", topic]),
+ ...(collaborative ? [["collaborative"]] : []),
+ ],
+})
+
+const makePinTemplate = ({
+ identifier = "",
+ boards = [],
+ comment = "",
+ title = "",
+ topics = [],
+ value = [],
+}: Partial) => ({
+ kind: PIN,
+ created_at: now(),
+ content: comment,
+ tags: [
+ // Reuse the identifier when editing so the addressable event is replaced;
+ // fall back to a random one for new pins.
+ ["d", identifier || randomId()],
+ ...boards.map(board => ["A", board]),
+ ...(value.length ? [value] : []),
+ ...(title ? [["title", title]] : []),
+ ...topics.map(topic => ["t", topic]),
+ ],
+})
+
+const makeDeleteTemplate = (tags: string[][]) => ({
+ kind: DELETE,
+ created_at: now(),
+ content: "",
+ tags,
+})
+
+export const editBoard = (url: string, board: Board) => signAsRelay(url, makeBoardTemplate(board))
+
+export const createPin = (url: string, pin: Partial) => signAsRelay(url, makePinTemplate(pin))
+
+export const deletePin = (url: string, id: string) =>
+ signAsRelay(
+ url,
+ makeDeleteTemplate([
+ ["e", id],
+ ["k", String(PIN)],
+ ]),
+ )
+
+export const deleteBoard = (url: string, address: string) =>
+ signAsRelay(
+ url,
+ makeDeleteTemplate([
+ ["a", address],
+ ["k", String(BOARD)],
+ ]),
+ )
+
+export const pinToReference = (pin: Pin): string => {
+ const [type, data = "", relay] = pin.value
+ const relays = relay ? [relay] : []
+
+ if (type === "e") return nip19.neventEncode({id: data, relays})
+
+ if (type === "a") {
+ const {kind, pubkey, identifier} = Address.from(data)
+
+ return nip19.naddrEncode({kind, pubkey, identifier, relays})
+ }
+
+ return data
+}
+
+export const referenceToPin = (reference: string): Partial | undefined => {
+ const trimmed = reference.trim()
+
+ if (!trimmed) return undefined
+
+ try {
+ const decoded = nip19.decode(fromNostrURI(trimmed))
+
+ if (decoded.type === "note") return {value: ["e", decoded.data]}
+ if (decoded.type === "nevent") {
+ const {id, relays} = decoded.data
+
+ return {value: relays?.[0] ? ["e", id, relays[0]] : ["e", id]}
+ }
+ if (decoded.type === "naddr") {
+ const {kind, pubkey, identifier, relays} = decoded.data
+ const coordinate = `${kind}:${pubkey}:${identifier}`
+
+ return {value: relays?.[0] ? ["a", coordinate, relays[0]] : ["a", coordinate]}
+ }
+ } catch {
+ // Not a nostr entity; fall through to external url handling.
+ }
+
+ if (/^https?:\/\//i.test(trimmed)) {
+ return {value: ["i", trimmed]}
+ }
+
+ return undefined
+}
diff --git a/src/app/repository.ts b/src/app/repository.ts
index c7904aff..3d78c531 100644
--- a/src/app/repository.ts
+++ b/src/app/repository.ts
@@ -12,7 +12,7 @@ import {deriveRelay, repository, tracker} from "@welshman/app"
import {sortEventsDesc} from "@welshman/util"
import type {Filter} from "@welshman/util"
import {load} from "@welshman/net"
-import {first} from "@welshman/lib"
+import {filter, first, spec} from "@welshman/lib"
import {derived} from "svelte/store"
export const deriveEvent = makeDeriveEvent({
repository,
@@ -41,9 +41,6 @@ export const deriveLatestEventForUrl = (url: string, filters: Filter[] = [{}]) =
)
export const deriveRelaySignedEvents = (url: string, filters: Filter[] = [{}]) =>
- derived(
- [deriveRelay(url), deriveEventsForUrl(url, filters)],
- ([relay, events]) => events,
- // TODO: khatru doesn't support relay.self, uncomment when it's ready
- // filter(spec({pubkey: relay.self}), events)
+ derived([deriveRelay(url), deriveEventsForUrl(url, filters)], ([relay, events]) =>
+ filter(spec({pubkey: relay?.self}), events),
)
diff --git a/src/app/sync.ts b/src/app/sync.ts
index 80b1f04f..de649b89 100644
--- a/src/app/sync.ts
+++ b/src/app/sync.ts
@@ -57,6 +57,7 @@ import {decodeRelay} from "@app/relays"
import {loadFeedsForPubkey} from "@app/feeds"
import {RELAY_ROLE} from "@app/members"
import {FEATURED_CONTENT_D} from "@app/featured"
+import {BOARD, PIN} from "@app/pinboards"
import {hasBlossomSupport} from "@app/uploads"
import {LIVEKIT_PARTICIPANTS} from "@app/call"
@@ -300,6 +301,7 @@ const syncSpace = (url: string) => {
filters: [
{kinds: [...relayKinds, ...roomMetaKinds, ...roomDeleteKinds, ...CONTENT_KINDS, MESSAGE]},
{kinds: [APP_DATA], "#d": [FEATURED_CONTENT_D]},
+ {kinds: [BOARD, PIN]},
{kinds: [...REACTION_KINDS, POLL_RESPONSE], since},
makeCommentFilter(CONTENT_KINDS, {since}),
],
diff --git a/src/lib/components/Masonry.svelte b/src/lib/components/Masonry.svelte
new file mode 100644
index 00000000..c2471478
--- /dev/null
+++ b/src/lib/components/Masonry.svelte
@@ -0,0 +1,27 @@
+
+
+
+ {#each items as item (getKey(item))}
+
+ {@render child(item)}
+
+ {/each}
+
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 23d2e022..0117a898 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -47,6 +47,7 @@
import * as lightning from "@app/lightning"
import * as uploads from "@app/uploads"
import * as appPolls from "@app/polls"
+ import * as pinboards from "@app/pinboards"
import * as reports from "@app/reports"
import * as relays from "@app/relays"
import * as settings from "@app/settings"
@@ -116,6 +117,7 @@
...lightning,
...uploads,
...appPolls,
+ ...pinboards,
...reports,
...notifications,
})
diff --git a/src/routes/spaces/[relay]/library/+page.svelte b/src/routes/spaces/[relay]/library/+page.svelte
new file mode 100644
index 00000000..59de9077
--- /dev/null
+++ b/src/routes/spaces/[relay]/library/+page.svelte
@@ -0,0 +1,107 @@
+
+
+
+ {#snippet leading()}
+
+ {/snippet}
+ {#snippet title()}
+ Library
+ {/snippet}
+ {#snippet action()}
+ {#if canManage}
+
+ {/if}
+ {/snippet}
+
+
+
+
+
+
board.address} columnWidth={80} gap={2}>
+ {#snippet child(board)}
+
+ {/snippet}
+
+ {#if loading}
+
+ Loading library...
+
+ {:else if filtered.length === 0}
+
No shelves found.
+ {/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..269ab57b
--- /dev/null
+++ b/src/routes/spaces/[relay]/library/[address]/+page.svelte
@@ -0,0 +1,176 @@
+
+
+
+ {#snippet title()}
+ {$board?.title || "Shelf"}
+ {/snippet}
+ {#snippet action()}
+ {#if canManage}
+
+
+ {#if menuOpen}
+
(menuOpen = false)}>
+
+
+ {/if}
+
+ {/if}
+ {/snippet}
+
+
+
+ {#if $board}
+ {#if $pins.length === 0}
+
+
This shelf doesn't have any links yet.
+ {#if canManage}
+
+ {/if}
+
+ {:else}
+
+ {#if filtered.length === 0}
+ No links found.
+ {:else}
+ pin.id} columnWidth={60} gap={3}>
+ {#snippet child(pin)}
+
+ {/snippet}
+
+ {/if}
+ {/if}
+ {:else}
+
+ {#await sleep(5000)}
+
Loading shelf...
+ {:then}
+
Failed to load shelf.
+ {/await}
+
+ {/if}
+