Refine domain, integrate into app
tests / tests (push) Failing after 5m14s

This commit is contained in:
Jon Staab
2026-06-19 12:50:34 -07:00
parent 1bd62d3024
commit e2a6ef21cd
115 changed files with 1354 additions and 3176 deletions
-223
View File
@@ -1,223 +0,0 @@
import {MUTES} from "@welshman/util"
import {now} from "@welshman/lib"
import {describe, it, expect, vi, beforeEach} from "vitest"
import {Encryptable, asDecryptedEvent} from "../src/Encryptable"
import type {OwnedEvent, TrustedEvent} from "../src/Events"
describe("Encryptable", () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Mock encryption function
const mockEncrypt = vi.fn(async (text: string) => `encrypted:${text}`)
// Realistic Nostr values
const pub = "ee".repeat(32)
const currentTime = now()
describe("constructor", () => {
it("should create an instance with minimal event template", () => {
const event: Partial<OwnedEvent> = {
kind: MUTES,
pubkey: pub,
created_at: currentTime,
}
const encryptable = new Encryptable(event, {})
expect(encryptable.event).toBe(event)
expect(encryptable.updates).toEqual({})
})
it("should create an instance with full event template", () => {
const event: OwnedEvent = {
kind: MUTES,
pubkey: pub,
created_at: currentTime,
content: "original encrypted content",
tags: [["p", pub]],
}
const updates = {
content: JSON.stringify({list: ["item1", "item2"]}),
tags: [["p", pub, "wss://relay.example.com"]],
}
const encryptable = new Encryptable(event, updates)
expect(encryptable.event).toBe(event)
expect(encryptable.updates).toBe(updates)
})
})
describe("reconcile", () => {
it("should encrypt content updates", async () => {
const event: Partial<OwnedEvent> = {
kind: MUTES,
pubkey: pub,
created_at: currentTime,
}
const updates = {
content: JSON.stringify({muted: [pub]}),
}
const encryptable = new Encryptable(event, updates)
const result = await encryptable.reconcile(mockEncrypt)
expect(result.content).toBe(`encrypted:${updates.content}`)
expect(mockEncrypt).toHaveBeenCalledWith(updates.content)
})
it("should encrypt tag updates", async () => {
const event: Partial<OwnedEvent> = {
kind: MUTES,
pubkey: pub,
created_at: currentTime,
}
const updates = {
tags: [["p", pub, "wss://relay.example.com"]],
}
const encryptable = new Encryptable(event, updates)
const result = await encryptable.reconcile(mockEncrypt)
expect(result.tags[0][1]).toBe(`encrypted:${pub}`)
expect(mockEncrypt).toHaveBeenCalledWith(pub)
})
it("should handle both content and tag updates", async () => {
const event: Partial<OwnedEvent> = {
kind: MUTES,
pubkey: pub,
created_at: currentTime,
}
const updates = {
content: JSON.stringify({muted: [pub]}),
tags: [["p", pub, "wss://relay.example.com"]],
}
const encryptable = new Encryptable(event, updates)
const result = await encryptable.reconcile(mockEncrypt)
expect(result.content).toBe(`encrypted:${updates.content}`)
expect(result.tags[0][1]).toBe(`encrypted:${pub}`)
expect(mockEncrypt).toHaveBeenCalledTimes(2)
})
it("should preserve original content when no updates", async () => {
const event: OwnedEvent = {
kind: MUTES,
pubkey: pub,
created_at: currentTime,
content: JSON.stringify({originalList: [pub]}),
tags: [],
}
const encryptable = new Encryptable(event, {})
const result = await encryptable.reconcile(mockEncrypt)
expect(result.content).toBe(event.content)
expect(mockEncrypt).not.toHaveBeenCalled()
})
it("should preserve original tags when no updates", async () => {
const event: OwnedEvent = {
kind: MUTES,
pubkey: pub,
created_at: currentTime,
content: "",
tags: [["p", pub, "wss://relay.example.com"]],
}
const encryptable = new Encryptable(event, {})
const result = await encryptable.reconcile(mockEncrypt)
expect(result.tags).toEqual(event.tags)
expect(mockEncrypt).not.toHaveBeenCalled()
})
})
describe("asDecryptedEvent", () => {
it("should create a decrypted event with plaintext", () => {
const event: TrustedEvent = {
id: "ff".repeat(32),
sig: "00".repeat(64),
kind: MUTES,
pubkey: pub,
created_at: currentTime,
content: "encrypted content",
tags: [],
}
const plaintext = {
content: JSON.stringify({muted: [pub]}),
tags: [["p", pub, "wss://relay.example.com"]],
}
const result = asDecryptedEvent(event, plaintext)
expect(result).toEqual({
...event,
plaintext,
})
})
it("should handle empty plaintext", () => {
const event: TrustedEvent = {
id: "ff".repeat(32),
sig: "00".repeat(64),
kind: MUTES,
pubkey: pub,
created_at: currentTime,
content: "encrypted content",
tags: [],
}
const result = asDecryptedEvent(event)
expect(result).toEqual({
...event,
plaintext: {},
})
})
})
describe("error handling", () => {
it("should handle encryption failures", async () => {
const failingEncrypt = async () => {
throw new Error("Encryption failed")
}
const event: Partial<OwnedEvent> = {
kind: MUTES,
pubkey: pub,
created_at: currentTime,
}
const updates = {
content: JSON.stringify({muted: [pub]}),
}
const encryptable = new Encryptable(event, updates)
await expect(encryptable.reconcile(failingEncrypt)).rejects.toThrow("Encryption failed")
})
it("should handle partial encryption failures", async () => {
let callCount = 0
const partialFailingEncrypt = async () => {
callCount++
if (callCount > 1) throw new Error("Encryption failed")
return "encrypted:success"
}
const event: Partial<OwnedEvent> = {
kind: MUTES,
pubkey: pub,
created_at: currentTime,
}
const updates = {
content: JSON.stringify({muted: [pub]}),
tags: [["p", pub]],
}
const encryptable = new Encryptable(event, updates)
await expect(encryptable.reconcile(partialFailingEncrypt)).rejects.toThrow(
"Encryption failed",
)
})
})
})
-197
View File
@@ -1,197 +0,0 @@
import {now} from "@welshman/lib"
import {HANDLER_INFORMATION} from "@welshman/util"
import {describe, it, vi, expect, beforeEach} from "vitest"
import {readHandlers, getHandlerKey, displayHandler, getHandlerAddress} from "../src/Handler"
import type {TrustedEvent} from "../src/Events"
describe("Handler", () => {
beforeEach(() => {
vi.clearAllMocks()
})
const pubkey = "ee".repeat(32)
const id = "ff".repeat(32)
const currentTime = now()
const createHandlerEvent = (overrides = {}): TrustedEvent => ({
id: id,
pubkey: pubkey,
created_at: currentTime,
kind: HANDLER_INFORMATION,
tags: [
["d", "test-handler"],
["k", "30023"],
["k", "30024"],
],
content: JSON.stringify({
name: "Test Handler",
image: "https://example.com/image.jpg",
about: "Test handler description",
website: "https://example.com",
lud16: "user@domain.com",
nip05: "user@domain.com",
}),
...overrides,
})
describe("readHandlers", () => {
it("should parse valid handler event with full metadata", () => {
const event = createHandlerEvent()
const handlers = readHandlers(event)
expect(handlers).toHaveLength(2) // Two k tags
expect(handlers[0]).toMatchObject({
kind: 30023,
identifier: "test-handler",
name: "Test Handler",
image: "https://example.com/image.jpg",
about: "Test handler description",
website: "https://example.com",
lud16: "user@domain.com",
nip05: "user@domain.com",
})
})
it("should handle display_name and picture alternatives", () => {
const event = createHandlerEvent({
content: JSON.stringify({
display_name: "Test Handler",
picture: "https://example.com/image.jpg",
about: "Test description",
}),
})
const handlers = readHandlers(event)
expect(handlers[0].name).toBe("Test Handler")
expect(handlers[0].image).toBe("https://example.com/image.jpg")
})
it("should return empty array if name is missing", () => {
const event = createHandlerEvent({
content: JSON.stringify({
image: "https://example.com/image.jpg",
about: "Test description",
}),
})
const handlers = readHandlers(event)
expect(handlers).toEqual([])
})
it("should return empty array if image is missing", () => {
const event = createHandlerEvent({
content: JSON.stringify({
name: "Test Handler",
about: "Test description",
}),
})
const handlers = readHandlers(event)
expect(handlers).toEqual([])
})
it("should handle invalid JSON content", () => {
const event = createHandlerEvent({
content: "invalid json",
})
const handlers = readHandlers(event)
expect(handlers).toEqual([])
})
it("should handle empty content", () => {
const event = createHandlerEvent({
content: "",
})
const handlers = readHandlers(event)
expect(handlers).toEqual([])
})
it("should handle missing optional fields", () => {
const event = createHandlerEvent({
content: JSON.stringify({
name: "Test Handler",
image: "https://example.com/image.jpg",
}),
})
const handlers = readHandlers(event)
expect(handlers[0]).toMatchObject({
name: "Test Handler",
image: "https://example.com/image.jpg",
about: "",
website: "",
lud16: "",
nip05: "",
})
})
})
describe("getHandlerKey", () => {
it("should generate correct handler key", () => {
const event = createHandlerEvent()
const handler = readHandlers(event)[0]
const key = getHandlerKey(handler)
expect(key).toBe(`30023:31990:${pubkey}:test-handler`)
})
})
describe("displayHandler", () => {
it("should return handler name when available", () => {
const event = createHandlerEvent()
const handler = readHandlers(event)[0]
expect(displayHandler(handler)).toBe("Test Handler")
})
it("should return fallback when handler is undefined", () => {
expect(displayHandler(undefined, "Fallback")).toBe("Fallback")
})
it("should return empty string when no fallback provided", () => {
expect(displayHandler(undefined)).toBe("")
})
})
describe("getHandlerAddress", () => {
it("should return web-tagged address if available", () => {
const event = createHandlerEvent({
tags: [
["a", "30023:pubkey1:test", "relay1", "web"],
["a", "30024:pubkey2:test", "relay2"],
],
})
expect(getHandlerAddress(event)).toBe("30023:pubkey1:test")
})
it("should return first address if no web tag", () => {
const event = createHandlerEvent({
tags: [
["a", "30023:pubkey1:test", "relay1"],
["a", "30024:pubkey2:test", "relay2"],
],
})
expect(getHandlerAddress(event)).toBe("30023:pubkey1:test")
})
it("should return undefined if no address tags", () => {
const event = createHandlerEvent({
tags: [["d", "test-handler"]],
})
expect(getHandlerAddress(event)).toBeUndefined()
})
it("should handle empty tags array", () => {
const event = createHandlerEvent({
tags: [],
})
expect(getHandlerAddress(event)).toBeUndefined()
})
})
})
-324
View File
@@ -1,324 +0,0 @@
import {now} from "@welshman/lib"
import {MUTES} from "@welshman/util"
import {describe, it, vi, expect, beforeEach} from "vitest"
import {
makeList,
readList,
getListTags,
removeFromList,
removeFromListByPredicate,
addToListPublicly,
addToListPrivately,
} from "../src/List"
import type {DecryptedEvent} from "../src/Encryptable"
import type {List} from "../src/List"
describe("List", () => {
beforeEach(() => {
vi.clearAllMocks()
})
const pubkey = "ee".repeat(32)
const validEventId = "ff".repeat(32)
const address = `30023:${pubkey}:test`
const currentTime = now()
const createDecryptedEvent = (overrides = {}): DecryptedEvent => ({
id: validEventId,
pubkey: pubkey,
created_at: currentTime,
kind: MUTES,
tags: [],
content: "",
plaintext: {},
...overrides,
})
describe("makeList", () => {
it("should create a list with defaults", () => {
const list = makeList({kind: MUTES})
expect(list).toEqual({
kind: MUTES,
publicTags: [],
privateTags: [],
})
})
it("should preserve existing tags", () => {
const list = makeList({
kind: MUTES,
publicTags: [["p", pubkey]],
privateTags: [["e", validEventId]],
})
expect(list.publicTags).toHaveLength(1)
expect(list.privateTags).toHaveLength(1)
})
})
describe("readList", () => {
it("should parse valid public tags", () => {
const event = createDecryptedEvent({
tags: [
["p", pubkey],
["e", validEventId],
["a", address],
["t", "test"],
["r", "wss://relay.example.com"],
["relay", "wss://relay.example.com"],
["unknown", "value"],
],
})
const list = readList(event)
expect(list.publicTags).toHaveLength(7)
})
it("should not parse invalid public tags", () => {
const event = createDecryptedEvent({
tags: [
["p", "invalid-pubkey"],
["e", "invalid-event-id"],
["a", "invalid-address"],
["t", ""],
["r", "invalid-url"],
["relay", "invalid-url"],
],
})
const list = readList(event)
expect(list.publicTags).toHaveLength(0)
})
it("should parse valid private tags", () => {
const event = createDecryptedEvent({
plaintext: {
content: JSON.stringify([
["p", pubkey],
["e", validEventId],
]),
},
})
const list = readList(event)
expect(list.privateTags).toHaveLength(2)
})
it("should not parse invalid private tags", () => {
const event = createDecryptedEvent({
plaintext: {
content: JSON.stringify([
["p", "invalid-pubkey"],
["e", "invalid-event-id"],
]),
},
})
const list = readList(event)
expect(list.privateTags).toHaveLength(0)
})
it("should filter invalid tags", () => {
const event = createDecryptedEvent({
tags: [
["p", "invalid-pubkey"],
["e", "invalid-event-id"],
["a", "invalid-address"],
["t", ""],
["r", "invalid-url"],
["relay", "invalid-url"],
],
})
const list = readList(event)
expect(list.publicTags).toHaveLength(0)
})
it("should handle invalid JSON in private content", () => {
const event = createDecryptedEvent({
plaintext: {content: "invalid-json"},
})
const list = readList(event)
expect(list.privateTags).toEqual([])
})
it("should handle non-array private content", () => {
const event = createDecryptedEvent({
plaintext: {content: JSON.stringify({not: "an-array"})},
})
const list = readList(event)
expect(list.privateTags).toEqual([])
})
})
describe("getListTags", () => {
it("should combine public and private tags", () => {
const list: List = {
kind: MUTES,
publicTags: [["p", pubkey]],
privateTags: [["e", validEventId]],
}
const tags = getListTags(list)
expect(tags).toHaveLength(2)
})
it("should handle undefined list", () => {
expect(getListTags(undefined)).toEqual([])
})
})
describe("removeFromList", () => {
it("should remove matching public tags", () => {
const list: List = {
kind: MUTES,
publicTags: [["p", pubkey]],
privateTags: [],
event: createDecryptedEvent(),
}
const result = removeFromList(list, pubkey)
expect(result.event.tags).toHaveLength(0)
})
it("should remove matching private tags", () => {
const list: List = {
kind: MUTES,
publicTags: [],
privateTags: [["p", pubkey]],
event: createDecryptedEvent(),
}
const result = removeFromList(list, pubkey)
const plaintext = JSON.parse(result.updates.content || "[]")
expect(plaintext).toHaveLength(0)
})
})
describe("removeFromListByPredicate", () => {
it("should remove tags matching predicate", () => {
const list: List = {
kind: MUTES,
publicTags: [
["p", pubkey],
["e", validEventId],
],
privateTags: [["p", pubkey]],
event: createDecryptedEvent(),
}
const result = removeFromListByPredicate(list, tag => tag[0] === "p")
expect(result.event.tags).toHaveLength(1)
const plaintext = JSON.parse(result.updates.content || "[]")
expect(plaintext).toHaveLength(0)
})
})
describe("addToListPublicly", () => {
it("should add tags to public list", () => {
const list: List = {
kind: MUTES,
publicTags: [],
privateTags: [],
event: createDecryptedEvent(),
}
const result = addToListPublicly(list, ["p", pubkey])
expect(result.event.tags).toHaveLength(1)
expect(result.updates).toEqual({})
})
it("should deduplicate tags", () => {
const list: List = {
kind: MUTES,
publicTags: [["p", pubkey]],
privateTags: [],
event: createDecryptedEvent(),
}
const result = addToListPublicly(list, ["p", pubkey])
expect(result.event.tags).toHaveLength(1)
})
})
describe("addToListPrivately", () => {
it("should add tags to private list", () => {
const list: List = {
kind: MUTES,
publicTags: [],
privateTags: [],
event: createDecryptedEvent(),
}
const result = addToListPrivately(list, ["p", pubkey])
const plaintext = JSON.parse(result.updates.content || "[]")
expect(plaintext).toHaveLength(1)
})
it("should deduplicate private tags", () => {
const list: List = {
kind: MUTES,
publicTags: [],
privateTags: [["p", pubkey]],
event: createDecryptedEvent(),
}
const result = addToListPrivately(list, ["p", pubkey])
const plaintext = JSON.parse(result.updates.content || "[]")
expect(plaintext).toHaveLength(1)
})
})
describe("tag validation", () => {
it("should validate pubkey tags", () => {
const event = createDecryptedEvent({
tags: [
["p", pubkey],
["p", "invalid"],
],
})
const list = readList(event)
expect(list.publicTags).toHaveLength(1)
})
it("should validate event tags", () => {
const event = createDecryptedEvent({
tags: [
["e", validEventId],
["e", "invalid"],
],
})
const list = readList(event)
expect(list.publicTags).toHaveLength(1)
})
it("should validate address tags", () => {
const event = createDecryptedEvent({
tags: [
["a", address],
["a", "invalid"],
],
})
const list = readList(event)
expect(list.publicTags).toHaveLength(1)
})
it("should validate topic tags", () => {
const event = createDecryptedEvent({
tags: [
["t", "valid-topic"],
["t", ""],
],
})
const list = readList(event)
expect(list.publicTags).toHaveLength(1)
})
it("should validate relay tags", () => {
const event = createDecryptedEvent({
tags: [
["r", "wss://relay.example.com"],
["r", "invalid"],
["relay", "wss://relay.example.com"],
["relay", "invalid"],
],
})
const list = readList(event)
expect(list.publicTags).toHaveLength(2)
})
it("should accept unknown tag types", () => {
const event = createDecryptedEvent({
tags: [["unknown", "value"]],
})
const list = readList(event)
expect(list.publicTags).toHaveLength(1)
})
})
})
-232
View File
@@ -1,232 +0,0 @@
import {now} from "@welshman/lib"
import {describe, it, vi, expect, beforeEach} from "vitest"
import {
makeProfile,
readProfile,
createProfile,
editProfile,
displayPubkey,
displayProfile,
profileHasName,
isPublishedProfile,
} from "../src/Profile"
import {PROFILE} from "../src/Kinds"
import type {TrustedEvent} from "../src/Events"
import type {Profile, PublishedProfile} from "../src/Profile"
describe("Profile", () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Realistic Nostr data
const pubkey = "ee".repeat(32)
const id = "ff".repeat(32)
const sig = "00".repeat(64)
const currentTime = now()
const createEvent = (overrides = {}): TrustedEvent => ({
id: id,
pubkey: pubkey,
created_at: currentTime,
kind: PROFILE,
tags: [],
content: "",
sig: sig,
...overrides,
})
describe("makeProfile", () => {
it("should create empty profile", () => {
const profile = makeProfile()
expect(profile).toEqual({})
})
it("should handle lud06 lightning address", () => {
const profile = makeProfile({
lud06:
"lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserxfq5fns",
})
expect(profile.lnurl).toBeDefined()
})
it("should handle lud16 lightning address", () => {
const profile = makeProfile({
lud16: "user@domain.com",
})
expect(profile.lnurl).toBeDefined()
})
it("should preserve other profile fields", () => {
const profile = makeProfile({
name: "Test User",
about: "Test Bio",
picture: "https://example.com/pic.jpg",
})
expect(profile.name).toBe("Test User")
expect(profile.about).toBe("Test Bio")
expect(profile.picture).toBe("https://example.com/pic.jpg")
})
})
describe("readProfile", () => {
it("should parse valid profile content", () => {
const event = createEvent({
content: JSON.stringify({
name: "Test User",
about: "Test Bio",
picture: "https://example.com/pic.jpg",
lud16: "user@domain.com",
}),
})
const profile = readProfile(event)
expect(profile.name).toBe("Test User")
expect(profile.about).toBe("Test Bio")
expect(profile.picture).toBe("https://example.com/pic.jpg")
expect(profile.lnurl).toBeDefined()
expect(profile.event).toBe(event)
})
it("should handle invalid JSON content", () => {
const event = createEvent({
content: "invalid json",
})
const profile = readProfile(event)
expect(profile.event).toBe(event)
expect(Object.keys(profile)).not.toContain("name")
})
it("should handle empty content", () => {
const event = createEvent({
content: "",
})
const profile = readProfile(event)
expect(profile.event).toBe(event)
expect(Object.keys(profile)).not.toContain("name")
})
})
describe("createProfile", () => {
it("should create profile event template", () => {
const profile: Profile = {
name: "Test User",
about: "Test Bio",
picture: "https://example.com/pic.jpg",
lud16: "user@domain.com",
}
const result = createProfile(profile)
expect(result.kind).toBe(PROFILE)
expect(JSON.parse(result.content)).toMatchObject({
name: "Test User",
about: "Test Bio",
picture: "https://example.com/pic.jpg",
lud16: "user@domain.com",
})
})
it("should exclude event field from content", () => {
const profile: Profile = {
name: "Test User",
event: createEvent(),
}
const result = createProfile(profile)
const content = JSON.parse(result.content)
expect(content).not.toHaveProperty("event")
expect(content).toHaveProperty("name")
})
})
describe("editProfile", () => {
it("should create edit event template with existing tags", () => {
const profile: PublishedProfile = {
name: "Test User",
event: createEvent({
tags: [["p", pubkey]],
}),
}
const result = editProfile(profile)
expect(result.kind).toBe(PROFILE)
expect(result.tags).toEqual([["p", pubkey]])
expect(JSON.parse(result.content)).toMatchObject({
name: "Test User",
})
})
})
describe("displayPubkey", () => {
it("should format pubkey correctly", () => {
const display = displayPubkey(pubkey)
expect(display.length).toBe(14) // 8 + 1 + 5 characters
})
})
describe("displayProfile", () => {
it("should display name if available", () => {
const profile: Profile = {name: "Test User"}
expect(displayProfile(profile)).toBe("Test User")
})
it("should display display_name if name not available", () => {
const profile: Profile = {display_name: "Test Display"}
expect(displayProfile(profile)).toBe("Test Display")
})
it("should display pubkey if no names available", () => {
const profile: Profile = {event: createEvent()}
expect(displayProfile(profile)).toMatch(/^npub1/)
})
it("should display fallback if no profile", () => {
expect(displayProfile(undefined, "Fallback")).toBe("Fallback")
})
it("should truncate long names", () => {
const longName = "a".repeat(100) + " " + "b".repeat(100)
const profile: Profile = {name: longName}
// ellipsize split at space and adds ellipsis to the end of the first part
expect(displayProfile(profile).length).toBeLessThanOrEqual(103)
})
})
describe("profileHasName", () => {
it("should return true if profile has name", () => {
expect(profileHasName({name: "Test"})).toBe(true)
})
it("should return true if profile has display_name", () => {
expect(profileHasName({display_name: "Test"})).toBe(true)
})
it("should return false if profile has no names", () => {
expect(profileHasName({})).toBe(false)
})
it("should return false if profile is undefined", () => {
expect(profileHasName(undefined)).toBe(false)
})
})
describe("isPublishedProfile", () => {
it("should return true for published profile", () => {
const profile: PublishedProfile = {
name: "Test",
event: createEvent(),
}
expect(isPublishedProfile(profile)).toBe(true)
})
it("should return false for unpublished profile", () => {
const profile: Profile = {
name: "Test",
}
expect(isPublishedProfile(profile)).toBe(false)
})
})
})
-68
View File
@@ -1,68 +0,0 @@
import type {EventContent, TrustedEvent, EventTemplate} from "./Events.js"
export type Encrypt = (x: string) => Promise<string>
export type EncryptableUpdates = Partial<EventContent>
export type DecryptedEvent = TrustedEvent & {
plaintext: EncryptableUpdates
}
export const asDecryptedEvent = (event: TrustedEvent, plaintext: EncryptableUpdates = {}) =>
({...event, plaintext}) as DecryptedEvent
/**
* Represents an encryptable event with optional updates.
*/
export class Encryptable<T extends EventTemplate> {
/**
* Creates an instance of Encryptable.
* @param event - An EventTemplate with optional tags and content.
* @param updates - Plaintext updates to be applied to the event content.
* @example
* Here's an example which enables updating a private mute list:
* ```
* const event = {kind: 10000, content: "", tags: []} // An event, only kind is required
* const encryptable = new Encryptable(event, {content: JSON.stringify([["e", "bad word"]])})
* const eventTemplate = await encryptable.reconcile(myEncryptFunction)
* ```
*/
constructor(
readonly event: Partial<T>,
readonly updates: EncryptableUpdates,
) {}
/**
* Encrypts plaintext updates and merges them into the event template.
* @param encrypt - The encryption function to be used.
* @returns A promise that resolves to the reconciled and encrypted event.
*/
async reconcile(encrypt: Encrypt) {
const encryptContent = () => {
if (!this.updates.content) return undefined
return encrypt(this.updates.content)
}
const encryptTags = () => {
if (!this.updates.tags) return undefined
return Promise.all(
this.updates.tags.map(async tag => {
tag[1] = await encrypt(tag[1])
return tag
}),
)
}
const [content, tags] = await Promise.all([encryptContent(), encryptTags()])
// Updates are optional. If not provided, fall back to the event's content and tags.
return {
...this.event,
tags: tags || this.event.tags || [],
content: content || this.event.content || "",
} as T
}
}
-52
View File
@@ -1,52 +0,0 @@
import {fromPairs, last, first, parseJson} from "@welshman/lib"
import {getAddress} from "./Address.js"
import {getAddressTags, getKindTagValues} from "./Tags.js"
import type {TrustedEvent} from "./Events.js"
export type Handler = {
kind: number
name: string
about: string
image: string
identifier: string
event: TrustedEvent
website?: string
lud16?: string
nip05?: string
}
export const readHandlers = (event: TrustedEvent) => {
const {d: identifier} = fromPairs(event.tags)
const meta = parseJson(event.content)
const normalizedMeta = {
name: meta?.name || meta?.display_name || "",
image: meta?.image || meta?.picture || "",
about: meta?.about || "",
website: meta?.website || "",
lud16: meta?.lud16 || "",
nip05: meta?.nip05 || "",
}
// If our meta is missing important stuff, don't bother showing it
if (!normalizedMeta.name || !normalizedMeta.image) {
return []
}
return getKindTagValues(event.tags).map(kind => ({
...normalizedMeta,
kind,
identifier,
event,
})) as Handler[]
}
export const getHandlerKey = (handler: Handler) => `${handler.kind}:${getAddress(handler.event)}`
export const displayHandler = (handler?: Handler, fallback = "") => handler?.name || fallback
export const getHandlerAddress = (event: TrustedEvent) => {
const tags = getAddressTags(event.tags)
const tag = tags.find(t => last(t) === "web") || first(tags)
return tag?.[1]
}
-119
View File
@@ -1,119 +0,0 @@
import {parseJson, nth, uniqBy, nthEq} from "@welshman/lib"
import {Address} from "./Address.js"
import {uniqTags, getRelayTags} from "./Tags.js"
import {isRelayUrl, RelayMode, normalizeRelayUrl} from "./Relay.js"
import {Encryptable, DecryptedEvent} from "./Encryptable.js"
import type {EncryptableUpdates} from "./Encryptable.js"
export type ListParams = {
kind: number
}
export type List = ListParams & {
publicTags: string[][]
privateTags: string[][]
event?: DecryptedEvent
}
export type PublishedList = Omit<List, "event"> & {
event: DecryptedEvent
}
export const makeList = (list: ListParams & Partial<List>): List => ({
publicTags: [],
privateTags: [],
...list,
})
const isValidTag = (tag: string[]) => {
if (tag[0] === "p") return tag[1]?.length === 64
if (tag[0] === "e") return tag[1]?.length === 64
if (tag[0] === "a") return Address.isAddress(tag[1] || "")
if (tag[0] === "t") return tag[1]?.length > 0
if (tag[0] === "r") return isRelayUrl(tag[1])
if (tag[0] === "relay") return isRelayUrl(tag[1])
return true
}
export const readList = (event: DecryptedEvent): PublishedList => {
const getTags = (tags: string[][]) => (Array.isArray(tags) ? tags.filter(isValidTag) : [])
const privateTags = getTags(parseJson(event.plaintext?.content) || [])
const publicTags = getTags(event.tags)
return {event, kind: event.kind, publicTags, privateTags}
}
export const getListTags = (list: List | undefined) => [
...(list?.publicTags || []),
...(list?.privateTags || []),
]
export const removeFromListByPredicate = (list: List, pred: (t: string[]) => boolean) => {
const plaintext: EncryptableUpdates = {}
const template = {
kind: list.kind,
content: list.event?.content || "",
tags: list.publicTags.filter(t => !pred(t)),
}
// Avoid redundant encrypt calls if possible
if (list.privateTags.some(t => pred(t))) {
plaintext.content = JSON.stringify(list.privateTags.filter(t => !pred(t)))
}
return new Encryptable(template, plaintext)
}
export const removeFromList = (list: List, value: string) =>
removeFromListByPredicate(list, nthEq(1, value))
export const addToListPublicly = (list: List, ...tags: string[][]) => {
const template = {
kind: list.kind,
content: list.event?.content || "",
tags: uniqTags([...list.publicTags, ...tags]),
}
return new Encryptable(template, {})
}
export const addToListPrivately = (list: List, ...tags: string[][]) => {
const template = {
kind: list.kind,
tags: list.publicTags,
}
return new Encryptable(template, {
content: JSON.stringify(uniqTags([...list.privateTags, ...tags])),
})
}
export const updateList = (
list: List,
{publicTags, privateTags}: {publicTags?: string[][]; privateTags?: string[][]},
) => {
const template = {
kind: list.kind,
content: list.event?.content || "",
tags: publicTags || list.publicTags,
}
const updates: EncryptableUpdates = {}
if (privateTags) {
updates.content = JSON.stringify(privateTags)
}
return new Encryptable(template, updates)
}
export const getRelaysFromList = (list?: List, mode?: RelayMode): string[] => {
let tags = getRelayTags(getListTags(list))
if (mode) {
tags = tags.filter((t: string[]) => !t[2] || t[2] === mode)
}
return uniqBy(normalizeRelayUrl, tags.map(nth(1)))
}
-81
View File
@@ -1,81 +0,0 @@
import {npubEncode} from "nostr-tools/nip19"
import {ellipsize, parseJson} from "@welshman/lib"
import {TrustedEvent, EventTemplate} from "./Events.js"
import {getLnUrl} from "./Zaps.js"
import {PROFILE} from "./Kinds.js"
export type Profile = {
name?: string
nip05?: string
lud06?: string
lud16?: string
lnurl?: string
about?: string
banner?: string
picture?: string
website?: string
display_name?: string
event?: TrustedEvent
}
export type PublishedProfile = Omit<Profile, "event"> & {
event: TrustedEvent
}
export const isPublishedProfile = (profile: Profile): profile is PublishedProfile =>
Boolean(profile.event)
export const makeProfile = (profile: Partial<Profile> = {}): Profile => {
if (typeof profile.lud06 === "string") {
const lnurl = getLnUrl(profile.lud06)
if (lnurl) {
profile = {...profile, lnurl}
}
}
if (typeof profile.lud16 === "string") {
const lnurl = getLnUrl(profile.lud16)
if (lnurl) {
profile = {...profile, lnurl}
}
}
return profile
}
export const readProfile = (event: TrustedEvent): PublishedProfile => ({
...makeProfile(parseJson(event.content) || {}),
event,
})
export const createProfile = ({event, ...profile}: Profile): EventTemplate => ({
kind: PROFILE,
content: JSON.stringify(profile),
tags: [],
})
export const editProfile = ({event, ...profile}: PublishedProfile): EventTemplate => ({
kind: PROFILE,
content: JSON.stringify(profile),
tags: event.tags,
})
export const displayPubkey = (pubkey: string) => {
const d = npubEncode(pubkey)
return d.slice(0, 8) + "…" + d.slice(-5)
}
export const displayProfile = (profile?: Profile, fallback = "") => {
const {display_name, name, event} = profile || {}
if (name) return ellipsize(name, 60).trim()
if (display_name) return ellipsize(display_name, 60).trim()
if (event) return displayPubkey(event.pubkey).trim()
return fallback.trim()
}
export const profileHasName = (profile?: Profile) => Boolean(profile?.name || profile?.display_name)
-147
View File
@@ -1,147 +0,0 @@
import {spec} from "@welshman/lib"
import {
ROOM_META,
ROOM_DELETE,
ROOM_CREATE,
ROOM_EDIT_META,
ROOM_JOIN,
ROOM_LEAVE,
ROOM_ADD_MEMBER,
ROOM_REMOVE_MEMBER,
} from "./Kinds.js"
import {makeEvent, TrustedEvent, getIdentifier} from "./Events.js"
import {getTag, getTagValue} from "./Tags.js"
export type RoomMeta = {
h: string
name?: string
about?: string
picture?: string
pictureMeta?: string[]
isClosed?: boolean
isHidden?: boolean
isPrivate?: boolean
isRestricted?: boolean
livekit?: boolean
event?: TrustedEvent
}
export type PublishedRoomMeta = Omit<RoomMeta, "event"> & {
event: TrustedEvent
}
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(
",",
)
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 makeRoomMeta = (room: Partial<RoomMeta> = {}): RoomMeta => {
return {
h: room.h ?? generateH(),
...room,
}
}
export const readRoomMeta = (event: TrustedEvent): PublishedRoomMeta => {
if (event.kind !== ROOM_META) {
throw new Error("Invalid group meta event")
}
const h = getIdentifier(event)
if (!h) {
throw new Error("Group meta event had no d tag")
}
return {
h,
event,
name: getTagValue("name", event.tags),
about: getTagValue("about", event.tags),
picture: getTagValue("picture", event.tags),
pictureMeta: getTag("picture", event.tags)?.slice(2),
isClosed: event.tags.some(spec(["closed"])),
isHidden: event.tags.some(spec(["hidden"])),
isPrivate: event.tags.some(spec(["private"])),
isRestricted: event.tags.some(spec(["restricted"])),
livekit: event.tags.some(spec(["livekit"])),
}
}
export const makeRoomCreateEvent = (room: RoomMeta) =>
makeEvent(ROOM_CREATE, {tags: [["h", room.h]]})
export const makeRoomDeleteEvent = (room: RoomMeta) =>
makeEvent(ROOM_DELETE, {tags: [["h", room.h]]})
export const makeRoomEditEvent = (room: RoomMeta) => {
const tags = [["h", room.h]]
if (room.name) tags.push(["name", room.name])
if (room.about) tags.push(["about", room.about])
if (room.picture) {
const tag = ["picture", room.picture]
if (room.pictureMeta) {
tag.push(...room.pictureMeta)
}
tags.push(tag)
}
if (room.isClosed) tags.push(["closed"])
if (room.isHidden) tags.push(["hidden"])
if (room.isPrivate) tags.push(["private"])
if (room.isRestricted) tags.push(["restricted"])
if (room.livekit) tags.push(["livekit"])
if (room.event) {
for (const t of room.event.tags) {
if (tags.some(spec(t.slice(0, 1)))) continue
if (["closed", "hidden", "private", "restricted", "livekit"].includes(t[0])) continue
tags.push(t)
}
}
return makeEvent(ROOM_EDIT_META, {tags})
}
export const makeRoomJoinEvent = (room: RoomMeta) => makeEvent(ROOM_JOIN, {tags: [["h", room.h]]})
export const makeRoomLeaveEvent = (room: RoomMeta) => makeEvent(ROOM_LEAVE, {tags: [["h", room.h]]})
export const makeRoomAddMemberEvent = (room: RoomMeta, pubkey: string) =>
makeEvent(ROOM_ADD_MEMBER, {
tags: [
["h", room.h],
["p", pubkey],
],
})
export const makeRoomRemoveMemberEvent = (room: RoomMeta, pubkey: string) =>
makeEvent(ROOM_REMOVE_MEMBER, {
tags: [
["h", room.h],
["p", pubkey],
],
})
-5
View File
@@ -1,22 +1,17 @@
export * from "./Address.js"
export * from "./Blossom.js"
export * from "./Encryptable.js"
export * from "./Events.js"
export * from "./Filters.js"
export * from "./Handler.js"
export * from "./Handles.js"
export * from "./Keys.js"
export * from "./Kinds.js"
export * from "./Links.js"
export * from "./List.js"
export * from "./Nip42.js"
export * from "./Nip86.js"
export * from "./Nip98.js"
export * from "./Pow.js"
export * from "./Profile.js"
export * from "./Pubkey.js"
export * from "./Relay.js"
export * from "./Room.js"
export * from "./Tags.js"
export * from "./Wallet.js"
export * from "./Zaps.js"