Build out @welshman/domain on top of the DomainObject/EncryptableList base
patterns, porting domain-object use cases from @welshman/util and flotilla
that weren't yet represented.
New classes:
- Relay lists: RelayList (NIP-65 read/write markers), Blocked/Search/Messaging
relay lists, RelaySet (NIP-51 30002 named set)
- Server lists: Blossom, FileServer
- NIP-51 lists: Follow, Pin, Bookmark, Community, Channel, Room, Feed, Topic,
Emoji
- Zaps: ZapRequest, ZapReceipt, ZapGoal
- NIP-89 handlers: Handler, HandlerRecommendation
- Rooms/groups (NIP-29): RoomMeta, RoomAdmins, RoomMembers, RoomMembershipOp,
Room create/delete/join/leave, RoomCreatePermission, RelayMembers,
RelayMembershipOp, Relay join/leave/invite
- Content: Poll, PollResponse, Thread, Comment, Classified, CalendarEvent,
Report, Feed, Settings
Also fix unfinished accessors in Profile, method-call bugs in MuteList, and
correct RelayList.set{Read,Write}Relays to preserve a relay's complementary
read/write capability instead of dropping modeless entries.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V67tPYdvh1qCkjEBhJGZUR
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
import {uniqBy} from "@welshman/lib"
|
||||||
|
import {BLOCKED_RELAYS, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
||||||
|
import {EncryptableList} from "./List.js"
|
||||||
|
|
||||||
|
// NIP-51 kind-10006 blocked relays. Entries are marker-less ['relay', url] tags
|
||||||
|
// (NOT NIP-65 'r' tags with read/write markers). `urls()` gates AUTH (never auth
|
||||||
|
// to a blocked relay) and relay selection, so it stays a flat, normalized set.
|
||||||
|
export class BlockedRelayList extends EncryptableList {
|
||||||
|
readonly kind = BLOCKED_RELAYS
|
||||||
|
|
||||||
|
urls() {
|
||||||
|
return uniqBy(normalizeRelayUrl, getTagValues("relay", this.tags()))
|
||||||
|
}
|
||||||
|
|
||||||
|
addRelay(url: string) {
|
||||||
|
return this.addPublicTags(["relay", normalizeRelayUrl(url)])
|
||||||
|
}
|
||||||
|
|
||||||
|
removeRelay(url: string) {
|
||||||
|
return this.removeTagsWithValue(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
setRelays(urls: string[]) {
|
||||||
|
this.keepTagsWithKey("relay")
|
||||||
|
|
||||||
|
return this.addPublicTags(...urls.map(url => ["relay", normalizeRelayUrl(url)]))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import {uniq} from "@welshman/lib"
|
||||||
|
import {BLOSSOM_SERVERS, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
||||||
|
import {EncryptableList} from "./List.js"
|
||||||
|
|
||||||
|
// Blossom BUD-03 user server list (kind 10063). Server endpoints are stored as
|
||||||
|
// `["server", url]` tags (NOT the `r`/`relay` tags used by relay lists), so the
|
||||||
|
// generic relay-tag helpers would miss them. Effectively public-only.
|
||||||
|
export class BlossomServerList extends EncryptableList {
|
||||||
|
readonly kind = BLOSSOM_SERVERS
|
||||||
|
|
||||||
|
servers() {
|
||||||
|
return uniq(getTagValues("server", this.tags()).map(normalizeRelayUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
includes(url: string) {
|
||||||
|
return this.servers().includes(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
addServer(url: string) {
|
||||||
|
return this.addPublicTags(["server", normalizeRelayUrl(url)])
|
||||||
|
}
|
||||||
|
|
||||||
|
removeServer(url: string) {
|
||||||
|
return this.removeTagsWithValue(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
setServers(urls: string[]) {
|
||||||
|
this.keepTagsWithKey("server")
|
||||||
|
|
||||||
|
return this.addPublicTags(...urls.map(url => ["server", normalizeRelayUrl(url)]))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import {uniq} from "@welshman/lib"
|
||||||
|
import {
|
||||||
|
BOOKMARKS,
|
||||||
|
getEventTagValues,
|
||||||
|
getAddressTagValues,
|
||||||
|
getTopicTagValues,
|
||||||
|
getTagValues,
|
||||||
|
} from "@welshman/util"
|
||||||
|
import {EncryptableList} from "./List.js"
|
||||||
|
|
||||||
|
// NIP-51 kind-10003 bookmark list. Mixed entries (notes via 'e', articles via
|
||||||
|
// 'a', hashtags via 't', urls via 'r') can be bookmarked publicly (tags) or
|
||||||
|
// privately (encrypted content); accessors treat both as one merged set.
|
||||||
|
export class BookmarkList extends EncryptableList {
|
||||||
|
readonly kind = BOOKMARKS
|
||||||
|
|
||||||
|
ids() {
|
||||||
|
return uniq(getEventTagValues(this.tags()))
|
||||||
|
}
|
||||||
|
|
||||||
|
addresses() {
|
||||||
|
return uniq(getAddressTagValues(this.tags()))
|
||||||
|
}
|
||||||
|
|
||||||
|
topics() {
|
||||||
|
return uniq(getTopicTagValues(this.tags()))
|
||||||
|
}
|
||||||
|
|
||||||
|
urls() {
|
||||||
|
return uniq(getTagValues("r", this.tags()))
|
||||||
|
}
|
||||||
|
|
||||||
|
bookmarkPublicly(tag: string[]) {
|
||||||
|
return this.addPublicTags(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
bookmarkPrivately(tag: string[]) {
|
||||||
|
return this.addPrivateTags(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeBookmark(value: string) {
|
||||||
|
return this.removeTagsWithValue(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import {EVENT_TIME, getIdentifier, getTagValue, getTagValues, getAddress} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type CalendarEventValues = {
|
||||||
|
identifier: string
|
||||||
|
title?: string
|
||||||
|
location?: string
|
||||||
|
content: string
|
||||||
|
start?: number
|
||||||
|
end?: number
|
||||||
|
days: string[]
|
||||||
|
h?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeCalendarEventValues = (
|
||||||
|
values: Partial<CalendarEventValues> = {},
|
||||||
|
): CalendarEventValues => ({
|
||||||
|
identifier: "",
|
||||||
|
content: "",
|
||||||
|
days: [],
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// NIP-52 kind-31923 time-based calendar event. Addressable via the "d" tag.
|
||||||
|
// `start`/`end` are unix-second timestamps carried in "start"/"end" tags
|
||||||
|
// (parsed with parseInt), `title` falls back to the legacy "name" tag, and the
|
||||||
|
// plain-text body lives in `content`. Flotilla additionally writes per-day
|
||||||
|
// ["D", "YYYY-MM-DD"] tags spanning start..end for day-bucket querying, and an
|
||||||
|
// optional "h" tag scoping the event to a room (commented via "#A"). This is the
|
||||||
|
// only calendar object flotilla uses (CALENDAR 31924 / EVENT_DATE 31922 /
|
||||||
|
// EVENT_RSVP 31925 are not used). Tags + plain content, so it extends
|
||||||
|
// DomainObject directly.
|
||||||
|
export class CalendarEvent extends DomainObject<CalendarEventValues> {
|
||||||
|
readonly kind = EVENT_TIME
|
||||||
|
values = makeCalendarEventValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<CalendarEventValues> = {}) {
|
||||||
|
return makeCalendarEventValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<CalendarEventValues> {
|
||||||
|
const start = parseInt(getTagValue("start", event.tags)!)
|
||||||
|
const end = parseInt(getTagValue("end", event.tags)!)
|
||||||
|
|
||||||
|
return {
|
||||||
|
identifier: getIdentifier(event) || "",
|
||||||
|
title: getTagValue("title", event.tags) || getTagValue("name", event.tags),
|
||||||
|
location: getTagValue("location", event.tags),
|
||||||
|
content: event.content || "",
|
||||||
|
start: isNaN(start) ? undefined : start,
|
||||||
|
end: isNaN(end) ? undefined : end,
|
||||||
|
days: getTagValues("D", event.tags),
|
||||||
|
h: getTagValue("h", event.tags),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
identifier() {
|
||||||
|
return this.values.identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
title() {
|
||||||
|
return this.values.title
|
||||||
|
}
|
||||||
|
|
||||||
|
location() {
|
||||||
|
return this.values.location
|
||||||
|
}
|
||||||
|
|
||||||
|
content() {
|
||||||
|
return this.values.content
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
return this.values.start
|
||||||
|
}
|
||||||
|
|
||||||
|
end() {
|
||||||
|
return this.values.end
|
||||||
|
}
|
||||||
|
|
||||||
|
days() {
|
||||||
|
return this.values.days
|
||||||
|
}
|
||||||
|
|
||||||
|
h() {
|
||||||
|
return this.values.h
|
||||||
|
}
|
||||||
|
|
||||||
|
room() {
|
||||||
|
return this.values.h
|
||||||
|
}
|
||||||
|
|
||||||
|
address() {
|
||||||
|
return getAddress(this.event!)
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
const {identifier, title, location, content, start, end, days, h} = this.values
|
||||||
|
|
||||||
|
const tags: string[][] = [["d", identifier]]
|
||||||
|
|
||||||
|
if (title) tags.push(["title", title])
|
||||||
|
if (location) tags.push(["location", location])
|
||||||
|
if (start != null) tags.push(["start", String(start)])
|
||||||
|
if (end != null) tags.push(["end", String(end)])
|
||||||
|
|
||||||
|
for (const day of days) {
|
||||||
|
tags.push(["D", day])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (h) tags.push(["h", h])
|
||||||
|
|
||||||
|
return {kind: this.kind, content, tags}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import {uniq} from "@welshman/lib"
|
||||||
|
import {CHANNELS, getEventTagValues} from "@welshman/util"
|
||||||
|
import {EncryptableList} from "./List.js"
|
||||||
|
|
||||||
|
// NIP-51 kind-10005 public chat channel list. Entries are `e` tags pointing at
|
||||||
|
// NIP-28 kind-40 channel-create events.
|
||||||
|
export class ChannelList extends EncryptableList {
|
||||||
|
readonly kind = CHANNELS
|
||||||
|
|
||||||
|
ids() {
|
||||||
|
return uniq(getEventTagValues(this.tags()))
|
||||||
|
}
|
||||||
|
|
||||||
|
add(id: string, relayHint?: string) {
|
||||||
|
return this.addPublicTags(["e", id, relayHint || ""])
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(id: string) {
|
||||||
|
return this.removeTagsWithValue(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import {
|
||||||
|
CLASSIFIED,
|
||||||
|
getAddress,
|
||||||
|
getIdentifier,
|
||||||
|
getTag,
|
||||||
|
getTagValue,
|
||||||
|
getTagValues,
|
||||||
|
getTopicTagValues,
|
||||||
|
} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type ClassifiedValues = {
|
||||||
|
identifier: string
|
||||||
|
title?: string
|
||||||
|
summary?: string
|
||||||
|
content: string
|
||||||
|
price?: {amount: number; currency: string}
|
||||||
|
status?: string
|
||||||
|
images: string[]
|
||||||
|
topics: string[]
|
||||||
|
h?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeClassifiedValues = (
|
||||||
|
values: Partial<ClassifiedValues> = {},
|
||||||
|
): ClassifiedValues => ({
|
||||||
|
identifier: "",
|
||||||
|
content: "",
|
||||||
|
images: [],
|
||||||
|
topics: [],
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// NIP-99 kind-30402 addressable classified listing. Addressable via the "d"
|
||||||
|
// tag; the listing description lives in `content` as plain text (not JSON). The
|
||||||
|
// price is carried in a ["price", amount, currency] tag with the currency
|
||||||
|
// defaulting to "SAT", images in repeated "image" tags, topics in "t" tags, and
|
||||||
|
// an optional "h" tag scopes the listing to a room. Tags-only metadata, so it
|
||||||
|
// extends DomainObject directly. Commented via "#A" (kind 1111 comments).
|
||||||
|
export class Classified extends DomainObject<ClassifiedValues> {
|
||||||
|
readonly kind = CLASSIFIED
|
||||||
|
values = makeClassifiedValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<ClassifiedValues> = {}) {
|
||||||
|
return makeClassifiedValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<ClassifiedValues> {
|
||||||
|
const priceTag = getTag("price", event.tags)
|
||||||
|
|
||||||
|
return {
|
||||||
|
identifier: getIdentifier(event) || "",
|
||||||
|
title: getTagValue("title", event.tags),
|
||||||
|
summary: getTagValue("summary", event.tags),
|
||||||
|
content: event.content || "",
|
||||||
|
price: priceTag
|
||||||
|
? {amount: parseFloat(priceTag[1]) || 0, currency: priceTag[2] || "SAT"}
|
||||||
|
: undefined,
|
||||||
|
status: getTagValue("status", event.tags),
|
||||||
|
images: getTagValues("image", event.tags),
|
||||||
|
topics: getTopicTagValues(event.tags),
|
||||||
|
h: getTagValue("h", event.tags),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
identifier() {
|
||||||
|
return this.values.identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
title() {
|
||||||
|
return this.values.title
|
||||||
|
}
|
||||||
|
|
||||||
|
summary() {
|
||||||
|
return this.values.summary
|
||||||
|
}
|
||||||
|
|
||||||
|
content() {
|
||||||
|
return this.values.content
|
||||||
|
}
|
||||||
|
|
||||||
|
price() {
|
||||||
|
return this.values.price
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
return this.values.status
|
||||||
|
}
|
||||||
|
|
||||||
|
images() {
|
||||||
|
return this.values.images
|
||||||
|
}
|
||||||
|
|
||||||
|
topics() {
|
||||||
|
return this.values.topics
|
||||||
|
}
|
||||||
|
|
||||||
|
h() {
|
||||||
|
return this.values.h
|
||||||
|
}
|
||||||
|
|
||||||
|
room() {
|
||||||
|
return this.values.h
|
||||||
|
}
|
||||||
|
|
||||||
|
address() {
|
||||||
|
return getAddress(this.event!)
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
const tags: string[][] = [["d", this.values.identifier]]
|
||||||
|
|
||||||
|
if (this.values.title) {
|
||||||
|
tags.push(["title", this.values.title])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.values.summary) {
|
||||||
|
tags.push(["summary", this.values.summary])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.values.price) {
|
||||||
|
tags.push(["price", String(this.values.price.amount), this.values.price.currency])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.values.status) {
|
||||||
|
tags.push(["status", this.values.status])
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const topic of this.values.topics) {
|
||||||
|
tags.push(["t", topic])
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const image of this.values.images) {
|
||||||
|
tags.push(["image", image])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.values.h) {
|
||||||
|
tags.push(["h", this.values.h])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {kind: this.kind, content: this.values.content, tags}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import {COMMENT, getTagValue} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type CommentValues = {
|
||||||
|
content: string
|
||||||
|
rootId?: string
|
||||||
|
rootAddress?: string
|
||||||
|
rootKind?: string
|
||||||
|
rootPubkey?: string
|
||||||
|
parentId?: string
|
||||||
|
parentAddress?: string
|
||||||
|
parentKind?: string
|
||||||
|
tags: string[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeCommentValues = (values: Partial<CommentValues> = {}): CommentValues => ({
|
||||||
|
content: "",
|
||||||
|
tags: [],
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// NIP-22 kind-1111 generic comment, flotilla's universal reply primitive: threads,
|
||||||
|
// goals, and polls reference their root event via uppercase E/A/K/P tags, while
|
||||||
|
// classifieds and calendar events reference addressable roots via #A. Uppercase
|
||||||
|
// tags (E/A/K/P) name the root of the thread; lowercase tags (e/a/k) name the
|
||||||
|
// immediate parent. The comment body lives in `content` as plain text (not JSON).
|
||||||
|
//
|
||||||
|
// Flotilla builds the reference tags at call sites via @welshman/app's
|
||||||
|
// `tagEventForComment(event, url)`, so this class round-trips the raw `tags`
|
||||||
|
// rather than reconstructing them.
|
||||||
|
export class Comment extends DomainObject<CommentValues> {
|
||||||
|
readonly kind = COMMENT
|
||||||
|
values = makeCommentValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<CommentValues> = {}) {
|
||||||
|
return makeCommentValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<CommentValues> {
|
||||||
|
return {
|
||||||
|
content: event.content || "",
|
||||||
|
rootId: getTagValue("E", event.tags),
|
||||||
|
rootAddress: getTagValue("A", event.tags),
|
||||||
|
rootKind: getTagValue("K", event.tags),
|
||||||
|
rootPubkey: getTagValue("P", event.tags),
|
||||||
|
parentId: getTagValue("e", event.tags),
|
||||||
|
parentAddress: getTagValue("a", event.tags),
|
||||||
|
parentKind: getTagValue("k", event.tags),
|
||||||
|
tags: event.tags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content() {
|
||||||
|
return this.values.content
|
||||||
|
}
|
||||||
|
|
||||||
|
rootId() {
|
||||||
|
return this.values.rootId
|
||||||
|
}
|
||||||
|
|
||||||
|
rootAddress() {
|
||||||
|
return this.values.rootAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
rootKind() {
|
||||||
|
return this.values.rootKind
|
||||||
|
}
|
||||||
|
|
||||||
|
rootPubkey() {
|
||||||
|
return this.values.rootPubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
parentId() {
|
||||||
|
return this.values.parentId
|
||||||
|
}
|
||||||
|
|
||||||
|
parentAddress() {
|
||||||
|
return this.values.parentAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
parentKind() {
|
||||||
|
return this.values.parentKind
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
return {
|
||||||
|
kind: this.kind,
|
||||||
|
content: this.values.content,
|
||||||
|
tags: this.values.tags,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import {uniq} from "@welshman/lib"
|
||||||
|
import {COMMUNITIES, getAddressTagValues} from "@welshman/util"
|
||||||
|
import {EncryptableList} from "./List.js"
|
||||||
|
|
||||||
|
// NIP-51 kind-10004 community membership list. Entries are `a` tags pointing at
|
||||||
|
// kind-34550 community definitions.
|
||||||
|
export class CommunityList extends EncryptableList {
|
||||||
|
readonly kind = COMMUNITIES
|
||||||
|
|
||||||
|
addresses() {
|
||||||
|
return uniq(getAddressTagValues(this.tags()))
|
||||||
|
}
|
||||||
|
|
||||||
|
add(address: string, relayHint?: string) {
|
||||||
|
return this.addPublicTags(["a", address, relayHint || ""])
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(address: string) {
|
||||||
|
return this.removeTagsWithValue(address)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import {uniq} from "@welshman/lib"
|
||||||
|
import {EMOJIS, getAddressTagValues} from "@welshman/util"
|
||||||
|
import {EncryptableList} from "./List.js"
|
||||||
|
|
||||||
|
// NIP-51 / NIP-30 kind-10030 user emoji list. Holds references to kind 30030
|
||||||
|
// emoji sets via `a` tags, plus inline `["emoji", shortcode, url]` tags.
|
||||||
|
export class EmojiList extends EncryptableList {
|
||||||
|
readonly kind = EMOJIS
|
||||||
|
|
||||||
|
addresses() {
|
||||||
|
return uniq(getAddressTagValues(this.tags()))
|
||||||
|
}
|
||||||
|
|
||||||
|
emojis() {
|
||||||
|
return this.tags().filter(t => t[0] === "emoji")
|
||||||
|
}
|
||||||
|
|
||||||
|
addEmoji(shortcode: string, url: string) {
|
||||||
|
return this.addPublicTags(["emoji", shortcode, url])
|
||||||
|
}
|
||||||
|
|
||||||
|
addSet(address: string) {
|
||||||
|
return this.addPublicTags(["a", address])
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(value: string) {
|
||||||
|
return this.removeTagsWithValue(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import {parseJson} from "@welshman/lib"
|
||||||
|
import {FEED, getIdentifier, getTagValue, getAddress} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type FeedValues = {
|
||||||
|
identifier: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
// The feed definition is a @welshman/feeds `IFeed` AST. That package is not a
|
||||||
|
// dependency of @welshman/domain, so it is typed as `unknown` here.
|
||||||
|
definition: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeFeedValues = (values: Partial<FeedValues> = {}): FeedValues => ({
|
||||||
|
identifier: "",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
definition: undefined,
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// NIP-51 kind-31890 saved-feed DEFINITION event. Addressable via the "d" tag.
|
||||||
|
// The feed definition is a @welshman/feeds `IFeed` AST, JSON-encoded in a "feed"
|
||||||
|
// tag. Content is empty (tags-only, no encryption). This is distinct from the
|
||||||
|
// kind-10014 FEEDS favorites list (FeedList.ts) which references these by
|
||||||
|
// address. Flotilla's isTopicFeed/isMentionFeed/isAddressFeed/isContextFeed/
|
||||||
|
// isPeopleFeed are pure functions over the IFeed AST and stay in flotilla's lib,
|
||||||
|
// not on this class. Tags-only, so it extends DomainObject directly.
|
||||||
|
export class Feed extends DomainObject<FeedValues> {
|
||||||
|
readonly kind = FEED
|
||||||
|
values = makeFeedValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<FeedValues> = {}) {
|
||||||
|
return makeFeedValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<FeedValues> {
|
||||||
|
return {
|
||||||
|
identifier: getIdentifier(event) || "",
|
||||||
|
title: getTagValue("title", event.tags) || "",
|
||||||
|
description: getTagValue("description", event.tags) || "",
|
||||||
|
definition: parseJson(getTagValue("feed", event.tags) || ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
identifier() {
|
||||||
|
return this.values.identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
title() {
|
||||||
|
return this.values.title
|
||||||
|
}
|
||||||
|
|
||||||
|
description() {
|
||||||
|
return this.values.description
|
||||||
|
}
|
||||||
|
|
||||||
|
definition() {
|
||||||
|
return this.values.definition
|
||||||
|
}
|
||||||
|
|
||||||
|
display() {
|
||||||
|
return this.values.title || "[no name]"
|
||||||
|
}
|
||||||
|
|
||||||
|
getAddress() {
|
||||||
|
return getAddress(this.event!)
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
const {identifier, title, description, definition} = this.values
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: this.kind,
|
||||||
|
content: "",
|
||||||
|
tags: [
|
||||||
|
["d", identifier],
|
||||||
|
["alt", title],
|
||||||
|
["title", title],
|
||||||
|
["description", description],
|
||||||
|
["feed", JSON.stringify(definition)],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import {uniq} from "@welshman/lib"
|
||||||
|
import {FEEDS, getAddressTagValues} from "@welshman/util"
|
||||||
|
import {EncryptableList} from "./List.js"
|
||||||
|
|
||||||
|
// NIP-51 kind-10014 saved feeds list. Entries are `a` tags pointing at kind 31890
|
||||||
|
// FEED definitions. Extends EncryptableList; exposes the addresses as a merged set.
|
||||||
|
export class FeedList extends EncryptableList {
|
||||||
|
readonly kind = FEEDS
|
||||||
|
|
||||||
|
addresses() {
|
||||||
|
return uniq(getAddressTagValues(this.tags()))
|
||||||
|
}
|
||||||
|
|
||||||
|
add(address: string, relayHint?: string) {
|
||||||
|
return this.addPublicTags(["a", address, relayHint || ""])
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(address: string) {
|
||||||
|
return this.removeTagsWithValue(address)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import {uniq} from "@welshman/lib"
|
||||||
|
import {FILE_SERVERS, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
||||||
|
import {EncryptableList} from "./List.js"
|
||||||
|
|
||||||
|
// NIP-96 file storage server list (kind 10096). Server endpoints are stored as
|
||||||
|
// `["server", url]` tags (NOT the `r`/`relay` tags used by relay lists), so the
|
||||||
|
// generic relay-tag helpers would miss them. Structurally identical to
|
||||||
|
// BlossomServerList; effectively public-only.
|
||||||
|
export class FileServerList extends EncryptableList {
|
||||||
|
readonly kind = FILE_SERVERS
|
||||||
|
|
||||||
|
servers() {
|
||||||
|
return uniq(getTagValues("server", this.tags()).map(normalizeRelayUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
includes(url: string) {
|
||||||
|
return this.servers().includes(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
addServer(url: string) {
|
||||||
|
return this.addPublicTags(["server", normalizeRelayUrl(url)])
|
||||||
|
}
|
||||||
|
|
||||||
|
removeServer(url: string) {
|
||||||
|
return this.removeTagsWithValue(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
setServers(urls: string[]) {
|
||||||
|
this.keepTagsWithKey("server")
|
||||||
|
|
||||||
|
return this.addPublicTags(...urls.map(url => ["server", normalizeRelayUrl(url)]))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {uniq} from "@welshman/lib"
|
||||||
|
import {FOLLOWS, getPubkeyTagValues} from "@welshman/util"
|
||||||
|
import {EncryptableList} from "./List.js"
|
||||||
|
|
||||||
|
// NIP-02 kind-3 follow list. Structurally a 'p'-tag list; follows are public in
|
||||||
|
// practice, but the encryptable-list machinery is inherited unchanged (private
|
||||||
|
// tags simply go unused). Follow targets may also be non-pubkey tags (e.g. 't'
|
||||||
|
// hashtags), so `follow` accepts a full tag and `unfollow` removes by value.
|
||||||
|
export class FollowList extends EncryptableList {
|
||||||
|
readonly kind = FOLLOWS
|
||||||
|
|
||||||
|
pubkeys() {
|
||||||
|
return uniq(getPubkeyTagValues(this.tags()))
|
||||||
|
}
|
||||||
|
|
||||||
|
includes(pubkey: string) {
|
||||||
|
return this.pubkeys().includes(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
follow(tag: string[]) {
|
||||||
|
return this.addPublicTags(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
unfollow(value: string) {
|
||||||
|
return this.removeTagsWithValue(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import {parseJson} from "@welshman/lib"
|
||||||
|
import {HANDLER_INFORMATION, getKindTagValues, getIdentifier, getAddress} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type HandlerValues = {
|
||||||
|
name?: string
|
||||||
|
about?: string
|
||||||
|
image?: string
|
||||||
|
website?: string
|
||||||
|
lud16?: string
|
||||||
|
nip05?: string
|
||||||
|
kinds: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeHandlerValues = (values: Partial<HandlerValues> = {}): HandlerValues => ({
|
||||||
|
kinds: [],
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// NIP-89 kind-31990 handler information. Addressable (has a `d` tag); content is a
|
||||||
|
// JSON metadata blob like a profile. Holds one object with the full set of handled
|
||||||
|
// `kinds`, rather than the legacy per-kind fan-out.
|
||||||
|
export class Handler extends DomainObject<HandlerValues> {
|
||||||
|
readonly kind = HANDLER_INFORMATION
|
||||||
|
values = makeHandlerValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<HandlerValues> = {}) {
|
||||||
|
return makeHandlerValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<HandlerValues> {
|
||||||
|
const meta = parseJson(event.content) || {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: meta.name || meta.display_name,
|
||||||
|
about: meta.about,
|
||||||
|
image: meta.image || meta.picture,
|
||||||
|
website: meta.website,
|
||||||
|
lud16: meta.lud16,
|
||||||
|
nip05: meta.nip05,
|
||||||
|
kinds: getKindTagValues(event.tags),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
name() {
|
||||||
|
return this.values.name
|
||||||
|
}
|
||||||
|
|
||||||
|
about() {
|
||||||
|
return this.values.about
|
||||||
|
}
|
||||||
|
|
||||||
|
image() {
|
||||||
|
return this.values.image
|
||||||
|
}
|
||||||
|
|
||||||
|
website() {
|
||||||
|
return this.values.website
|
||||||
|
}
|
||||||
|
|
||||||
|
lud16() {
|
||||||
|
return this.values.lud16
|
||||||
|
}
|
||||||
|
|
||||||
|
nip05() {
|
||||||
|
return this.values.nip05
|
||||||
|
}
|
||||||
|
|
||||||
|
kinds() {
|
||||||
|
return this.values.kinds
|
||||||
|
}
|
||||||
|
|
||||||
|
supportsKind(kind: number) {
|
||||||
|
return this.values.kinds.includes(kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
identifier() {
|
||||||
|
return getIdentifier(this.event!)
|
||||||
|
}
|
||||||
|
|
||||||
|
display(fallback = "") {
|
||||||
|
return this.name() || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
getAddress() {
|
||||||
|
return getAddress(this.event!)
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
const {name, about, image, website, lud16, nip05} = this.values
|
||||||
|
|
||||||
|
const content = JSON.stringify({name, about, image, website, lud16, nip05})
|
||||||
|
|
||||||
|
// Rebuild `k` tags from values.kinds, preserving existing `d`/`a` tags.
|
||||||
|
const preservedTags = (this.event?.tags || []).filter(t => t[0] === "d" || t[0] === "a")
|
||||||
|
const kindTags = this.values.kinds.map(kind => ["k", String(kind)])
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: this.kind,
|
||||||
|
content,
|
||||||
|
tags: [...preservedTags, ...kindTags],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import {last} from "@welshman/lib"
|
||||||
|
import {HANDLER_RECOMMENDATION, getIdentifier, getAddressTags, getAddressTagValues} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type HandlerRecommendationValues = {
|
||||||
|
// The recommended kind, stored in the `d` tag.
|
||||||
|
identifier: string
|
||||||
|
// Raw `a` tags: ["a", address, relay?, platform?].
|
||||||
|
addresses: string[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeHandlerRecommendationValues = (
|
||||||
|
values: Partial<HandlerRecommendationValues> = {},
|
||||||
|
): HandlerRecommendationValues => ({
|
||||||
|
identifier: "",
|
||||||
|
addresses: [],
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// NIP-89 kind-31989 handler recommendation. Addressable (the `d` tag holds the
|
||||||
|
// recommended kind), tags-only with empty content. Each entry is a raw `a` tag
|
||||||
|
// pointing at a kind-31990 handler, optionally carrying a relay hint and a
|
||||||
|
// trailing platform marker (e.g. "web").
|
||||||
|
export class HandlerRecommendation extends DomainObject<HandlerRecommendationValues> {
|
||||||
|
readonly kind = HANDLER_RECOMMENDATION
|
||||||
|
values = makeHandlerRecommendationValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<HandlerRecommendationValues> = {}) {
|
||||||
|
return makeHandlerRecommendationValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<HandlerRecommendationValues> {
|
||||||
|
return {
|
||||||
|
identifier: getIdentifier(event) || "",
|
||||||
|
addresses: getAddressTags(event.tags),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
identifier() {
|
||||||
|
return this.values.identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
addresses() {
|
||||||
|
return getAddressTagValues(this.values.addresses)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer the recommendation marked as a "web" handler, otherwise fall back to
|
||||||
|
// the first recommendation.
|
||||||
|
handlerAddress() {
|
||||||
|
const tag = this.values.addresses.find(t => last(t) === "web") || this.values.addresses[0]
|
||||||
|
|
||||||
|
return tag?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
addRecommendation(address: string, relay?: string, platform?: string) {
|
||||||
|
if (!this.values.addresses.some(t => t[1] === address)) {
|
||||||
|
this.values.addresses = [
|
||||||
|
...this.values.addresses,
|
||||||
|
["a", address, relay || "", platform || ""],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
removeRecommendation(address: string) {
|
||||||
|
this.values.addresses = this.values.addresses.filter(t => t[1] !== address)
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
return {
|
||||||
|
kind: this.kind,
|
||||||
|
tags: [["d", this.values.identifier], ...this.values.addresses],
|
||||||
|
content: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import {uniqBy} from "@welshman/lib"
|
||||||
|
import {MESSAGING_RELAYS, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
||||||
|
import {EncryptableList} from "./List.js"
|
||||||
|
|
||||||
|
// NIP-17 kind-10050 messaging/inbox relays. Entries are marker-less
|
||||||
|
// ['relay', url] tags (NOT NIP-65 'r' tags with read/write markers, and the
|
||||||
|
// RelayMode.Messaging marker is not used per-tag here). `urls()` drives where
|
||||||
|
// encrypted DM gift-wraps are sent and fetched, so it stays a flat, normalized
|
||||||
|
// set. Identical structure to BlockedRelayList/SearchRelayList.
|
||||||
|
export class MessagingRelayList extends EncryptableList {
|
||||||
|
readonly kind = MESSAGING_RELAYS
|
||||||
|
|
||||||
|
urls() {
|
||||||
|
return uniqBy(normalizeRelayUrl, getTagValues("relay", this.tags()))
|
||||||
|
}
|
||||||
|
|
||||||
|
addRelay(url: string) {
|
||||||
|
return this.addPublicTags(["relay", normalizeRelayUrl(url)])
|
||||||
|
}
|
||||||
|
|
||||||
|
removeRelay(url: string) {
|
||||||
|
return this.removeTagsWithValue(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
setRelays(urls: string[]) {
|
||||||
|
this.keepTagsWithKey("relay")
|
||||||
|
|
||||||
|
return this.addPublicTags(...urls.map(url => ["relay", normalizeRelayUrl(url)]))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,11 @@ export class MuteList extends EncryptableList {
|
|||||||
readonly kind = MUTES
|
readonly kind = MUTES
|
||||||
|
|
||||||
pubkeys() {
|
pubkeys() {
|
||||||
return uniq(getPubkeyTagValues(this.tags))
|
return uniq(getPubkeyTagValues(this.tags()))
|
||||||
}
|
}
|
||||||
|
|
||||||
includes(pubkey: string) {
|
includes(pubkey: string) {
|
||||||
return this.pubkeys.includes(pubkey)
|
return this.pubkeys().includes(pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
mutePublicly(pubkey: string) {
|
mutePublicly(pubkey: string) {
|
||||||
@@ -24,6 +24,6 @@ export class MuteList extends EncryptableList {
|
|||||||
}
|
}
|
||||||
|
|
||||||
unmute(pubkey: string) {
|
unmute(pubkey: string) {
|
||||||
return this.removeTagsByValue(pubkey)
|
return this.removeTagsWithValue(pubkey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {uniq} from "@welshman/lib"
|
||||||
|
import {PINS, getEventTagValues, getAddressTagValues} from "@welshman/util"
|
||||||
|
import {EncryptableList} from "./List.js"
|
||||||
|
|
||||||
|
// NIP-51 kind-10001 pin list. Pinned items are heterogeneous tags (typically
|
||||||
|
// 'e' events and optionally 'a' addresses), so they are exposed through
|
||||||
|
// type-specific accessors rather than a single id-only set.
|
||||||
|
export class PinList extends EncryptableList {
|
||||||
|
readonly kind = PINS
|
||||||
|
|
||||||
|
ids() {
|
||||||
|
return uniq(getEventTagValues(this.tags()))
|
||||||
|
}
|
||||||
|
|
||||||
|
addresses() {
|
||||||
|
return uniq(getAddressTagValues(this.tags()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pin a full tag (e.g. ["e", id, ...] or ["a", address, ...]) publicly.
|
||||||
|
pin(tag: string[]) {
|
||||||
|
return this.addPublicTags(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
unpin(value: string) {
|
||||||
|
return this.removeTagsWithValue(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import {now, uniq} from "@welshman/lib"
|
||||||
|
import {POLL, getTagValue, getTagValues} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type PollType = "singlechoice" | "multiplechoice"
|
||||||
|
|
||||||
|
export type PollOption = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PollResult = {
|
||||||
|
options: {id: string; label: string; votes: number}[]
|
||||||
|
voters: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PollValues = {
|
||||||
|
title: string
|
||||||
|
options: PollOption[]
|
||||||
|
pollType: PollType
|
||||||
|
endsAt?: number
|
||||||
|
relays: string[]
|
||||||
|
h?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makePollValues = (values: Partial<PollValues> = {}): PollValues => ({
|
||||||
|
title: "",
|
||||||
|
options: [],
|
||||||
|
pollType: "singlechoice",
|
||||||
|
relays: [],
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// NIP-88 kind-1068 poll. The poll title/question lives in `content` as plain
|
||||||
|
// text (not JSON), options come from "option" tags, and the response tally is
|
||||||
|
// computed from sibling kind-1018 response events passed into `results`.
|
||||||
|
export class Poll extends DomainObject<PollValues> {
|
||||||
|
readonly kind = POLL
|
||||||
|
values = makePollValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<PollValues> = {}) {
|
||||||
|
return makePollValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<PollValues> {
|
||||||
|
const endsAtRaw = getTagValue("endsAt", event.tags)
|
||||||
|
const endsAt = endsAtRaw == null ? NaN : parseInt(endsAtRaw)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: event.content || "",
|
||||||
|
options: event.tags
|
||||||
|
.filter(t => t[0] === "option")
|
||||||
|
.map(t => ({id: t[1], label: t[2] || t[1]})),
|
||||||
|
pollType: (getTagValue("polltype", event.tags) as PollType) || "singlechoice",
|
||||||
|
endsAt: Number.isNaN(endsAt) ? undefined : endsAt,
|
||||||
|
relays: getTagValues("relay", event.tags),
|
||||||
|
h: getTagValue("h", event.tags),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
title() {
|
||||||
|
return this.values.title
|
||||||
|
}
|
||||||
|
|
||||||
|
options() {
|
||||||
|
return this.values.options
|
||||||
|
}
|
||||||
|
|
||||||
|
pollType() {
|
||||||
|
return this.values.pollType
|
||||||
|
}
|
||||||
|
|
||||||
|
endsAt() {
|
||||||
|
return this.values.endsAt
|
||||||
|
}
|
||||||
|
|
||||||
|
isClosed() {
|
||||||
|
return this.values.endsAt != null && this.values.endsAt <= now()
|
||||||
|
}
|
||||||
|
|
||||||
|
relays() {
|
||||||
|
return this.values.relays
|
||||||
|
}
|
||||||
|
|
||||||
|
h() {
|
||||||
|
return this.values.h
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tally the latest response per pubkey across the poll options. Each response
|
||||||
|
// is a kind-1018 event whose "response" tags name selected option ids;
|
||||||
|
// single-choice polls only honor the first selection.
|
||||||
|
results(responses: TrustedEvent[]): PollResult {
|
||||||
|
const options = this.values.options.map(option => ({...option, votes: 0}))
|
||||||
|
const counts = new Map(options.map(option => [option.id, option]))
|
||||||
|
const latestByPubkey = new Map<string, TrustedEvent>()
|
||||||
|
|
||||||
|
for (const response of responses) {
|
||||||
|
const current = latestByPubkey.get(response.pubkey)
|
||||||
|
|
||||||
|
if (!current || response.created_at > current.created_at) {
|
||||||
|
latestByPubkey.set(response.pubkey, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const response of latestByPubkey.values()) {
|
||||||
|
const selections = getTagValues("response", response.tags)
|
||||||
|
const ids =
|
||||||
|
this.values.pollType === "singlechoice" ? selections.slice(0, 1) : uniq(selections)
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
const option = counts.get(id)
|
||||||
|
|
||||||
|
if (option) {
|
||||||
|
option.votes += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {options, voters: latestByPubkey.size}
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
const tags: string[][] = [
|
||||||
|
...this.values.options.map(o => ["option", o.id, o.label]),
|
||||||
|
["polltype", this.values.pollType],
|
||||||
|
]
|
||||||
|
|
||||||
|
if (this.values.endsAt != null) {
|
||||||
|
tags.push(["endsAt", String(this.values.endsAt)])
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const relay of this.values.relays) {
|
||||||
|
tags.push(["relay", relay])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.values.h) {
|
||||||
|
tags.push(["h", this.values.h])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {kind: this.kind, content: this.values.title, tags}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {uniq} from "@welshman/lib"
|
||||||
|
import {POLL_RESPONSE, getTagValue, getTagValues} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type PollResponseValues = {
|
||||||
|
pollId: string
|
||||||
|
selections: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makePollResponseValues = (
|
||||||
|
values: Partial<PollResponseValues> = {},
|
||||||
|
): PollResponseValues => ({
|
||||||
|
pollId: "",
|
||||||
|
selections: [],
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// NIP-88 kind-1018 poll vote. Empty content; the target poll is referenced via
|
||||||
|
// an "e" tag and each chosen option id lives in its own "response" tag. Tags-only
|
||||||
|
// content, so it extends DomainObject directly rather than the encryptable list base.
|
||||||
|
export class PollResponse extends DomainObject<PollResponseValues> {
|
||||||
|
readonly kind = POLL_RESPONSE
|
||||||
|
values = makePollResponseValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<PollResponseValues> = {}) {
|
||||||
|
return makePollResponseValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<PollResponseValues> {
|
||||||
|
return {
|
||||||
|
pollId: getTagValue("e", event.tags) || "",
|
||||||
|
selections: getTagValues("response", event.tags),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pollId() {
|
||||||
|
return this.values.pollId
|
||||||
|
}
|
||||||
|
|
||||||
|
selections(pollType?: "singlechoice" | "multiplechoice") {
|
||||||
|
if (pollType === "singlechoice") {
|
||||||
|
return this.values.selections.slice(0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniq(this.values.selections)
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
return {
|
||||||
|
kind: this.kind,
|
||||||
|
content: "",
|
||||||
|
tags: [
|
||||||
|
["e", this.values.pollId],
|
||||||
|
...this.values.selections.map(id => ["response", id]),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,27 +57,27 @@ export class Profile extends DomainObject<ProfileValues> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nip05() {
|
nip05() {
|
||||||
return this.values.
|
return this.values.nip05
|
||||||
}
|
}
|
||||||
|
|
||||||
lnurl() {
|
lnurl() {
|
||||||
return this.values.
|
return this.values.lnurl
|
||||||
}
|
}
|
||||||
|
|
||||||
about() {
|
about() {
|
||||||
return this.values.
|
return this.values.about
|
||||||
}
|
}
|
||||||
|
|
||||||
banner() {
|
banner() {
|
||||||
return this.values.
|
return this.values.banner
|
||||||
}
|
}
|
||||||
|
|
||||||
picture() {
|
picture() {
|
||||||
return this.values.
|
return this.values.picture
|
||||||
}
|
}
|
||||||
|
|
||||||
website() {
|
website() {
|
||||||
return this.values.
|
return this.values.website
|
||||||
}
|
}
|
||||||
|
|
||||||
display(fallback = "") {
|
display(fallback = "") {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import {RELAY_INVITE, getTagValue} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type RelayInviteValues = {
|
||||||
|
claim?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeRelayInviteValues = (
|
||||||
|
values: Partial<RelayInviteValues> = {},
|
||||||
|
): RelayInviteValues => ({
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// NIP-29 kind-28935 ephemeral relay invite event. Its "claim" tag carries the
|
||||||
|
// invite code, which flotilla turns into a /join?r=&c= link. Flotilla only reads
|
||||||
|
// this event (see app/relays.ts requestRelayClaim), so `claim` is the sole field.
|
||||||
|
// Tags-only content, so it extends DomainObject directly.
|
||||||
|
export class RelayInvite extends DomainObject<RelayInviteValues> {
|
||||||
|
readonly kind = RELAY_INVITE
|
||||||
|
values = makeRelayInviteValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<RelayInviteValues> = {}) {
|
||||||
|
return makeRelayInviteValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<RelayInviteValues> {
|
||||||
|
return {
|
||||||
|
claim: getTagValue("claim", event.tags),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
claim() {
|
||||||
|
return this.values.claim
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
return {
|
||||||
|
kind: this.kind,
|
||||||
|
tags: this.values.claim ? [["claim", this.values.claim]] : [],
|
||||||
|
content: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import {RELAY_JOIN, getTagValue} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type RelayJoinValues = {
|
||||||
|
claim?: string
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeRelayJoinValues = (values: Partial<RelayJoinValues> = {}): RelayJoinValues => ({
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ephemeral kind-28934 relay/space join request. Both written (the join flow)
|
||||||
|
// and read (membership status): it carries an optional invite "claim" tag and a
|
||||||
|
// free-text reason in the event content, driving the space membership state
|
||||||
|
// machine (RELAY_JOIN -> Pending/Granted). Tags-plus-content, so it extends
|
||||||
|
// DomainObject directly.
|
||||||
|
export class RelayJoin extends DomainObject<RelayJoinValues> {
|
||||||
|
readonly kind = RELAY_JOIN
|
||||||
|
values = makeRelayJoinValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<RelayJoinValues> = {}) {
|
||||||
|
return makeRelayJoinValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<RelayJoinValues> {
|
||||||
|
return {
|
||||||
|
claim: getTagValue("claim", event.tags),
|
||||||
|
reason: event.content || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
claim() {
|
||||||
|
return this.values.claim
|
||||||
|
}
|
||||||
|
|
||||||
|
reason() {
|
||||||
|
return this.values.reason
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
const tags: string[][] = []
|
||||||
|
|
||||||
|
if (this.values.claim) {
|
||||||
|
tags.push(["claim", this.values.claim])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: this.kind,
|
||||||
|
tags,
|
||||||
|
content: this.values.reason || "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {RELAY_LEAVE} from "@welshman/util"
|
||||||
|
import type {EventTemplate} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type RelayLeaveValues = {}
|
||||||
|
|
||||||
|
export const makeRelayLeaveValues = (values: Partial<RelayLeaveValues> = {}): RelayLeaveValues => ({
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ephemeral kind-28936 relay/space leave marker, the counterpart to RelayJoin.
|
||||||
|
// Carries no tags and no content; flotilla both emits it (the leave flow) and
|
||||||
|
// consumes it to reset the space membership state machine (RELAY_LEAVE ->
|
||||||
|
// Initial). State-free, so it extends DomainObject directly.
|
||||||
|
export class RelayLeave extends DomainObject<RelayLeaveValues> {
|
||||||
|
readonly kind = RELAY_LEAVE
|
||||||
|
values = makeRelayLeaveValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<RelayLeaveValues> = {}) {
|
||||||
|
return makeRelayLeaveValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(): Partial<RelayLeaveValues> {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
return {
|
||||||
|
kind: this.kind,
|
||||||
|
tags: [],
|
||||||
|
content: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import {uniq, uniqBy} from "@welshman/lib"
|
||||||
|
import {RELAYS, RelayMode, getRelayTags, getRelayTagValues, normalizeRelayUrl} from "@welshman/util"
|
||||||
|
import {EncryptableList} from "./List.js"
|
||||||
|
|
||||||
|
// NIP-65 kind-10002 relay list (the outbox-model routing substrate). Entries are
|
||||||
|
// `["r", url, mode?]` tags where `mode` is RelayMode.Read or RelayMode.Write; a
|
||||||
|
// missing marker means the relay is used for both read and write. NIP-65 entries
|
||||||
|
// are public in practice, so mutations target the public tag set.
|
||||||
|
export class RelayList extends EncryptableList {
|
||||||
|
readonly kind = RELAYS
|
||||||
|
|
||||||
|
// All relay urls, deduped by normalized url.
|
||||||
|
urls() {
|
||||||
|
return uniqBy(normalizeRelayUrl, getRelayTagValues(this.tags()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relays usable for reading: includes modeless (both) entries.
|
||||||
|
readUrls() {
|
||||||
|
return uniqBy(
|
||||||
|
normalizeRelayUrl,
|
||||||
|
getRelayTags(this.tags())
|
||||||
|
.filter(t => !t[2] || t[2] === RelayMode.Read)
|
||||||
|
.map(t => t[1]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relays usable for writing: includes modeless (both) entries.
|
||||||
|
writeUrls() {
|
||||||
|
return uniqBy(
|
||||||
|
normalizeRelayUrl,
|
||||||
|
getRelayTags(this.tags())
|
||||||
|
.filter(t => !t[2] || t[2] === RelayMode.Write)
|
||||||
|
.map(t => t[1]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert a relay for a given mode. If an existing entry already covered the
|
||||||
|
// complementary mode (or was modeless), collapse to a modeless ["r", url] tag;
|
||||||
|
// otherwise store ["r", url, mode].
|
||||||
|
addRelay(url: string, mode: RelayMode) {
|
||||||
|
const normalized = normalizeRelayUrl(url)
|
||||||
|
const existing = getRelayTags(this.values.publicTags).filter(
|
||||||
|
t => normalizeRelayUrl(t[1]) === normalized,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Modes already covered by existing entries (undefined marker = both).
|
||||||
|
const priorModes = new Set<RelayMode | undefined>(existing.map(t => t[2] as RelayMode | undefined))
|
||||||
|
|
||||||
|
const alt = mode === RelayMode.Read ? RelayMode.Write : RelayMode.Read
|
||||||
|
const coversAlt = priorModes.has(undefined) || priorModes.has(alt)
|
||||||
|
|
||||||
|
this.values.publicTags = this.values.publicTags.filter(
|
||||||
|
t => !(t[0] === "r" && normalizeRelayUrl(t[1]) === normalized),
|
||||||
|
)
|
||||||
|
|
||||||
|
this.values.publicTags.push(coversAlt ? ["r", url] : ["r", url, mode])
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a relay for a given mode while preserving the alternate. A
|
||||||
|
// modeless/both entry is downgraded to the alternate mode; an entry that only
|
||||||
|
// covered `mode` is fully removed.
|
||||||
|
removeRelay(url: string, mode: RelayMode) {
|
||||||
|
const normalized = normalizeRelayUrl(url)
|
||||||
|
const existing = getRelayTags(this.values.publicTags).filter(
|
||||||
|
t => normalizeRelayUrl(t[1]) === normalized,
|
||||||
|
)
|
||||||
|
|
||||||
|
const alt = mode === RelayMode.Read ? RelayMode.Write : RelayMode.Read
|
||||||
|
|
||||||
|
// Keep the alternate if any existing entry was modeless/both or the alt mode.
|
||||||
|
const keepAlt = existing.some(t => !t[2] || t[2] === alt)
|
||||||
|
|
||||||
|
this.values.publicTags = this.values.publicTags.filter(
|
||||||
|
t => !(t[0] === "r" && normalizeRelayUrl(t[1]) === normalized),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (keepAlt) {
|
||||||
|
this.values.publicTags.push(["r", url, alt])
|
||||||
|
}
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the read set while PRESERVING every relay's write capability: a
|
||||||
|
// relay that was write-capable (write-marked or modeless) stays writable, and
|
||||||
|
// collapses back to a modeless ["r", url] tag if it's also in the new read set.
|
||||||
|
setReadRelays(urls: string[]) {
|
||||||
|
return this.setRelaysForModes(urls, this.writeUrls())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the write set while PRESERVING every relay's read capability.
|
||||||
|
setWriteRelays(urls: string[]) {
|
||||||
|
return this.setRelaysForModes(this.readUrls(), urls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the public 'r' tag set from explicit read/write membership. A relay
|
||||||
|
// in both sets is emitted modeless (both); otherwise it carries its single
|
||||||
|
// mode marker. Non-'r' public tags are preserved.
|
||||||
|
private setRelaysForModes(readUrls: string[], writeUrls: string[]) {
|
||||||
|
const read = new Set(readUrls.map(normalizeRelayUrl))
|
||||||
|
const write = new Set(writeUrls.map(normalizeRelayUrl))
|
||||||
|
const otherTags = this.values.publicTags.filter(t => t[0] !== "r")
|
||||||
|
const relayTags = uniq([...read, ...write]).map(url =>
|
||||||
|
read.has(url) && write.has(url)
|
||||||
|
? ["r", url]
|
||||||
|
: read.has(url)
|
||||||
|
? ["r", url, RelayMode.Read]
|
||||||
|
: ["r", url, RelayMode.Write],
|
||||||
|
)
|
||||||
|
|
||||||
|
this.values.publicTags = [...otherTags, ...relayTags]
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the entire public tag set.
|
||||||
|
setRelays(tags: string[][]) {
|
||||||
|
this.values.publicTags = tags
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import {uniq} from "@welshman/lib"
|
||||||
|
import {RELAY_MEMBERS, getTagValues} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type RelayMembersValues = {
|
||||||
|
members: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeRelayMembersValues = (
|
||||||
|
values: Partial<RelayMembersValues> = {},
|
||||||
|
): RelayMembersValues => ({
|
||||||
|
members: [],
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Flotilla relay-wide (space) member-list snapshot, replaceable kind 13534.
|
||||||
|
// Members are stored under "member" tags (tag[0] === "member"), NOT "p" tags,
|
||||||
|
// so parsing uses getTagValues("member", ...) rather than getPubkeyTagValues.
|
||||||
|
// Not addressable (no "d" tag); tags-only content, so it extends DomainObject
|
||||||
|
// directly rather than the encryptable list base.
|
||||||
|
export class RelayMembers extends DomainObject<RelayMembersValues> {
|
||||||
|
readonly kind = RELAY_MEMBERS
|
||||||
|
values = makeRelayMembersValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<RelayMembersValues> = {}) {
|
||||||
|
return makeRelayMembersValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<RelayMembersValues> {
|
||||||
|
return {
|
||||||
|
members: uniq(getTagValues("member", event.tags)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
members() {
|
||||||
|
return this.values.members
|
||||||
|
}
|
||||||
|
|
||||||
|
isMember(pubkey: string) {
|
||||||
|
return this.values.members.includes(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
addMember(pubkey: string) {
|
||||||
|
this.values.members = uniq([...this.values.members, pubkey])
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
removeMember(pubkey: string) {
|
||||||
|
this.values.members = this.values.members.filter(pk => pk !== pubkey)
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
const tags: string[][] = this.values.members.map(pk => ["member", pk])
|
||||||
|
|
||||||
|
return {kind: this.kind, tags, content: ""}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import {uniq} from "@welshman/lib"
|
||||||
|
import {RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, getPubkeyTagValues} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import type {ISigner} from "@welshman/signer"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type RelayMembershipOpValues = {
|
||||||
|
kind: number
|
||||||
|
pubkeys: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeRelayMembershipOpValues = (
|
||||||
|
values: Partial<RelayMembershipOpValues> = {},
|
||||||
|
): RelayMembershipOpValues => ({
|
||||||
|
kind: RELAY_ADD_MEMBER,
|
||||||
|
pubkeys: [],
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const makeRelayAddMember = (pubkeys: string[]) =>
|
||||||
|
RelayMembershipOp.init({kind: RELAY_ADD_MEMBER, pubkeys})
|
||||||
|
|
||||||
|
export const makeRelayRemoveMember = (pubkeys: string[]) =>
|
||||||
|
RelayMembershipOp.init({kind: RELAY_REMOVE_MEMBER, pubkeys})
|
||||||
|
|
||||||
|
// Relay/space-level moderation op for adding (kind 8000) or removing (kind 8001)
|
||||||
|
// members. These are regular (non-addressable) events carrying the affected
|
||||||
|
// pubkeys in "p" tags. The two kinds share an identical shape, so they're merged
|
||||||
|
// into one kind-discriminated class.
|
||||||
|
//
|
||||||
|
// Flotilla's deriveUserSpaceMembershipStatus replays this history (RELAY_ADD_MEMBER
|
||||||
|
// => isMember true, RELAY_REMOVE_MEMBER => isMember false) when no RELAY_MEMBERS
|
||||||
|
// snapshot is available.
|
||||||
|
//
|
||||||
|
// Because the base DomainObject treats `kind` as a fixed value and asserts
|
||||||
|
// event.kind === this.kind in parse(), `kind` is a mutable instance field here:
|
||||||
|
// it's seeded from values.kind in normalizeValues, and parse() is overridden to
|
||||||
|
// adopt the event's kind before normalizing.
|
||||||
|
export class RelayMembershipOp extends DomainObject<RelayMembershipOpValues> {
|
||||||
|
kind = RELAY_ADD_MEMBER
|
||||||
|
values = makeRelayMembershipOpValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<RelayMembershipOpValues> = {}) {
|
||||||
|
const normalized = makeRelayMembershipOpValues(values)
|
||||||
|
|
||||||
|
this.kind = normalized.kind
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<RelayMembershipOpValues> {
|
||||||
|
return {
|
||||||
|
kind: event.kind,
|
||||||
|
pubkeys: uniq(getPubkeyTagValues(event.tags)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async parse(event: TrustedEvent, signer?: ISigner) {
|
||||||
|
this.event = event
|
||||||
|
this.kind = event.kind
|
||||||
|
this.values = this.normalizeValues(await this.parseEvent(event, signer))
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
pubkeys() {
|
||||||
|
return this.values.pubkeys
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdd() {
|
||||||
|
return this.kind === RELAY_ADD_MEMBER
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
return {
|
||||||
|
kind: this.kind,
|
||||||
|
tags: this.values.pubkeys.map(pk => ["p", pk]),
|
||||||
|
content: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import {uniqBy} from "@welshman/lib"
|
||||||
|
import {NAMED_RELAYS, getTagValue, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
||||||
|
import {EncryptableList} from "./List.js"
|
||||||
|
|
||||||
|
// NIP-51 kind-30002 relay set: an addressable, named collection of relays
|
||||||
|
// identified by its `d` tag. Entries are marker-less ['relay', url] tags (like
|
||||||
|
// the other NIP-51 relay lists, NOT NIP-65 'r' tags with read/write markers).
|
||||||
|
// It also carries optional set metadata (title/description/image) used to label
|
||||||
|
// the set in UIs.
|
||||||
|
export class RelaySet extends EncryptableList {
|
||||||
|
readonly kind = NAMED_RELAYS
|
||||||
|
|
||||||
|
identifier() {
|
||||||
|
return getTagValue("d", this.tags()) || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
title() {
|
||||||
|
return getTagValue("title", this.tags())
|
||||||
|
}
|
||||||
|
|
||||||
|
description() {
|
||||||
|
return getTagValue("description", this.tags())
|
||||||
|
}
|
||||||
|
|
||||||
|
image() {
|
||||||
|
return getTagValue("image", this.tags())
|
||||||
|
}
|
||||||
|
|
||||||
|
urls() {
|
||||||
|
return uniqBy(normalizeRelayUrl, getTagValues("relay", this.tags()))
|
||||||
|
}
|
||||||
|
|
||||||
|
addRelay(url: string) {
|
||||||
|
return this.addPublicTags(["relay", normalizeRelayUrl(url)])
|
||||||
|
}
|
||||||
|
|
||||||
|
removeRelay(url: string) {
|
||||||
|
return this.removeTagsWithValue(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
setRelays(urls: string[]) {
|
||||||
|
this.keepTagsWithKey("relay")
|
||||||
|
|
||||||
|
return this.addPublicTags(...urls.map(url => ["relay", normalizeRelayUrl(url)]))
|
||||||
|
}
|
||||||
|
|
||||||
|
setIdentifier(identifier: string) {
|
||||||
|
this.removeTagsWithKey("d")
|
||||||
|
|
||||||
|
return this.addPublicTags(["d", identifier])
|
||||||
|
}
|
||||||
|
|
||||||
|
setTitle(title: string) {
|
||||||
|
this.removeTagsWithKey("title")
|
||||||
|
|
||||||
|
return this.addPublicTags(["title", title])
|
||||||
|
}
|
||||||
|
|
||||||
|
setDescription(description: string) {
|
||||||
|
this.removeTagsWithKey("description")
|
||||||
|
|
||||||
|
return this.addPublicTags(["description", description])
|
||||||
|
}
|
||||||
|
|
||||||
|
setImage(image: string) {
|
||||||
|
this.removeTagsWithKey("image")
|
||||||
|
|
||||||
|
return this.addPublicTags(["image", image])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import {REPORT, getTag, getTagValue} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type ReportValues = {
|
||||||
|
pubkey?: string
|
||||||
|
eventId?: string
|
||||||
|
reason?: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeReportValues = (values: Partial<ReportValues> = {}): ReportValues => ({
|
||||||
|
content: "",
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// NIP-56 kind-1984 report, feeding flotilla's admin action-items / moderation
|
||||||
|
// review queue (see app/actionItems.ts `deriveSpaceActionItems`). The reported
|
||||||
|
// author is named in the "p" tag and the reported event in the "e" tag, with the
|
||||||
|
// report reason carried as the 3rd element of the "e" tag (NOT a separate tag).
|
||||||
|
// Flotilla destructures this by hand in ReactionSummary.svelte and
|
||||||
|
// ReportMenu.svelte; `reason()` centralizes that access. The report body lives in
|
||||||
|
// `content` as plain text (not JSON).
|
||||||
|
export class Report extends DomainObject<ReportValues> {
|
||||||
|
readonly kind = REPORT
|
||||||
|
values = makeReportValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<ReportValues> = {}) {
|
||||||
|
return makeReportValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<ReportValues> {
|
||||||
|
const eTag = getTag("e", event.tags)
|
||||||
|
|
||||||
|
return {
|
||||||
|
pubkey: getTagValue("p", event.tags),
|
||||||
|
eventId: eTag?.[1],
|
||||||
|
reason: eTag?.[2],
|
||||||
|
content: event.content || "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pubkey() {
|
||||||
|
return this.values.pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
eventId() {
|
||||||
|
return this.values.eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
reason() {
|
||||||
|
return this.values.reason
|
||||||
|
}
|
||||||
|
|
||||||
|
content() {
|
||||||
|
return this.values.content
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent(content: string) {
|
||||||
|
this.values.content = content
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
const {pubkey, eventId, reason, content} = this.values
|
||||||
|
const tags: string[][] = []
|
||||||
|
|
||||||
|
if (pubkey) {
|
||||||
|
tags.push(["p", pubkey])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventId) {
|
||||||
|
tags.push(["e", eventId, ...(reason ? [reason] : [])])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {kind: this.kind, content, tags}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import {uniq} from "@welshman/lib"
|
||||||
|
import {ROOM_ADMINS, getIdentifier, getPubkeyTagValues} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type RoomAdminsValues = {
|
||||||
|
h: string
|
||||||
|
admins: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeRoomAdminsValues = (
|
||||||
|
values: Partial<RoomAdminsValues> = {},
|
||||||
|
): RoomAdminsValues => ({
|
||||||
|
h: "",
|
||||||
|
admins: [],
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// NIP-29 kind-39001 relay-generated room admin list. Addressable, with the group
|
||||||
|
// id ("h") stored in the "d" tag and admins as "p" tags. Tags-only content, so it
|
||||||
|
// extends DomainObject directly rather than the encryptable list base.
|
||||||
|
export class RoomAdmins extends DomainObject<RoomAdminsValues> {
|
||||||
|
readonly kind = ROOM_ADMINS
|
||||||
|
values = makeRoomAdminsValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<RoomAdminsValues> = {}) {
|
||||||
|
return makeRoomAdminsValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<RoomAdminsValues> {
|
||||||
|
return {
|
||||||
|
h: getIdentifier(event) || "",
|
||||||
|
admins: uniq(getPubkeyTagValues(event.tags)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h() {
|
||||||
|
return this.values.h
|
||||||
|
}
|
||||||
|
|
||||||
|
admins() {
|
||||||
|
return this.values.admins
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdmin(pubkey: string) {
|
||||||
|
return this.values.admins.includes(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
addAdmin(pubkey: string) {
|
||||||
|
if (!this.values.admins.includes(pubkey)) {
|
||||||
|
this.values.admins.push(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAdmin(pubkey: string) {
|
||||||
|
this.values.admins = this.values.admins.filter(pk => pk !== pubkey)
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
const tags: string[][] = [
|
||||||
|
["d", this.values.h],
|
||||||
|
...this.values.admins.map(pk => ["p", pk]),
|
||||||
|
]
|
||||||
|
|
||||||
|
return {kind: this.kind, tags, content: ""}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import {ROOM_CREATE, getTagValue} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type RoomCreateValues = {
|
||||||
|
h: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeRoomCreateValues = (
|
||||||
|
values: Partial<RoomCreateValues> = {},
|
||||||
|
): RoomCreateValues => ({
|
||||||
|
h: "",
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// NIP-29 kind-9007 create-room action op. A regular (write-primarily) event
|
||||||
|
// carrying only the target group id ("h") tag. Tags-only content, so it extends
|
||||||
|
// DomainObject directly rather than the encryptable list base.
|
||||||
|
export class RoomCreate extends DomainObject<RoomCreateValues> {
|
||||||
|
readonly kind = ROOM_CREATE
|
||||||
|
values = makeRoomCreateValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<RoomCreateValues> = {}) {
|
||||||
|
return makeRoomCreateValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<RoomCreateValues> {
|
||||||
|
return {
|
||||||
|
h: getTagValue("h", event.tags) || "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h() {
|
||||||
|
return this.values.h
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
return {
|
||||||
|
kind: this.kind,
|
||||||
|
tags: [["h", this.values.h]],
|
||||||
|
content: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import {uniq} from "@welshman/lib"
|
||||||
|
import {ROOM_CREATE_PERMISSION, getPubkeyTagValues} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type RoomCreatePermissionValues = {
|
||||||
|
pubkeys: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeRoomCreatePermissionValues = (
|
||||||
|
values: Partial<RoomCreatePermissionValues> = {},
|
||||||
|
): RoomCreatePermissionValues => ({
|
||||||
|
pubkeys: [],
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Flotilla/NIP-29 extension: relay-authored grant of room-creation permission
|
||||||
|
// (kind 19004). The "p" tags list the pubkeys allowed to create rooms. Read-only
|
||||||
|
// in practice. Tags-only content, so it extends DomainObject directly rather than
|
||||||
|
// the encryptable list base.
|
||||||
|
export class RoomCreatePermission extends DomainObject<RoomCreatePermissionValues> {
|
||||||
|
readonly kind = ROOM_CREATE_PERMISSION
|
||||||
|
values = makeRoomCreatePermissionValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<RoomCreatePermissionValues> = {}) {
|
||||||
|
return makeRoomCreatePermissionValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<RoomCreatePermissionValues> {
|
||||||
|
return {
|
||||||
|
pubkeys: uniq(getPubkeyTagValues(event.tags)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pubkeys() {
|
||||||
|
return this.values.pubkeys
|
||||||
|
}
|
||||||
|
|
||||||
|
canCreate(pubkey: string) {
|
||||||
|
return this.values.pubkeys.includes(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
return {
|
||||||
|
kind: this.kind,
|
||||||
|
tags: this.values.pubkeys.map(pk => ["p", pk]),
|
||||||
|
content: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import {ROOM_DELETE, getTagValues} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type RoomDeleteValues = {
|
||||||
|
hs: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeRoomDeleteValues = (
|
||||||
|
values: Partial<RoomDeleteValues> = {},
|
||||||
|
): RoomDeleteValues => ({
|
||||||
|
hs: [],
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// NIP-29 kind-9008 delete-room/tombstone op. A regular event that may carry
|
||||||
|
// MULTIPLE group id ("h") tags, allowing a single delete event to tombstone
|
||||||
|
// several rooms at once. Tags-only content, so it extends DomainObject directly
|
||||||
|
// rather than the encryptable list base.
|
||||||
|
export class RoomDelete extends DomainObject<RoomDeleteValues> {
|
||||||
|
readonly kind = ROOM_DELETE
|
||||||
|
values = makeRoomDeleteValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<RoomDeleteValues> = {}) {
|
||||||
|
return makeRoomDeleteValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<RoomDeleteValues> {
|
||||||
|
return {
|
||||||
|
hs: getTagValues("h", event.tags),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hs() {
|
||||||
|
return this.values.hs
|
||||||
|
}
|
||||||
|
|
||||||
|
h() {
|
||||||
|
return this.values.hs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
addRoom(h: string) {
|
||||||
|
if (!this.values.hs.includes(h)) {
|
||||||
|
this.values.hs.push(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
removeRoom(h: string) {
|
||||||
|
this.values.hs = this.values.hs.filter(value => value !== h)
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
return {
|
||||||
|
kind: this.kind,
|
||||||
|
tags: this.values.hs.map(h => ["h", h]),
|
||||||
|
content: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {ROOM_JOIN, getTagValue} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type RoomJoinValues = {
|
||||||
|
h: string
|
||||||
|
claim?: string
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeRoomJoinValues = (values: Partial<RoomJoinValues> = {}): RoomJoinValues => ({
|
||||||
|
h: "",
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// NIP-29 kind-9021 room join request. A regular (read-and-written) event
|
||||||
|
// carrying the target group id ("h") tag, an optional invite "claim" tag, and a
|
||||||
|
// free-text reason in the event content. Drives the membership state machine
|
||||||
|
// (ROOM_JOIN -> Pending/Granted) and the pending-join admin queue, grouped by
|
||||||
|
// h + pubkey. Tags-plus-content, so it extends DomainObject directly.
|
||||||
|
export class RoomJoin extends DomainObject<RoomJoinValues> {
|
||||||
|
readonly kind = ROOM_JOIN
|
||||||
|
values = makeRoomJoinValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<RoomJoinValues> = {}) {
|
||||||
|
return makeRoomJoinValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<RoomJoinValues> {
|
||||||
|
return {
|
||||||
|
h: getTagValue("h", event.tags) || "",
|
||||||
|
claim: getTagValue("claim", event.tags),
|
||||||
|
reason: event.content || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h() {
|
||||||
|
return this.values.h
|
||||||
|
}
|
||||||
|
|
||||||
|
claim() {
|
||||||
|
return this.values.claim
|
||||||
|
}
|
||||||
|
|
||||||
|
reason() {
|
||||||
|
return this.values.reason
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
const tags: string[][] = [["h", this.values.h]]
|
||||||
|
|
||||||
|
if (this.values.claim) {
|
||||||
|
tags.push(["claim", this.values.claim])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: this.kind,
|
||||||
|
tags,
|
||||||
|
content: this.values.reason || "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import {ROOM_LEAVE, getTagValue} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type RoomLeaveValues = {
|
||||||
|
h: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeRoomLeaveValues = (values: Partial<RoomLeaveValues> = {}): RoomLeaveValues => ({
|
||||||
|
h: "",
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// NIP-29 kind-9022 room leave op, the counterpart to RoomJoin. A regular event
|
||||||
|
// carrying the target group id ("h") tag, which resets the membership state
|
||||||
|
// machine (ROOM_LEAVE -> Initial). Tags-only, so it extends DomainObject directly.
|
||||||
|
export class RoomLeave extends DomainObject<RoomLeaveValues> {
|
||||||
|
readonly kind = ROOM_LEAVE
|
||||||
|
values = makeRoomLeaveValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<RoomLeaveValues> = {}) {
|
||||||
|
return makeRoomLeaveValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<RoomLeaveValues> {
|
||||||
|
return {
|
||||||
|
h: getTagValue("h", event.tags) || "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h() {
|
||||||
|
return this.values.h
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
return {
|
||||||
|
kind: this.kind,
|
||||||
|
tags: [["h", this.values.h]],
|
||||||
|
content: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {ROOMS, getGroupTags, getGroupTagValues} from "@welshman/util"
|
||||||
|
import {EncryptableList} from "./List.js"
|
||||||
|
|
||||||
|
// NIP-51 / NIP-29 kind-10009 simple-groups membership list. Each entry is a
|
||||||
|
// group tag `["group", groupId, relayUrl]` (legacy `"h"` is also accepted).
|
||||||
|
// Distinct from the NIP-29 room management events, which are not lists.
|
||||||
|
export class RoomList extends EncryptableList {
|
||||||
|
readonly kind = ROOMS
|
||||||
|
|
||||||
|
groups() {
|
||||||
|
return getGroupTagValues(this.tags())
|
||||||
|
}
|
||||||
|
|
||||||
|
groupTags() {
|
||||||
|
return getGroupTags(this.tags())
|
||||||
|
}
|
||||||
|
|
||||||
|
join(groupId: string, relayUrl: string) {
|
||||||
|
return this.addPublicTags(["group", groupId, relayUrl])
|
||||||
|
}
|
||||||
|
|
||||||
|
leave(groupId: string) {
|
||||||
|
return this.removeTagsWithValue(groupId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import {uniq} from "@welshman/lib"
|
||||||
|
import {ROOM_MEMBERS, getIdentifier, getPubkeyTagValues} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type RoomMembersValues = {
|
||||||
|
h: string
|
||||||
|
members: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeRoomMembersValues = (
|
||||||
|
values: Partial<RoomMembersValues> = {},
|
||||||
|
): RoomMembersValues => ({
|
||||||
|
h: "",
|
||||||
|
members: [],
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// NIP-29 kind-39002 relay-authored room member-list snapshot. Addressable, with
|
||||||
|
// the group id ("h") stored in the "d" tag and members listed as "p" tags.
|
||||||
|
// Tags-only content, so it extends DomainObject directly rather than the
|
||||||
|
// encryptable list base.
|
||||||
|
export class RoomMembers extends DomainObject<RoomMembersValues> {
|
||||||
|
readonly kind = ROOM_MEMBERS
|
||||||
|
values = makeRoomMembersValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<RoomMembersValues> = {}) {
|
||||||
|
return makeRoomMembersValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<RoomMembersValues> {
|
||||||
|
return {
|
||||||
|
h: getIdentifier(event) || "",
|
||||||
|
members: uniq(getPubkeyTagValues(event.tags)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h() {
|
||||||
|
return this.values.h
|
||||||
|
}
|
||||||
|
|
||||||
|
members() {
|
||||||
|
return this.values.members
|
||||||
|
}
|
||||||
|
|
||||||
|
isMember(pubkey: string) {
|
||||||
|
return this.values.members.includes(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
addMember(pubkey: string) {
|
||||||
|
this.values.members = uniq([...this.values.members, pubkey])
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
removeMember(pubkey: string) {
|
||||||
|
this.values.members = this.values.members.filter(pk => pk !== pubkey)
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
const tags: string[][] = [
|
||||||
|
["d", this.values.h],
|
||||||
|
...this.values.members.map(pk => ["p", pk]),
|
||||||
|
]
|
||||||
|
|
||||||
|
return {kind: this.kind, tags, content: ""}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import {uniq} from "@welshman/lib"
|
||||||
|
import {ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER, getTagValue, getPubkeyTagValues} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import type {ISigner} from "@welshman/signer"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type RoomMembershipOpValues = {
|
||||||
|
kind: number
|
||||||
|
h: string
|
||||||
|
pubkeys: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeRoomMembershipOpValues = (
|
||||||
|
values: Partial<RoomMembershipOpValues> = {},
|
||||||
|
): RoomMembershipOpValues => ({
|
||||||
|
kind: ROOM_ADD_MEMBER,
|
||||||
|
h: "",
|
||||||
|
pubkeys: [],
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const makeRoomAddMember = (h: string, pubkeys: string[]) =>
|
||||||
|
RoomMembershipOp.init({kind: ROOM_ADD_MEMBER, h, pubkeys})
|
||||||
|
|
||||||
|
export const makeRoomRemoveMember = (h: string, pubkeys: string[]) =>
|
||||||
|
RoomMembershipOp.init({kind: ROOM_REMOVE_MEMBER, h, pubkeys})
|
||||||
|
|
||||||
|
// NIP-29 moderation op for adding (kind 9000) or removing (kind 9001) room
|
||||||
|
// members. These are regular (non-addressable) events carrying the group id in
|
||||||
|
// the "h" tag and the affected pubkeys in "p" tags. The two kinds share an
|
||||||
|
// identical shape, so they're merged into one kind-discriminated class.
|
||||||
|
//
|
||||||
|
// Because the base DomainObject treats `kind` as a fixed value and asserts
|
||||||
|
// event.kind === this.kind in parse(), `kind` is a mutable instance field here:
|
||||||
|
// it's seeded from values.kind in normalizeValues, and parse() is overridden to
|
||||||
|
// adopt the event's kind before normalizing.
|
||||||
|
export class RoomMembershipOp extends DomainObject<RoomMembershipOpValues> {
|
||||||
|
kind = ROOM_ADD_MEMBER
|
||||||
|
values = makeRoomMembershipOpValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<RoomMembershipOpValues> = {}) {
|
||||||
|
const normalized = makeRoomMembershipOpValues(values)
|
||||||
|
|
||||||
|
this.kind = normalized.kind
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<RoomMembershipOpValues> {
|
||||||
|
return {
|
||||||
|
kind: event.kind,
|
||||||
|
h: getTagValue("h", event.tags) || "",
|
||||||
|
pubkeys: uniq(getPubkeyTagValues(event.tags)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async parse(event: TrustedEvent, signer?: ISigner) {
|
||||||
|
this.event = event
|
||||||
|
this.kind = event.kind
|
||||||
|
this.values = this.normalizeValues(await this.parseEvent(event, signer))
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
h() {
|
||||||
|
return this.values.h
|
||||||
|
}
|
||||||
|
|
||||||
|
pubkeys() {
|
||||||
|
return this.values.pubkeys
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdd() {
|
||||||
|
return this.kind === ROOM_ADD_MEMBER
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
const tags: string[][] = [
|
||||||
|
["h", this.values.h],
|
||||||
|
...this.values.pubkeys.map(pk => ["p", pk]),
|
||||||
|
]
|
||||||
|
|
||||||
|
return {kind: this.kind, tags, content: ""}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import {ROOM_META, getIdentifier, getTag, getTagValue} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type RoomMetaValues = {
|
||||||
|
h: string
|
||||||
|
name?: string
|
||||||
|
about?: string
|
||||||
|
picture?: string
|
||||||
|
pictureMeta?: string[]
|
||||||
|
isClosed: boolean
|
||||||
|
isHidden: boolean
|
||||||
|
isPrivate: boolean
|
||||||
|
isRestricted: boolean
|
||||||
|
livekit: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const vowels = "a,e,i,o,u,ay,ey,oy,ou,ia,ea,ough,oo,ee,argh".split(",")
|
||||||
|
|
||||||
|
const consonants =
|
||||||
|
"p,b,t,d,k,g,ch,sh,th,f,v,s,z,l,r,m,n,pl,bl,cl,gl,pr,br,tr,dr,kr,gr,fl,sl,fr,thr,str,sk,sp,st".split(
|
||||||
|
",",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generate a random NIP-29 group id ("h" / "d" tag value).
|
||||||
|
export const generateH = () => {
|
||||||
|
const n = (6 + Math.random() * 2) | 0
|
||||||
|
const s = [consonants, vowels]
|
||||||
|
|
||||||
|
if (Math.random() < 0.5) {
|
||||||
|
s.reverse()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
Array.from({length: n}, (_, i) =>
|
||||||
|
s[i % 2].splice((Math.random() * s[i % 2].length) | 0, 1),
|
||||||
|
).join("") +
|
||||||
|
(1 + Math.floor(Math.random() * 9))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeRoomMetaValues = (values: Partial<RoomMetaValues> = {}): RoomMetaValues => ({
|
||||||
|
h: values.h || generateH(),
|
||||||
|
isClosed: false,
|
||||||
|
isHidden: false,
|
||||||
|
isPrivate: false,
|
||||||
|
isRestricted: false,
|
||||||
|
livekit: false,
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// NIP-29 kind-39000 relay-generated group metadata. Addressable, with the group
|
||||||
|
// id ("h") stored in the "d" tag. Tags-only content, so it extends DomainObject
|
||||||
|
// directly rather than the encryptable list base.
|
||||||
|
export class RoomMeta extends DomainObject<RoomMetaValues> {
|
||||||
|
readonly kind = ROOM_META
|
||||||
|
values = makeRoomMetaValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<RoomMetaValues> = {}) {
|
||||||
|
return makeRoomMetaValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<RoomMetaValues> {
|
||||||
|
const pic = getTag("picture", event.tags)
|
||||||
|
|
||||||
|
return {
|
||||||
|
h: getIdentifier(event) || "",
|
||||||
|
name: getTagValue("name", event.tags),
|
||||||
|
about: getTagValue("about", event.tags),
|
||||||
|
picture: pic?.[1],
|
||||||
|
pictureMeta: pic ? pic.slice(2) : undefined,
|
||||||
|
isClosed: Boolean(getTag("closed", event.tags)),
|
||||||
|
isHidden: Boolean(getTag("hidden", event.tags)),
|
||||||
|
isPrivate: Boolean(getTag("private", event.tags)),
|
||||||
|
isRestricted: Boolean(getTag("restricted", event.tags)),
|
||||||
|
livekit: Boolean(getTag("livekit", event.tags)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h() {
|
||||||
|
return this.values.h
|
||||||
|
}
|
||||||
|
|
||||||
|
name() {
|
||||||
|
return this.values.name
|
||||||
|
}
|
||||||
|
|
||||||
|
about() {
|
||||||
|
return this.values.about
|
||||||
|
}
|
||||||
|
|
||||||
|
picture() {
|
||||||
|
return this.values.picture
|
||||||
|
}
|
||||||
|
|
||||||
|
pictureMeta() {
|
||||||
|
return this.values.pictureMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
isClosed() {
|
||||||
|
return this.values.isClosed
|
||||||
|
}
|
||||||
|
|
||||||
|
isHidden() {
|
||||||
|
return this.values.isHidden
|
||||||
|
}
|
||||||
|
|
||||||
|
isPrivate() {
|
||||||
|
return this.values.isPrivate
|
||||||
|
}
|
||||||
|
|
||||||
|
isRestricted() {
|
||||||
|
return this.values.isRestricted
|
||||||
|
}
|
||||||
|
|
||||||
|
livekit() {
|
||||||
|
return this.values.livekit
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
const tags: string[][] = [["d", this.values.h]]
|
||||||
|
|
||||||
|
if (this.values.name) tags.push(["name", this.values.name])
|
||||||
|
if (this.values.about) tags.push(["about", this.values.about])
|
||||||
|
|
||||||
|
if (this.values.picture) {
|
||||||
|
tags.push(["picture", this.values.picture, ...(this.values.pictureMeta || [])])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.values.isClosed) tags.push(["closed"])
|
||||||
|
if (this.values.isHidden) tags.push(["hidden"])
|
||||||
|
if (this.values.isPrivate) tags.push(["private"])
|
||||||
|
if (this.values.isRestricted) tags.push(["restricted"])
|
||||||
|
if (this.values.livekit) tags.push(["livekit"])
|
||||||
|
|
||||||
|
return {kind: this.kind, tags, content: ""}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import {uniqBy} from "@welshman/lib"
|
||||||
|
import {SEARCH_RELAYS, getTagValues, normalizeRelayUrl} from "@welshman/util"
|
||||||
|
import {EncryptableList} from "./List.js"
|
||||||
|
|
||||||
|
// NIP-51 kind-10007 search relays (NIP-50). Entries are marker-less
|
||||||
|
// ['relay', url] tags (NOT NIP-65 'r' tags with read/write markers). Identical
|
||||||
|
// structure to BlockedRelayList; `urls()` stays a flat, normalized set.
|
||||||
|
export class SearchRelayList extends EncryptableList {
|
||||||
|
readonly kind = SEARCH_RELAYS
|
||||||
|
|
||||||
|
urls() {
|
||||||
|
return uniqBy(normalizeRelayUrl, getTagValues("relay", this.tags()))
|
||||||
|
}
|
||||||
|
|
||||||
|
addRelay(url: string) {
|
||||||
|
return this.addPublicTags(["relay", normalizeRelayUrl(url)])
|
||||||
|
}
|
||||||
|
|
||||||
|
removeRelay(url: string) {
|
||||||
|
return this.removeTagsWithValue(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
setRelays(urls: string[]) {
|
||||||
|
this.keepTagsWithKey("relay")
|
||||||
|
|
||||||
|
return this.addPublicTags(...urls.map(url => ["relay", normalizeRelayUrl(url)]))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import {append, parseJson, remove} from "@welshman/lib"
|
||||||
|
import {APP_DATA} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {decrypt} from "@welshman/signer"
|
||||||
|
import type {ISigner} from "@welshman/signer"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
// Literal d-tag identifying flotilla's single addressable settings event.
|
||||||
|
export const SETTINGS_IDENTIFIER = "flotilla/settings"
|
||||||
|
|
||||||
|
export type SpaceNotificationSettings = {
|
||||||
|
url: string
|
||||||
|
notify: boolean
|
||||||
|
exceptions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SettingsValues = {
|
||||||
|
show_media: boolean
|
||||||
|
hide_sensitive: boolean
|
||||||
|
trusted_relays: string[]
|
||||||
|
report_usage: boolean
|
||||||
|
report_errors: boolean
|
||||||
|
relay_auth: "aggressive" | "conservative"
|
||||||
|
send_delay: number
|
||||||
|
font_size: number
|
||||||
|
alerts: SpaceNotificationSettings[]
|
||||||
|
zap_amounts: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultSettings: SettingsValues = {
|
||||||
|
show_media: true,
|
||||||
|
hide_sensitive: true,
|
||||||
|
trusted_relays: [],
|
||||||
|
report_usage: true,
|
||||||
|
report_errors: true,
|
||||||
|
relay_auth: "conservative",
|
||||||
|
send_delay: 0,
|
||||||
|
font_size: 1.1,
|
||||||
|
alerts: [],
|
||||||
|
zap_amounts: [21, 210, 2100, 21000],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeSettingsValues = (values: Partial<SettingsValues> = {}): SettingsValues => ({
|
||||||
|
...defaultSettings,
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// FLOTILLA-SPECIFIC kind-30078 (NIP-78 app data) settings blob, addressable via the
|
||||||
|
// literal d-tag "flotilla/settings". The content is NIP-44 encrypted JSON, so `parse`
|
||||||
|
// needs the signer to decrypt and `toTemplate` needs it to encrypt: the values are
|
||||||
|
// stored to the author's own pubkey. Mutators stay synchronous over the decrypted
|
||||||
|
// `values`; encryption only happens on serialization.
|
||||||
|
export class Settings extends DomainObject<SettingsValues> {
|
||||||
|
readonly kind = APP_DATA
|
||||||
|
values = makeSettingsValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<SettingsValues> = {}) {
|
||||||
|
return makeSettingsValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async parseEvent(
|
||||||
|
event: TrustedEvent,
|
||||||
|
signer?: ISigner,
|
||||||
|
): Promise<Partial<SettingsValues>> {
|
||||||
|
if (!event.content) return defaultSettings
|
||||||
|
|
||||||
|
let plaintext = ""
|
||||||
|
|
||||||
|
if (signer) {
|
||||||
|
try {
|
||||||
|
plaintext = await decrypt(signer, event.pubkey, event.content)
|
||||||
|
} catch (e) {
|
||||||
|
return defaultSettings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {...defaultSettings, ...(parseJson(plaintext) || {})}
|
||||||
|
}
|
||||||
|
|
||||||
|
showMedia() {
|
||||||
|
return this.values.show_media
|
||||||
|
}
|
||||||
|
|
||||||
|
hideSensitive() {
|
||||||
|
return this.values.hide_sensitive
|
||||||
|
}
|
||||||
|
|
||||||
|
trustedRelays() {
|
||||||
|
return this.values.trusted_relays
|
||||||
|
}
|
||||||
|
|
||||||
|
reportUsage() {
|
||||||
|
return this.values.report_usage
|
||||||
|
}
|
||||||
|
|
||||||
|
reportErrors() {
|
||||||
|
return this.values.report_errors
|
||||||
|
}
|
||||||
|
|
||||||
|
relayAuth() {
|
||||||
|
return this.values.relay_auth
|
||||||
|
}
|
||||||
|
|
||||||
|
sendDelay() {
|
||||||
|
return this.values.send_delay
|
||||||
|
}
|
||||||
|
|
||||||
|
fontSize() {
|
||||||
|
return this.values.font_size
|
||||||
|
}
|
||||||
|
|
||||||
|
alerts() {
|
||||||
|
return this.values.alerts
|
||||||
|
}
|
||||||
|
|
||||||
|
zapAmounts() {
|
||||||
|
return this.values.zap_amounts
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port of flotilla's getShouldNotify branching: missing pref -> notify; space-level
|
||||||
|
// (no room) -> the pref's notify flag; otherwise rooms in `exceptions` invert the flag.
|
||||||
|
getShouldNotify(url: string, h?: string) {
|
||||||
|
const pref = this.values.alerts.find(s => s.url === url)
|
||||||
|
|
||||||
|
if (!pref) return true
|
||||||
|
if (!h) return pref.notify
|
||||||
|
if (pref.notify) return !pref.exceptions.includes(h)
|
||||||
|
|
||||||
|
return pref.exceptions.includes(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
addTrustedRelay(url: string) {
|
||||||
|
this.values.trusted_relays = append(url, this.values.trusted_relays)
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
removeTrustedRelay(url: string) {
|
||||||
|
this.values.trusted_relays = remove(url, this.values.trusted_relays)
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert a space-level notification preference, resetting its room exceptions.
|
||||||
|
setSpaceNotifications(url: string, notify: boolean) {
|
||||||
|
const {alerts} = this.values
|
||||||
|
const existing = alerts.find(s => s.url === url)
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
this.values.alerts = alerts.map(s =>
|
||||||
|
s.url === url ? {...s, notify, exceptions: []} : s,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this.values.alerts = [...alerts, {url, notify, exceptions: []}]
|
||||||
|
}
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle a room (h) in a space's exception list, creating the pref if absent.
|
||||||
|
toggleRoomNotifications(url: string, h: string) {
|
||||||
|
const {alerts} = this.values
|
||||||
|
const existing = alerts.find(s => s.url === url)
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
this.values.alerts = [...alerts, {url, notify: true, exceptions: [h]}]
|
||||||
|
} else {
|
||||||
|
const exceptions = existing.exceptions.includes(h)
|
||||||
|
? remove(h, existing.exceptions)
|
||||||
|
: append(h, existing.exceptions)
|
||||||
|
|
||||||
|
this.values.alerts = alerts.map(s => (s.url === url ? {...s, exceptions} : s))
|
||||||
|
}
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(signer?: ISigner): Promise<EventTemplate> {
|
||||||
|
if (!signer) {
|
||||||
|
throw new Error("A signer is required to serialize Settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
const pubkey = await signer.getPubkey()
|
||||||
|
const content = await signer.nip44.encrypt(pubkey, JSON.stringify(this.values))
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: this.kind,
|
||||||
|
content,
|
||||||
|
tags: [["d", SETTINGS_IDENTIFIER]],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import {THREAD, getTagValue} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type ThreadValues = {
|
||||||
|
title?: string
|
||||||
|
content: string
|
||||||
|
h?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeThreadValues = (values: Partial<ThreadValues> = {}): ThreadValues => ({
|
||||||
|
content: "",
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// NIP-7D kind-11 forum thread root. The body lives in `content` as plain text
|
||||||
|
// (not JSON), the title is carried in a "title" tag, and an optional "h" tag
|
||||||
|
// scopes the thread to a room. Non-addressable (referenced by event id);
|
||||||
|
// replies are COMMENT (kind 1111) via "#E". Flotilla also appends editor/inline
|
||||||
|
// tags and an optional PROTECTED ("-") tag at call sites, so on serialize we
|
||||||
|
// preserve any non-title/non-h tags from the parsed event to keep round-tripping
|
||||||
|
// lossless for those extra tags.
|
||||||
|
export class Thread extends DomainObject<ThreadValues> {
|
||||||
|
readonly kind = THREAD
|
||||||
|
values = makeThreadValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<ThreadValues> = {}) {
|
||||||
|
return makeThreadValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<ThreadValues> {
|
||||||
|
return {
|
||||||
|
title: getTagValue("title", event.tags),
|
||||||
|
content: event.content || "",
|
||||||
|
h: getTagValue("h", event.tags),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
title() {
|
||||||
|
return this.values.title
|
||||||
|
}
|
||||||
|
|
||||||
|
content() {
|
||||||
|
return this.values.content
|
||||||
|
}
|
||||||
|
|
||||||
|
h() {
|
||||||
|
return this.values.h
|
||||||
|
}
|
||||||
|
|
||||||
|
room() {
|
||||||
|
return this.values.h
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
const tags: string[][] = (this.event?.tags || []).filter(
|
||||||
|
t => t[0] !== "title" && t[0] !== "h",
|
||||||
|
)
|
||||||
|
|
||||||
|
if (this.values.title) {
|
||||||
|
tags.push(["title", this.values.title])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.values.h) {
|
||||||
|
tags.push(["h", this.values.h])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {kind: this.kind, content: this.values.content, tags}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {uniq} from "@welshman/lib"
|
||||||
|
import {TOPICS, getTopicTagValues, getAddressTagValues} from "@welshman/util"
|
||||||
|
import {EncryptableList} from "./List.js"
|
||||||
|
|
||||||
|
// NIP-51 kind-10015 interests/followed-topics list. Followed hashtags are stored
|
||||||
|
// as `t` tags; the list may also reference interest sets (kind 30015) via `a`
|
||||||
|
// tags. Extends EncryptableList so entries may be public (tags) or private
|
||||||
|
// (encrypted content), treated as one merged set by the accessors.
|
||||||
|
export class TopicList extends EncryptableList {
|
||||||
|
readonly kind = TOPICS
|
||||||
|
|
||||||
|
topics() {
|
||||||
|
return uniq(getTopicTagValues(this.tags()))
|
||||||
|
}
|
||||||
|
|
||||||
|
addresses() {
|
||||||
|
return uniq(getAddressTagValues(this.tags()))
|
||||||
|
}
|
||||||
|
|
||||||
|
follow(topic: string) {
|
||||||
|
return this.addPublicTags(["t", topic])
|
||||||
|
}
|
||||||
|
|
||||||
|
unfollow(topic: string) {
|
||||||
|
return this.removeTagsWithValue(topic)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {ZAP_GOAL, getTagValue, getTagValues} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type ZapGoalValues = {
|
||||||
|
title: string
|
||||||
|
summary?: string
|
||||||
|
amount: number
|
||||||
|
relays: string[]
|
||||||
|
h?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeZapGoalValues = (values: Partial<ZapGoalValues> = {}): ZapGoalValues => ({
|
||||||
|
title: "",
|
||||||
|
amount: 0,
|
||||||
|
relays: [],
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// NIP-75 kind-9041 zap goal. A fundraising target that drives flotilla's goals
|
||||||
|
// feature: the goal title lives in `content` as plain text (not JSON), the body
|
||||||
|
// in a "summary" tag, the target amount in an "amount" tag (millisats, parsed as
|
||||||
|
// an int defaulting to 0), the relays to tally receipts from in repeated
|
||||||
|
// "relays" tags, and an optional "h" tag scopes the goal to a room.
|
||||||
|
// Non-addressable (referenced by event id via "#E"); the funding tally is
|
||||||
|
// computed elsewhere from sibling zap receipts (ZAP_RESPONSE) and is not modeled
|
||||||
|
// here. Tags-only metadata, so it extends DomainObject directly.
|
||||||
|
export class ZapGoal extends DomainObject<ZapGoalValues> {
|
||||||
|
readonly kind = ZAP_GOAL
|
||||||
|
values = makeZapGoalValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<ZapGoalValues> = {}) {
|
||||||
|
return makeZapGoalValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<ZapGoalValues> {
|
||||||
|
return {
|
||||||
|
title: event.content || "",
|
||||||
|
summary: getTagValue("summary", event.tags),
|
||||||
|
amount: parseInt(getTagValue("amount", event.tags) || "0") || 0,
|
||||||
|
relays: getTagValues("relays", event.tags),
|
||||||
|
h: getTagValue("h", event.tags),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
title() {
|
||||||
|
return this.values.title
|
||||||
|
}
|
||||||
|
|
||||||
|
summary() {
|
||||||
|
return this.values.summary
|
||||||
|
}
|
||||||
|
|
||||||
|
amount() {
|
||||||
|
return this.values.amount
|
||||||
|
}
|
||||||
|
|
||||||
|
relays() {
|
||||||
|
return this.values.relays
|
||||||
|
}
|
||||||
|
|
||||||
|
h() {
|
||||||
|
return this.values.h
|
||||||
|
}
|
||||||
|
|
||||||
|
room() {
|
||||||
|
return this.values.h
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
const tags: string[][] = []
|
||||||
|
|
||||||
|
if (this.values.summary) {
|
||||||
|
tags.push(["summary", this.values.summary])
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.push(["amount", String(this.values.amount)])
|
||||||
|
|
||||||
|
for (const relay of this.values.relays) {
|
||||||
|
tags.push(["relays", relay])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.values.h) {
|
||||||
|
tags.push(["h", this.values.h])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {kind: this.kind, content: this.values.title, tags}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import {parseJson} from "@welshman/lib"
|
||||||
|
import {ZAP_RECEIPT, getTagValue, getInvoiceAmount} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent, Zapper} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type ZapReceiptValues = {
|
||||||
|
bolt11?: string
|
||||||
|
invoiceAmount?: number
|
||||||
|
request?: TrustedEvent
|
||||||
|
recipient?: string
|
||||||
|
eventId?: string
|
||||||
|
preimage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeZapReceiptValues = (
|
||||||
|
values: Partial<ZapReceiptValues> = {},
|
||||||
|
): ZapReceiptValues => ({...values})
|
||||||
|
|
||||||
|
// NIP-57 kind-9735 zap receipt. Relay/LN-generated, so it's read-only in spirit:
|
||||||
|
// we parse the bolt11 invoice and the embedded kind-9734 request, and round-trip
|
||||||
|
// the source event when serializing.
|
||||||
|
export class ZapReceipt extends DomainObject<ZapReceiptValues> {
|
||||||
|
readonly kind = ZAP_RECEIPT
|
||||||
|
values = makeZapReceiptValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<ZapReceiptValues> = {}) {
|
||||||
|
return makeZapReceiptValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<ZapReceiptValues> {
|
||||||
|
const bolt11 = getTagValue("bolt11", event.tags)
|
||||||
|
const description = getTagValue("description", event.tags)
|
||||||
|
|
||||||
|
return {
|
||||||
|
bolt11,
|
||||||
|
invoiceAmount: bolt11 ? getInvoiceAmount(bolt11) : undefined,
|
||||||
|
request: description ? parseJson(description) || undefined : undefined,
|
||||||
|
recipient: getTagValue("p", event.tags),
|
||||||
|
eventId: getTagValue("e", event.tags),
|
||||||
|
preimage: getTagValue("preimage", event.tags),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bolt11() {
|
||||||
|
return this.values.bolt11
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoice amount in millisats.
|
||||||
|
invoiceAmount() {
|
||||||
|
return this.values.invoiceAmount
|
||||||
|
}
|
||||||
|
|
||||||
|
// The embedded kind-9734 zap request.
|
||||||
|
request() {
|
||||||
|
return this.values.request
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pubkey that requested the zap.
|
||||||
|
sender() {
|
||||||
|
return this.values.request?.pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
recipient() {
|
||||||
|
return this.values.recipient
|
||||||
|
}
|
||||||
|
|
||||||
|
// The zapped event, if any.
|
||||||
|
eventId() {
|
||||||
|
return this.values.eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
// The comment the sender attached to the zap request.
|
||||||
|
comment() {
|
||||||
|
return this.values.request?.content
|
||||||
|
}
|
||||||
|
|
||||||
|
preimage() {
|
||||||
|
return this.values.preimage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port of zapFromEvent's NIP-57 verification (util/src/Zaps.ts). Returns false
|
||||||
|
// unless the receipt is a legitimate, unforged zap from the given zapper.
|
||||||
|
validate(zapper: Zapper): boolean {
|
||||||
|
const {request, invoiceAmount, recipient} = this.values
|
||||||
|
|
||||||
|
// We need a parsed request and a parsed invoice amount to verify anything.
|
||||||
|
if (!request || invoiceAmount === undefined) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't count zaps that the user requested for himself.
|
||||||
|
if (request.pubkey === zapper.pubkey) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = getTagValue("amount", request.tags)
|
||||||
|
const lnurl = getTagValue("lnurl", request.tags)
|
||||||
|
|
||||||
|
// Verify that the zapper actually sent the requested amount (if supplied).
|
||||||
|
if (amount && parseInt(amount) !== invoiceAmount) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the recipient and the zapper are the same person, it's legit.
|
||||||
|
if (recipient === this.event?.pubkey) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the sending client provided an lnurl tag, verify that too.
|
||||||
|
if (lnurl && lnurl !== zapper.lnurl) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the receipt actually came from the recipient's zapper.
|
||||||
|
if (this.event?.pubkey !== zapper.nostrPubkey) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receipts are relay/LN-generated; round-trip the source event verbatim.
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
return {
|
||||||
|
kind: this.kind,
|
||||||
|
content: this.event?.content || "",
|
||||||
|
tags: this.event?.tags || [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import {ZAP_REQUEST, getTag, getTagValue} from "@welshman/util"
|
||||||
|
import type {EventTemplate, TrustedEvent} from "@welshman/util"
|
||||||
|
import {DomainObject} from "./base.js"
|
||||||
|
|
||||||
|
export type ZapRequestValues = {
|
||||||
|
amount?: number
|
||||||
|
lnurl?: string
|
||||||
|
recipient?: string
|
||||||
|
relays: string[]
|
||||||
|
eventId?: string
|
||||||
|
anonymous: boolean
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeZapRequestValues = (
|
||||||
|
values: Partial<ZapRequestValues> = {},
|
||||||
|
): ZapRequestValues => ({
|
||||||
|
relays: [],
|
||||||
|
anonymous: false,
|
||||||
|
content: "",
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
|
// NIP-57 kind-9734 zap request: zap metadata in tags plus an optional comment in
|
||||||
|
// content. `amount` is in millisats.
|
||||||
|
export class ZapRequest extends DomainObject<ZapRequestValues> {
|
||||||
|
readonly kind = ZAP_REQUEST
|
||||||
|
values = makeZapRequestValues()
|
||||||
|
|
||||||
|
protected normalizeValues(values: Partial<ZapRequestValues> = {}) {
|
||||||
|
return makeZapRequestValues(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseEvent(event: TrustedEvent): Partial<ZapRequestValues> {
|
||||||
|
const amount = getTagValue("amount", event.tags)
|
||||||
|
const relaysTag = getTag("relays", event.tags)
|
||||||
|
|
||||||
|
return {
|
||||||
|
amount: amount ? parseInt(amount) : undefined,
|
||||||
|
lnurl: getTagValue("lnurl", event.tags),
|
||||||
|
recipient: getTagValue("p", event.tags),
|
||||||
|
relays: relaysTag ? relaysTag.slice(1) : [],
|
||||||
|
eventId: getTagValue("e", event.tags),
|
||||||
|
anonymous: Boolean(event.tags.find(t => t[0] === "anon")),
|
||||||
|
content: event.content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
amount() {
|
||||||
|
return this.values.amount
|
||||||
|
}
|
||||||
|
|
||||||
|
setAmount(amount: number) {
|
||||||
|
this.values.amount = amount
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
lnurl() {
|
||||||
|
return this.values.lnurl
|
||||||
|
}
|
||||||
|
|
||||||
|
setLnurl(lnurl: string) {
|
||||||
|
this.values.lnurl = lnurl
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
recipient() {
|
||||||
|
return this.values.recipient
|
||||||
|
}
|
||||||
|
|
||||||
|
setRecipient(recipient: string) {
|
||||||
|
this.values.recipient = recipient
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
relays() {
|
||||||
|
return this.values.relays
|
||||||
|
}
|
||||||
|
|
||||||
|
setRelays(relays: string[]) {
|
||||||
|
this.values.relays = relays
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
eventId() {
|
||||||
|
return this.values.eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
setEventId(eventId: string) {
|
||||||
|
this.values.eventId = eventId
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
isAnonymous() {
|
||||||
|
return this.values.anonymous
|
||||||
|
}
|
||||||
|
|
||||||
|
setAnonymous(anonymous: boolean) {
|
||||||
|
this.values.anonymous = anonymous
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
comment() {
|
||||||
|
return this.values.content
|
||||||
|
}
|
||||||
|
|
||||||
|
setComment(content: string) {
|
||||||
|
this.values.content = content
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
|
const {amount, lnurl, recipient, relays, eventId, anonymous, content} = this.values
|
||||||
|
|
||||||
|
const tags: string[][] = [["relays", ...relays]]
|
||||||
|
|
||||||
|
if (amount !== undefined) {
|
||||||
|
tags.push(["amount", String(amount)])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lnurl !== undefined) {
|
||||||
|
tags.push(["lnurl", lnurl])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipient !== undefined) {
|
||||||
|
tags.push(["p", recipient])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventId) {
|
||||||
|
tags.push(["e", eventId])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anonymous) {
|
||||||
|
tags.push(["anon"])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {kind: this.kind, tags, content}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,48 @@
|
|||||||
export * from "./base.js"
|
export * from "./base.js"
|
||||||
|
export * from "./BlockedRelayList.js"
|
||||||
|
export * from "./BlossomServerList.js"
|
||||||
|
export * from "./BookmarkList.js"
|
||||||
|
export * from "./CalendarEvent.js"
|
||||||
|
export * from "./ChannelList.js"
|
||||||
|
export * from "./Classified.js"
|
||||||
|
export * from "./Comment.js"
|
||||||
|
export * from "./CommunityList.js"
|
||||||
|
export * from "./EmojiList.js"
|
||||||
|
export * from "./Feed.js"
|
||||||
|
export * from "./FeedList.js"
|
||||||
|
export * from "./FileServerList.js"
|
||||||
|
export * from "./FollowList.js"
|
||||||
|
export * from "./Handler.js"
|
||||||
|
export * from "./HandlerRecommendation.js"
|
||||||
export * from "./List.js"
|
export * from "./List.js"
|
||||||
|
export * from "./MessagingRelayList.js"
|
||||||
export * from "./MuteList.js"
|
export * from "./MuteList.js"
|
||||||
|
export * from "./PinList.js"
|
||||||
|
export * from "./Poll.js"
|
||||||
|
export * from "./PollResponse.js"
|
||||||
export * from "./Profile.js"
|
export * from "./Profile.js"
|
||||||
|
export * from "./RelayInvite.js"
|
||||||
|
export * from "./RelayJoin.js"
|
||||||
|
export * from "./RelayLeave.js"
|
||||||
|
export * from "./RelayList.js"
|
||||||
|
export * from "./RelayMembers.js"
|
||||||
|
export * from "./RelayMembershipOp.js"
|
||||||
|
export * from "./RelaySet.js"
|
||||||
|
export * from "./Report.js"
|
||||||
|
export * from "./RoomAdmins.js"
|
||||||
|
export * from "./RoomCreate.js"
|
||||||
|
export * from "./RoomCreatePermission.js"
|
||||||
|
export * from "./RoomDelete.js"
|
||||||
|
export * from "./RoomJoin.js"
|
||||||
|
export * from "./RoomLeave.js"
|
||||||
|
export * from "./RoomList.js"
|
||||||
|
export * from "./RoomMembers.js"
|
||||||
|
export * from "./RoomMembershipOp.js"
|
||||||
|
export * from "./RoomMeta.js"
|
||||||
|
export * from "./SearchRelayList.js"
|
||||||
|
export * from "./Settings.js"
|
||||||
|
export * from "./Thread.js"
|
||||||
|
export * from "./TopicList.js"
|
||||||
|
export * from "./ZapGoal.js"
|
||||||
|
export * from "./ZapReceipt.js"
|
||||||
|
export * from "./ZapRequest.js"
|
||||||
|
|||||||
Reference in New Issue
Block a user