Add pinboards and content library

This commit is contained in:
Jon Staab 2026-06-23 16:30:54 -07:00
parent 518a312972
commit 61198f4f10
19 changed files with 1008 additions and 35 deletions

View file

@ -1,5 +1,21 @@
<script module lang="ts">
import {postJson, simpleCache} from "@welshman/lib"
import {dufflepud} from "@app/env"
// Cache previews by url so the same link isn't re-fetched across renders/instances.
const loadPreview = simpleCache(async ([url]: [string]) => {
const json = await postJson(dufflepud("link/preview"), {url})
if (!json?.title && !json?.image) {
throw new Error("Failed to load link preview")
}
return json
})
</script>
<script lang="ts">
import {call, ellipsize, displayUrl, postJson} from "@welshman/lib"
import {call, ellipsize, displayUrl} from "@welshman/lib"
import {isRelayUrl, getTagValue} from "@welshman/util"
import {Capacitor} from "@capacitor/core"
import {preventDefault, stopPropagation} from "@lib/html"
@ -9,7 +25,7 @@
import ContentLinkUrl from "@app/components/ContentLinkUrl.svelte"
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import {pushModal} from "@app/modal"
import {dufflepud, PLATFORM_URL, THUMBNAIL_URL} from "@app/env"
import {PLATFORM_URL, THUMBNAIL_URL} from "@app/env"
import {IMAGE_CONTENT_TYPES, VIDEO_CONTENT_TYPES} from "@app/content"
import {isRoomId} from "@app/groups"
@ -35,16 +51,6 @@
return undefined
}
const loadPreview = async () => {
const json = await postJson(dufflepud("link/preview"), {url})
if (!json?.title && !json?.image) {
throw new Error("Failed to load link preview")
}
return json
}
const onError = () => {
hideImage = true
}
@ -73,7 +79,7 @@
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-2xl" />
</button>
{:else}
{#await loadPreview()}
{#await loadPreview(url)}
<div class="flex justify-center items-center my-12 w-full">
<Spinner />
</div>

View file

@ -35,7 +35,7 @@
let muted = $state($isEventMuted(event))
</script>
<div class="flex flex-col gap-2 {restProps.class}" style={restProps.style}>
<div class="@container flex flex-col gap-2 {restProps.class}" style={restProps.style}>
{#if muted}
<div class="flex items-center justify-between">
<div class="flex gap-2 relative">
@ -54,7 +54,7 @@
{/if}
{/if}
<Button
class={cx("text-sm opacity-75", {"text-xs": minimal})}
class={cx("text-sm opacity-75 @max-sm:hidden", {"text-xs": minimal})}
onclick={() => goToEvent(event)}>
{formatTimestamp(event.created_at)}
</Button>

View file

@ -0,0 +1,120 @@
<script lang="ts">
import * as nip19 from "nostr-tools/nip19"
import {Address} from "@welshman/util"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
import TrashBin from "@assets/icons/trash-bin-2.svg?dataurl"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Badge from "@lib/components/Badge.svelte"
import Popover from "@lib/components/Popover.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import Content from "@app/components/Content.svelte"
import PinContentEvent from "@app/components/PinContentEvent.svelte"
import PinEdit from "@app/components/PinEdit.svelte"
import {deletePin, type Pin} from "@app/pinboards"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
type Props = {
url: string
pin: Pin
minimal?: boolean
showMenu?: boolean
class?: string
}
const {url, pin, minimal, showMenu, ...props}: Props = $props()
// Encode nostr references as nostr: entities (i tags are already urls) so
// Content parses them into rich embeds rather than rendering raw ids/coords.
const content = $derived.by(() => {
const [type, data = ""] = pin.value
if (type === "e") return "nostr:" + nip19.neventEncode({id: data, relays: [url]})
if (type === "a") {
const {kind, pubkey, identifier} = Address.from(data)
return "nostr:" + nip19.naddrEncode({kind, pubkey, identifier, relays: [url]})
}
return data
})
let menuOpen = $state(false)
const edit = () => {
menuOpen = false
pushModal(PinEdit, {url, pin})
}
const confirmDelete = () => {
menuOpen = false
pushModal(Confirm, {
title: "Delete Link",
message: "Delete this link?",
confirm: async () => {
const error = await deletePin(url, pin.id)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Link deleted!"})
}
},
})
}
</script>
<div class="{props.class} relative flex h-full flex-col gap-2">
{#if showMenu}
<div class="absolute right-2 top-2 z-feature">
<Button
class="button button-neutral button-sm button-square"
aria-label="More options"
onclick={() => (menuOpen = !menuOpen)}>
<Icon size={4} icon={MenuDots} />
</Button>
{#if menuOpen}
<Popover hideOnClick onClose={() => (menuOpen = false)}>
<ul
transition:fly
class="menu bg-surface absolute right-0 z-popover mt-2 w-48 gap-1 rounded-2xl p-2">
<li>
<Button onclick={edit}>
<Icon icon={Pen} />
Edit link
</Button>
</li>
<li>
<Button class="text-error" onclick={confirmDelete}>
<Icon icon={TrashBin} />
Delete link
</Button>
</li>
</ul>
</Popover>
{/if}
</div>
{#if pin.title}
<strong class="ellipsize pr-8">{pin.title}</strong>
{/if}
{/if}
{#if pin.value[0] === "i"}
<Content event={{content, tags: []}} {url} />
{:else}
<PinContentEvent
{url}
value={pin.value[1]}
relays={pin.value[2] ? [pin.value[2], url] : [url]} />
{/if}
{#if !minimal && pin.topics.length > 0}
<div class="mt-auto flex flex-wrap gap-1">
{#each pin.topics as topic (topic)}
<Badge>#{topic}</Badge>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import PinForm, {type PinFormValues} from "@app/components/PinForm.svelte"
import {createPin, referenceToPin} from "@app/pinboards"
type Props = {
url: string
address: string
}
const {url, address}: Props = $props()
const submit = async ({title, topics, value}: PinFormValues) => {
const params = referenceToPin(value)
if (!params) return "Please enter a valid URL or nostr link."
return createPin(url, {...params, boards: [address], title, topics})
}
</script>
<PinForm {url} heading="Add Link" action="Add link" successMessage="Link added!" {submit} />

View file

@ -0,0 +1,27 @@
<script lang="ts">
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import NoteItem from "@app/components/NoteItem.svelte"
import {deriveEvent} from "@app/repository"
import {goToEvent} from "@app/routes"
type Props = {
url: string
value: string
relays: string[]
}
const {url, value, relays}: Props = $props()
const event = deriveEvent(value, relays)
</script>
{#if $event}
<Button onclick={() => goToEvent($event)}>
<NoteItem {url} event={$event} />
</Button>
{:else}
<p class="flex justify-center py-8">
<Spinner loading>Loading event...</Spinner>
</p>
{/if}

View file

@ -0,0 +1,34 @@
<script lang="ts">
import PinForm, {type PinFormValues} from "@app/components/PinForm.svelte"
import {createPin, referenceToPin, pinToReference, type Pin} from "@app/pinboards"
type Props = {
url: string
pin: Pin
}
const {url, pin}: Props = $props()
const submit = async ({title, topics, value}: PinFormValues) => {
const params = referenceToPin(value)
if (!params) return "Please enter a valid URL or nostr link."
// Reuse the pin's identifier and boards so its addressable event is replaced.
return createPin(url, {
...params,
identifier: pin.identifier,
boards: pin.boards,
title,
topics,
})
}
</script>
<PinForm
{url}
heading="Edit Link"
action="Save changes"
successMessage="Link updated!"
values={{title: pin.title, topics: pin.topics, value: pinToReference(pin)}}
{submit} />

View file

@ -0,0 +1,107 @@
<script module lang="ts">
export type PinFormValues = {
title: string
topics: string[]
value: string
}
</script>
<script lang="ts">
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import StringMultiInput from "@lib/components/StringMultiInput.svelte"
import RelayName from "@app/components/RelayName.svelte"
import {pushToast} from "@app/toast"
type Props = {
url: string
heading: string
action: string
successMessage: string
values?: Partial<PinFormValues>
submit: (values: PinFormValues) => Promise<string | undefined>
}
const {url, heading, action, successMessage, values = {}, submit}: Props = $props()
let title = $state(values.title ?? "")
let topics = $state<string[]>(values.topics ?? [])
let value = $state(values.value ?? "")
let loading = $state(false)
const back = () => history.back()
const onSubmit = async () => {
loading = true
try {
const error = await submit({title, topics, value})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: successMessage})
back()
}
} finally {
loading = false
}
}
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>{heading}</ModalTitle>
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
</ModalHeader>
<Field>
{#snippet label()}
Title
{/snippet}
{#snippet input()}
<input
bind:value={title}
class="input input-bordered w-full"
placeholder="Optional title" />
{/snippet}
</Field>
<Field>
{#snippet label()}
Topics
{/snippet}
{#snippet input()}
<StringMultiInput bind:value={topics} placeholder="Add a topic..." />
{/snippet}
</Field>
<Field>
{#snippet label()}
Link
{/snippet}
{#snippet info()}
<p>A URL or nostr link (note, nevent, or naddr).</p>
{/snippet}
{#snippet input()}
<input bind:value class="input input-bordered w-full" placeholder="URL or nevent..." />
{/snippet}
</Field>
</ModalBody>
<ModalFooter>
<Button class="button button-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button class="button button-primary" onclick={onSubmit} disabled={loading || !value.trim()}>
<Spinner {loading}>{action}</Spinner>
</Button>
</ModalFooter>
</Modal>

View file

@ -0,0 +1,92 @@
<script lang="ts">
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {goto} from "$app/navigation"
import RelayName from "@app/components/RelayName.svelte"
import {editBoard, type Board} from "@app/pinboards"
import {makeSpacePath} from "@app/routes"
import {pushToast} from "@app/toast"
type Props = {
url: string
board: Board
isNew?: boolean
}
const {url, board, isNew = false}: Props = $props()
let title = $state(board.title)
let description = $state(board.description)
let loading = $state(false)
const back = () => history.back()
const submit = async () => {
loading = true
try {
const error = await editBoard(url, {...board, title, description})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: isNew ? "Shelf created!" : "Shelf updated!"})
// Navigate to a freshly created board; otherwise just close the modal.
if (isNew) {
goto(makeSpacePath(url, "library", board.address))
} else {
back()
}
}
} finally {
loading = false
}
}
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>{isNew ? "Create Shelf" : "Edit Shelf"}</ModalTitle>
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
</ModalHeader>
<Field>
{#snippet label()}
Title
{/snippet}
{#snippet input()}
<input bind:value={title} class="input input-bordered w-full" placeholder="Shelf title" />
{/snippet}
</Field>
<Field>
{#snippet label()}
Description
{/snippet}
{#snippet input()}
<input
bind:value={description}
class="input input-bordered w-full"
placeholder="What's this shelf about?" />
{/snippet}
</Field>
</ModalBody>
<ModalFooter>
<Button class="button button-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button class="button button-primary" onclick={submit} disabled={loading || !title.trim()}>
<Spinner {loading}>Save changes</Spinner>
</Button>
</ModalFooter>
</Modal>

View file

@ -0,0 +1,34 @@
<script lang="ts">
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Pin from "@app/components/Pin.svelte"
import {derivePins, type Board} from "@app/pinboards"
import {makeSpacePath} from "@app/routes"
type Props = {
url: string
board: Board
}
const {url, board}: Props = $props()
const pins = derivePins(url, board.address)
const detailPath = makeSpacePath(url, "library", board.address)
</script>
<div class="card flex flex-col gap-2">
<div class="flex flex-col">
<strong class="text-lg">{board.title || "Untitled shelf"}</strong>
{#if board.description}
<span class="text-sm opacity-70">{board.description}</span>
{/if}
</div>
{#each $pins.slice(0, 1) as pin (pin.id)}
<Pin minimal {url} {pin} />
{/each}
<Link href={detailPath} class="button button-primary">
<Icon icon={AltArrowRight} />
<strong>View all content</strong>
</Link>
</div>

View file

@ -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)}>
<Icon icon={Danger} />
<span>{$reports.length}</span>
</Button>
@ -186,7 +186,7 @@
"button-primary": isOwn,
"button-neutral": !isOwn,
})}
onclick={onClick}>
onclick={stopPropagation(onClick)}>
<Reaction event={events[0]} />
{#if events.length > 1}
<span>{events.length}</span>

View file

@ -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 @@
<SecondaryNavItem href={makeSpacePath(url, "directory")}>
<Icon icon={UsersGroup} /> Directory
</SecondaryNavItem>
{#if showLibrary}
<SecondaryNavItem href={makeSpacePath(url, "library")}>
<Icon icon={GalleryWide} /> Library
</SecondaryNavItem>
{/if}
{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)}
<SecondaryNavItem href={goalsPath}>
<Icon icon={StarFallMinimalistic} /> Goals

View file

@ -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", <value>] 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

213
src/app/pinboards.ts Normal file
View file

@ -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", <id>],
// ["a", <coord>], or ["i", <external id>]. 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<string | undefined> => {
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<Board, "address">) => ({
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<Pin>) => ({
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<Pin>) => 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<Pin> | 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
}

View file

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

View file

@ -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}),
],

View file

@ -0,0 +1,27 @@
<script lang="ts">
import type {Snippet} from "svelte"
type Props = {
items: any[]
child: Snippet<[any]>
getKey: (item: any) => PropertyKey
// Minimum column width, in tailwind spacing units (1 = 0.25rem). The browser
// fits as many columns as the container allows, so the layout stays
// responsive without media queries.
columnWidth?: number
// Space between columns and between stacked items, in tailwind units.
gap?: number
}
const {items, child, getKey, columnWidth = 72, gap = 3}: Props = $props()
const rem = (units: number) => `${units * 0.25}rem`
</script>
<div style="column-width: {rem(columnWidth)}; column-gap: {rem(gap)};">
{#each items as item (getKey(item))}
<div class="break-inside-avoid" style="margin-bottom: {rem(gap)};">
{@render child(item)}
</div>
{/each}
</div>

View file

@ -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,
})

View file

@ -0,0 +1,107 @@
<script lang="ts">
import {onMount} from "svelte"
import {get} from "svelte/store"
import {page} from "$app/stores"
import {randomId} from "@welshman/lib"
import {deriveRelay} from "@welshman/app"
import {load} from "@welshman/net"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
import Add from "@assets/icons/add.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Masonry from "@lib/components/Masonry.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import PinboardItem from "@app/components/PinboardItem.svelte"
import PinboardEdit from "@app/components/PinboardEdit.svelte"
import {decodeRelay, deriveSupportedMethods} from "@app/relays"
import {deriveBoards, BOARD, PIN, type Board} from "@app/pinboards"
import {pushModal} from "@app/modal"
const url = decodeRelay($page.params.relay!)
const boards = deriveBoards(url)
const supportedMethods = deriveSupportedMethods(url)
const canManage = $derived($supportedMethods.some(m => (m as string) === "signevent"))
let term = $state("")
let loading = $state(true)
const filtered = $derived.by(() => {
const value = term.trim().toLowerCase()
if (!value) return $boards
return $boards.filter(board =>
[board.title, board.description, ...board.topics].some(field =>
field?.toLowerCase().includes(value),
),
)
})
const createBoard = () => {
const self = get(deriveRelay(url))?.self ?? ""
const identifier = randomId()
const board: Board = {
address: `${BOARD}:${self}:${identifier}`,
identifier,
title: "",
description: "",
image: "",
topics: [],
collaborative: false,
}
pushModal(PinboardEdit, {url, board, isNew: true})
}
onMount(() => {
const controller = new AbortController()
load({relays: [url], filters: [{kinds: [BOARD, PIN]}], signal: controller.signal}).then(() => {
loading = false
})
return () => controller.abort()
})
</script>
<SpaceBar>
{#snippet leading()}
<Icon icon={GalleryWide} />
{/snippet}
{#snippet title()}
<strong>Library</strong>
{/snippet}
{#snippet action()}
{#if canManage}
<Button class="button button-primary button-sm" onclick={createBoard}>
<Icon icon={Add} />
Create Shelf
</Button>
{/if}
{/snippet}
</SpaceBar>
<PageContent class="flex flex-col gap-4 p-4">
<div class="card flex flex-col gap-2">
<label class="input input-sm input-group flex w-full items-center gap-2">
<Icon size={4} icon={Magnifier} />
<input bind:value={term} class="min-w-0 grow" type="text" placeholder="Search library..." />
</label>
<Masonry items={filtered} getKey={board => board.address} columnWidth={80} gap={2}>
{#snippet child(board)}
<PinboardItem {url} {board} />
{/snippet}
</Masonry>
{#if loading}
<p class="flex items-center justify-center py-20">
<Spinner loading>Loading library...</Spinner>
</p>
{:else if filtered.length === 0}
<p class="flex flex-col items-center py-20 text-center">No shelves found.</p>
{/if}
</div>
</PageContent>

View file

@ -0,0 +1,176 @@
<script lang="ts">
import {onMount} from "svelte"
import {page} from "$app/stores"
import {sleep} from "@welshman/lib"
import type {MakeNonOptional} from "@welshman/lib"
import {getIdFilters} from "@welshman/util"
import {request} from "@welshman/net"
import {fly} from "@lib/transition"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
import TrashBin from "@assets/icons/trash-bin-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Popover from "@lib/components/Popover.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Masonry from "@lib/components/Masonry.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import Pin from "@app/components/Pin.svelte"
import PinboardEdit from "@app/components/PinboardEdit.svelte"
import PinAdd from "@app/components/PinAdd.svelte"
import {decodeRelay, deriveSupportedMethods} from "@app/relays"
import {deriveBoardByAddress, derivePins, deleteBoard, PIN} from "@app/pinboards"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
const {relay, address} = $page.params as MakeNonOptional<typeof $page.params>
const url = decodeRelay(relay)
const board = deriveBoardByAddress(url, address)
const pins = derivePins(url, address)
const supportedMethods = deriveSupportedMethods(url)
const canManage = $derived($supportedMethods.some(m => (m as string) === "signevent"))
let term = $state("")
let menuOpen = $state(false)
const filtered = $derived.by(() => {
const value = term.trim().toLowerCase()
if (!value) return $pins
return $pins.filter(pin =>
[pin.title, pin.comment, pin.value[0] === "i" ? pin.value[1] : "", ...pin.topics].some(
field => field?.toLowerCase().includes(value),
),
)
})
const back = () => history.back()
const addLink = () => {
menuOpen = false
pushModal(PinAdd, {url, address})
}
const edit = () => {
menuOpen = false
pushModal(PinboardEdit, {url, board: $board})
}
const confirmDelete = () => {
menuOpen = false
pushModal(Confirm, {
title: "Delete Shelf",
message: `Delete "${$board?.title || "this shelf"}"?`,
confirm: async () => {
const error = await deleteBoard(url, address)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Shelf deleted!"})
back()
}
},
})
}
onMount(() => {
const controller = new AbortController()
request({
relays: [url],
filters: [...getIdFilters([address]), {kinds: [PIN], "#A": [address]}],
signal: controller.signal,
})
return () => controller.abort()
})
</script>
<SpaceBar {back}>
{#snippet title()}
<h1 class="ellipsize text-xl">{$board?.title || "Shelf"}</h1>
{/snippet}
{#snippet action()}
{#if canManage}
<div class="relative">
<Button
class="button button-neutral button-sm button-square"
aria-label="More options"
onclick={() => (menuOpen = !menuOpen)}>
<Icon size={4} icon={MenuDots} />
</Button>
{#if menuOpen}
<Popover hideOnClick onClose={() => (menuOpen = false)}>
<ul
transition:fly
class="menu bg-surface absolute right-0 z-popover mt-2 w-48 gap-1 rounded-2xl p-2">
<li>
<Button onclick={addLink}>
<Icon icon={AddCircle} />
Add link
</Button>
</li>
<li>
<Button onclick={edit}>
<Icon icon={Pen} />
Edit shelf
</Button>
</li>
<li>
<Button class="text-error" onclick={confirmDelete}>
<Icon icon={TrashBin} />
Delete shelf
</Button>
</li>
</ul>
</Popover>
{/if}
</div>
{/if}
{/snippet}
</SpaceBar>
<PageContent class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
{#if $board}
{#if $pins.length === 0}
<div class="flex flex-col items-center gap-4 py-20 text-center">
<p class="opacity-70">This shelf doesn't have any links yet.</p>
{#if canManage}
<Button class="button button-primary" onclick={addLink}>
<Icon icon={AddCircle} />
Add a link
</Button>
{/if}
</div>
{:else}
<label class="input input-sm input-group flex w-full items-center gap-2">
<Icon size={4} icon={Magnifier} />
<input bind:value={term} class="min-w-0 grow" type="text" placeholder="Search links..." />
</label>
{#if filtered.length === 0}
<p class="py-20 text-center opacity-70">No links found.</p>
{:else}
<Masonry items={filtered} getKey={pin => pin.id} columnWidth={60} gap={3}>
{#snippet child(pin)}
<Pin {url} {pin} showMenu={canManage} class="card" />
{/snippet}
</Masonry>
{/if}
{/if}
{:else}
<div class="flex justify-center py-20">
{#await sleep(5000)}
<Spinner loading>Loading shelf...</Spinner>
{:then}
<p>Failed to load shelf.</p>
{/await}
</div>
{/if}
</PageContent>