diff --git a/src/app/components/ContentLinkBlock.svelte b/src/app/components/ContentLinkBlock.svelte index 459d1dd8..7c899613 100644 --- a/src/app/components/ContentLinkBlock.svelte +++ b/src/app/components/ContentLinkBlock.svelte @@ -1,5 +1,21 @@ + + -
+
{#if muted}
@@ -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} +