flotilla/src/app/pinboards.ts

237 lines
6.3 KiB
TypeScript
Raw Normal View History

import {derived, readable} from "svelte/store"
import type {Readable} from "svelte/store"
2026-06-23 23:30:54 +00:00
import * as nip19 from "nostr-tools/nip19"
import {now, randomId} from "@welshman/lib"
import {
Address,
DELETE,
asDecryptedEvent,
fromNostrURI,
getAddress,
getListTags,
getTagValue,
getTagValues,
isReplaceableKind,
2026-06-23 23:30:54 +00:00
readList,
sortEventsDesc,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {isLink, parse} from "@welshman/content"
2026-06-23 23:30:54 +00:00
import {deriveEventsForUrl, deriveRelaySignedEvents} from "@app/repository"
import {signAsRelay} from "@app/relays"
2026-06-23 23:30:54 +00:00
export const BOARD = 30067
export const PIN = 39067
export type Board = {
event?: TrustedEvent
2026-06-23 23:30:54 +00:00
address: string
identifier: string
title: string
description: string
image: string
topics: string[]
collaborative: boolean
}
export type PublishedBoard = Board & {
event: TrustedEvent
}
2026-06-23 23:30:54 +00:00
export type Pin = {
event?: TrustedEvent
2026-06-23 23:30:54 +00:00
id: string
pubkey: string
identifier: string
boards: string[]
2026-07-06 18:53:12 +00:00
description: string
2026-06-23 23:30:54 +00:00
title: string
topics: string[]
// The pin's content reference: the first e/a/p/i tag found, e.g. ["e", <id>],
// ["a", <coord>], ["p", <pubkey>], or ["i", <url>]. Empty when the pin has no
// reference.
2026-06-23 23:30:54 +00:00
value: string[]
}
export type PublishedPin = Pin & {
event: TrustedEvent
}
export const readBoard = (event: TrustedEvent): PublishedBoard => {
2026-06-23 23:30:54 +00:00
const tags = getListTags(readList(asDecryptedEvent(event)))
return {
event,
2026-06-23 23:30:54 +00:00
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): PublishedPin => ({
event,
2026-06-23 23:30:54 +00:00
id: event.id,
pubkey: event.pubkey,
identifier: getTagValue("d", event.tags) ?? "",
boards: getTagValues("A", event.tags),
2026-07-06 18:53:12 +00:00
description: event.content,
2026-06-23 23:30:54 +00:00
title: getTagValue("title", event.tags) ?? "",
topics: getTagValues("t", event.tags),
value: event.tags.find(tag => ["e", "a", "p", "i"].includes(tag[0])) ?? [],
2026-06-23 23:30:54 +00:00
})
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,
): Readable<PublishedBoard | undefined> =>
Address.isAddress(address)
? deriveBoard(url, Address.from(address).identifier)
: readable(undefined)
2026-06-23 23:30:54 +00:00
export const derivePins = (url: string, address: string) =>
derived(deriveRelaySignedEvents(url, [{kinds: [PIN], "#A": [address]}]), $events =>
sortEventsDesc($events).map(readPin),
)
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 = [],
2026-07-06 18:53:12 +00:00
description = "",
2026-06-23 23:30:54 +00:00
title = "",
topics = [],
value = [],
}: Partial<Pin>) => ({
kind: PIN,
created_at: now(),
2026-07-06 18:53:12 +00:00
content: description,
2026-06-23 23:30:54 +00:00
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)],
]),
)
const valueToReference = (value: string[]): string => {
const [type, data = "", relay] = value
2026-06-23 23:30:54 +00:00
const relays = relay ? [relay] : []
if (type === "e") return nip19.neventEncode({id: data, relays})
if (type === "p") return nip19.nprofileEncode({pubkey: data, relays})
2026-06-23 23:30:54 +00:00
if (type === "a") {
const {kind, pubkey, identifier} = Address.from(data)
return nip19.naddrEncode({kind, pubkey, identifier, relays})
}
return data
}
export const pinToReference = (pin: Pin): string => valueToReference(pin.value)
export const eventToReference = (event: TrustedEvent): string =>
valueToReference(isReplaceableKind(event.kind) ? ["a", getAddress(event)] : ["e", event.id])
2026-06-23 23:30:54 +00:00
export const referenceToPin = (reference: string): Partial<Pin> | undefined => {
const trimmed = reference.trim()
try {
const decoded = nip19.decode(fromNostrURI(trimmed))
if (decoded.type === "note") return {value: ["e", decoded.data]}
if (decoded.type === "npub") return {value: ["p", decoded.data]}
2026-06-23 23:30:54 +00:00
if (decoded.type === "nevent") {
const {id, relays} = decoded.data
return {value: relays?.[0] ? ["e", id, relays[0]] : ["e", id]}
}
if (decoded.type === "nprofile") {
const {pubkey, relays} = decoded.data
return {value: relays?.[0] ? ["p", pubkey, relays[0]] : ["p", pubkey]}
}
2026-06-23 23:30:54 +00:00
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]}
}
return undefined
2026-06-23 23:30:54 +00:00
} catch {
// Not a nostr entity; fall through to external url handling.
}
const parsed = parse({content: trimmed})
return parsed.length === 1 && isLink(parsed[0]) ? {value: ["i", trimmed]} : undefined
2026-06-23 23:30:54 +00:00
}