import {nwc} from "@getalby/sdk" import * as nip19 from "nostr-tools/nip19" import {get, derived} from "svelte/store" import { first, sha256, append, remove, poll, uniq, equals, parseJson, last, simpleCache, normalizeUrl, nthNe, } from "@welshman/lib" import {Nip01Signer} from "@welshman/signer" import type {UploadTask} from "@welshman/editor" import type {TrustedEvent, EventContent, Profile, PublishedRoomMeta} from "@welshman/util" import {PollResponse} from "nostr-tools/kinds" import { DELETE, REPORT, PROFILE, MESSAGING_RELAYS, RELAYS, FOLLOWS, REACTION, RELAY_JOIN, RELAY_LEAVE, ROOMS, COMMENT, APP_DATA, isSignedEvent, makeEvent, normalizeRelayUrl, makeList, addToListPublicly, removeFromListByPredicate, updateList, getTag, getListTags, getRelayTagValues, toNostrURI, RelayMode, getTagValues, uploadBlob, canUploadBlob, encryptFile, makeBlossomAuthEvent, isPublishedProfile, editProfile, createProfile, uniqTags, ManagementMethod, } from "@welshman/util" import {Pool, AuthStatus, SocketStatus} from "@welshman/net" import {Router} from "@welshman/router" import { pubkey, sign, signer, session, repository, publishThunk, tagEvent, tagPubkey, tagEventForReaction, nip44EncryptToSelf, tagEventForComment, tagEventForQuote, waitForThunkError, getPubkeyRelays, userBlossomServerList, getThunkError, addRoomMember, manageRelay, getRelay, } from "@welshman/app" import {compressFile} from "@lib/html" import type {SettingsValues, SpaceNotificationSettings} from "@app/core/state" import { SETTINGS, PROTECTED, INDEXER_RELAYS, DEFAULT_BLOSSOM_SERVERS, userSpaceUrls, userSettingsValues, getSetting, getSettings, userGroupList, shouldIgnoreError, stripPrefix, relaysMostlyRestricted, deriveSocket, deriveSpaceMembers, } from "@app/core/state" // Utils export const getPubkeyHints = (pubkey: string) => { const relays = getPubkeyRelays(pubkey, RelayMode.Write) const hints = relays.length ? relays : INDEXER_RELAYS return hints } export const prependParent = ( parent: TrustedEvent | undefined, {content, tags}: EventContent, url?: string, ) => { if (parent) { const relays = url ? [url] : Router.get().Event(parent).limit(3).getUrls() const nevent = nip19.neventEncode({...parent, relays}) tags = [...tags, tagEventForQuote(parent, url), tagPubkey(parent.pubkey)] content = toNostrURI(nevent) + "\n\n" + content } return {content, tags} } // Synchronization export const broadcastUserData = async (relays: string[]) => { const authors = [pubkey.get()!] const kinds = [RELAYS, MESSAGING_RELAYS, FOLLOWS, PROFILE] const events = repository.query([{kinds, authors}]) for (const event of events) { if (isSignedEvent(event)) { await publishThunk({event, relays}).complete } } } // List updates export const addSpaceMembership = async (url: string) => { const list = get(userGroupList) || makeList({kind: ROOMS}) const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf) const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) return publishThunk({event, relays}) } export const removeSpaceMembership = async (url: string) => { const list = get(userGroupList) || makeList({kind: ROOMS}) const pred = (t: string[]) => normalizeRelayUrl(t[t[0] === "r" ? 1 : 2]) === url const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf) const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) return publishThunk({event, relays}) } export const setSpaceMembershipOrder = async (urls: string[]) => { const list = get(userGroupList) || makeList({kind: ROOMS}) const orderedUrls = uniq(urls.map(normalizeRelayUrl)) const relayTags = list.publicTags.filter(t => t[0] === "r") const otherPublicTags = list.publicTags.filter(t => t[0] !== "r") const relayTagByUrl = new Map(relayTags.map(t => [normalizeRelayUrl(t[1]), t])) const orderedRelayTags = orderedUrls.map(url => relayTagByUrl.get(url) || ["r", url]) const publicTags = [...orderedRelayTags, ...otherPublicTags] const event = await updateList(list, {publicTags}).reconcile(nip44EncryptToSelf) const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) return publishThunk({event, relays}) } export const addRoomMembership = async (url: string, h: string) => { const list = get(userGroupList) || makeList({kind: ROOMS}) const newTags = [ ["r", url], ["group", h, url], ] const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf) const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) return publishThunk({event, relays}) } export const removeRoomMembership = async (url: string, h: string) => { const list = get(userGroupList) || makeList({kind: ROOMS}) const pred = (t: string[]) => equals(["group", h, url], t.slice(0, 3)) const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf) const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) return publishThunk({event, relays}) } // Relay access export const canEnforceNip70 = async (url: string) => { const socket = Pool.get().get(url) await socket.auth.attemptAuth(sign) return socket.auth.status !== AuthStatus.None } export const attemptRelayAccess = async (url: string, claim = "") => { const socket = Pool.get().get(url) socket.attemptToOpen() await poll({ signal: AbortSignal.timeout(3000), condition: () => socket.status === SocketStatus.Open, }) if (socket.status !== SocketStatus.Open) { return `Failed to connect` } await socket.auth.attemptAuth(sign) // Only raise an error if it's not a timeout. // If it is, odds are the problem is with our signer, not the relay if (![AuthStatus.None, AuthStatus.Ok].includes(socket.auth.status)) { if (socket.auth.details) { return `Failed to authenticate (${socket.auth.details})` } else { return `Failed to authenticate (${last(socket.auth.status.split(":"))})` } } const error = await waitForThunkError(publishJoinRequest({url, claim})) if (shouldIgnoreError(error)) return if (!claim && error.includes("invite code size")) return if (error.includes("invite code")) return "join request rejected" return stripPrefix(error) } export const deriveRelayAuthError = (url: string, claim = "") => { // Kick off the auth process Pool.get().get(url).auth.attemptAuth(sign) // Attempt to join the relay const thunk = publishJoinRequest({url, claim}) return derived( [thunk, relaysMostlyRestricted, deriveSocket(url)], ([$thunk, $relaysMostlyRestricted, $socket]) => { if ($socket.auth.status === AuthStatus.Forbidden && $socket.auth.details) { return stripPrefix($socket.auth.details) } if ($relaysMostlyRestricted[url]) { return stripPrefix($relaysMostlyRestricted[url]) } const error = getThunkError($thunk) if (error) { const isEmptyInvite = !claim && error.includes("invite code") if (!shouldIgnoreError(error) && !isEmptyInvite) { return stripPrefix(error) || "join request rejected" } } }, ) } // Deletions export type DeleteParams = { protect: boolean event: TrustedEvent tags?: string[][] } export const makeDelete = ({protect, event, tags = []}: DeleteParams) => { const thisTags = [["k", String(event.kind)], ...tagEvent(event), ...tags] const groupTag = getTag("h", event.tags) if (groupTag) { thisTags.push(groupTag) } if (protect) { thisTags.push(PROTECTED) } return makeEvent(DELETE, {tags: thisTags}) } export const publishDelete = ({relays, ...params}: DeleteParams & {relays: string[]}) => publishThunk({event: makeDelete(params), relays}) // Reports export type ReportParams = { event: TrustedEvent content: string reason: string } export const makeReport = ({event, reason, content}: ReportParams) => { const tags = [ ["p", event.pubkey], ["e", event.id, reason], ] return makeEvent(REPORT, {content, tags}) } export const publishReport = ({ relays, event, reason, content, }: ReportParams & {relays: string[]}) => publishThunk({event: makeReport({event, reason, content}), relays}) // Reactions export type ReactionParams = { protect: boolean event: TrustedEvent content: string url?: string tags?: string[][] } export const makeReaction = ({ url, protect, content, event, tags: paramTags = [], }: ReactionParams) => { const tags = [...paramTags, ...tagEventForReaction(event, url)] const groupTag = getTag("h", event.tags) if (groupTag) { tags.push(groupTag) } if (protect) { tags.push(PROTECTED) } return makeEvent(REACTION, {content, tags}) } export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) => { publishThunk({event: makeReaction({url: relays[0], ...params}), relays}) } // Polls export type PollResponseParams = { event: TrustedEvent selectedIds: string[] } export const makePollResponse = ({event, selectedIds}: PollResponseParams) => makeEvent(PollResponse, { content: "", tags: [["e", event.id], ...selectedIds.map(selectedId => ["response", selectedId])], }) export const publishPollResponse = ({relays, ...params}: PollResponseParams & {relays: string[]}) => publishThunk({event: makePollResponse(params), relays}) // Comments export type CommentParams = { event: TrustedEvent content: string tags?: string[][] url?: string } export const makeComment = ({url, event, content, tags = []}: CommentParams) => makeEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event, url)]}) export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) => publishThunk({event: makeComment({url: relays[0], ...params}), relays}) // Settings export const makeSettings = async (params: Partial) => { const json = JSON.stringify({...get(userSettingsValues), ...params}) const content = await signer.get().nip44.encrypt(pubkey.get()!, json) const tags = [["d", SETTINGS]] return makeEvent(APP_DATA, {content, tags}) } export const publishSettings = async (params: Partial) => publishThunk({event: await makeSettings(params), relays: Router.get().FromUser().getUrls()}) export const addTrustedRelay = async (url: string) => publishSettings({trusted_relays: append(url, getSetting("trusted_relays"))}) export const removeTrustedRelay = async (url: string) => publishSettings({trusted_relays: remove(url, getSetting("trusted_relays"))}) // Space and room notification settings export const setSpaceNotifications = async (url: string, notify: boolean) => { const {alerts} = getSettings() const existing = alerts.find((s: SpaceNotificationSettings) => s.url === url) let updated: typeof alerts if (existing) { // Clear exceptions when changing the space notification setting updated = alerts.map((s: SpaceNotificationSettings) => s.url === url ? {...s, notify, exceptions: []} : s, ) } else { updated = [...alerts, {url, notify, exceptions: []}] } return publishSettings({alerts: updated}) } export const toggleRoomNotifications = async (url: string, h: string) => { const {alerts} = getSettings() const existing = alerts.find((s: SpaceNotificationSettings) => s.url === url) let updated: typeof alerts if (!existing) { updated = [...alerts, {url, notify: true, exceptions: [h]}] } else { const exceptions = existing.exceptions.includes(h) ? remove(h, existing.exceptions) : append(h, existing.exceptions) updated = alerts.map((s: SpaceNotificationSettings) => (s.url === url ? {...s, exceptions} : s)) } return publishSettings({alerts: updated}) } // Join request export type JoinRequestParams = { url: string claim: string } export const makeJoinRequest = (params: JoinRequestParams) => makeEvent(RELAY_JOIN, {tags: [["claim", params.claim]]}) export const publishJoinRequest = (params: JoinRequestParams) => publishThunk({event: makeJoinRequest(params), relays: [params.url]}) // Leave request export type LeaveRequestParams = { url: string } export const publishLeaveRequest = (params: LeaveRequestParams) => publishThunk({event: makeEvent(RELAY_LEAVE), relays: [params.url]}) // Lightning export const getWebLn = () => (window as any).webln export const getNwcClient = () => { const $session = session.get() if (!$session?.wallet || $session.wallet.type !== "nwc") { throw new Error("No NWC wallet is connected") } const {info} = $session.wallet if (info.nostrWalletConnectUrl) { return new nwc.NWCClient({nostrWalletConnectUrl: info.nostrWalletConnectUrl}) } return new nwc.NWCClient(info) } export const payInvoice = async (invoice: string, msats?: number) => { const $session = session.get() if (!$session?.wallet) { throw new Error("No wallet is connected") } if ($session.wallet.type === "nwc") { const params: {invoice: string; amount?: number} = {invoice} if (msats) params.amount = msats return getNwcClient().payInvoice(params) } else if ($session.wallet.type === "webln") { if (msats) throw new Error("Unable to pay zero invoices with webln") return getWebLn() .enable() .then(() => getWebLn().sendPayment(invoice)) } } export type CreateInvoiceParams = { sats: number description?: string } export const createInvoice = async ({ sats, description = "Receive via lightning", }: CreateInvoiceParams) => { const $session = session.get() if (!$session?.wallet) { throw new Error("No wallet is connected") } const satAmount = Math.floor(sats) if (!Number.isFinite(satAmount) || satAmount <= 0) { throw new Error("Invalid satoshi amount") } if ($session.wallet.type === "nwc") { const createdInvoice = await getNwcClient().makeInvoice({ amount: satAmount * 1000, description, }) if (!createdInvoice.invoice) { throw new Error("NWC wallet failed to return an invoice") } return createdInvoice.invoice } if ($session.wallet.type === "webln") { const webLn = getWebLn() if (!webLn) { throw new Error("WebLN not available") } await webLn.enable() const response = await webLn.makeInvoice({ amount: satAmount, defaultMemo: description, }) const paymentRequest = typeof response === "string" ? response : response?.paymentRequest || response?.pr || "" if (!paymentRequest) { throw new Error("Invalid payment request returned from WebLN") } return paymentRequest } throw new Error("Unsupported wallet type") } // File upload export const normalizeBlossomUrl = (url: string) => normalizeUrl(url.replace(/^ws/, "http")) export const fetchHasBlossomSupport = async (url: string) => { const relay = getRelay(url) if (relay?.supported_nips?.map(String).includes("BUD-02")) { return true } const server = normalizeBlossomUrl(url) const $signer = signer.get() || Nip01Signer.ephemeral() const headers: Record = { "X-Content-Type": "text/plain", "X-Content-Length": "1", "X-SHA-256": "73cb3858a687a8494ca3323053016282f3dad39d42cf62ca4e79dda2aac7d9ac", } try { const authEvent = await $signer.sign(makeBlossomAuthEvent({action: "upload", server})) const res = await canUploadBlob(server, {authEvent, headers}) return res.status === 200 } catch (e) { if (!String(e).match(/Failed to fetch|NetworkError/)) { console.error(e) } } return false } export const hasBlossomSupport = simpleCache(([url]: [string]) => fetchHasBlossomSupport(url)) export type GetBlossomServerOptions = { url?: string } export const getBlossomServer = async (options: GetBlossomServerOptions = {}) => { if (options.url) { if (await hasBlossomSupport(options.url)) { return normalizeBlossomUrl(options.url) } } const userUrls = getTagValues("server", getListTags(get(userBlossomServerList))) for (const url of userUrls) { return normalizeBlossomUrl(url) } return first(DEFAULT_BLOSSOM_SERVERS)! } export type UploadFileOptions = { url?: string encrypt?: boolean maxWidth?: number maxHeight?: number } export type UploadFileResult = { error?: string result?: UploadTask } export const uploadFile = async (file: File, options: UploadFileOptions = {}) => { try { const {name, type} = file if (!type.match("image/(webp|gif|svg)")) { file = await compressFile(file, options) } const tags: string[][] = [] if (options.encrypt) { const {ciphertext, key, nonce, algorithm} = await encryptFile(file) tags.push( ["decryption-key", key], ["decryption-nonce", nonce], ["encryption-algorithm", algorithm], ) file = new File([new Uint8Array(ciphertext)], name, { type: "application/octet-stream", }) } const ext = "." + type.split("/")[1] const server = await getBlossomServer(options) const hashes = [await sha256(await file.arrayBuffer())] const $signer = signer.get() || Nip01Signer.ephemeral() const authTemplate = makeBlossomAuthEvent({action: "upload", server, hashes}) const authEvent = await $signer.sign(authTemplate) const res = await uploadBlob(server, file, {authEvent}) const text = await res.text() let task try { task = parseJson(text) } catch (e) { return {error: text} } if (!task?.uploaded) { return {error: text || `Failed to upload file (HTTP ${res.status})`} } // Always append correct file extension if we encrypted the file, or if it's missing let url = task.url if (options.encrypt) { url = url.replace(/\.\w+$/, "") + ext } else if (new URL(url).pathname.split(".").length === 1) { url += ext } const result = {...task, tags, url} return {result} } catch (e: any) { console.error("Error caught when uploading file:", e) return {error: e.toString()} } } // Update Profile export const initProfile = (profile: Profile) => { const template = createProfile(profile) // Start out protected by default template.tags.push(PROTECTED) const event = makeEvent(PROFILE, template) // Don't publish anywhere yet, wait until they join a space return publishThunk({event, relays: []}) } export const updateProfile = ({ profile, shouldBroadcast = !getTag(PROTECTED, profile.event?.tags || []), }: { profile: Profile shouldBroadcast?: boolean }) => { const router = Router.get() const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile) const scenarios = [router.FromRelays(get(userSpaceUrls))] if (shouldBroadcast) { scenarios.push(router.FromUser(), router.Index()) template.tags = template.tags.filter(nthNe(0, "-")) } else { template.tags = uniqTags([...template.tags, PROTECTED]) } const event = makeEvent(template.kind, template) const relays = router.merge(scenarios).getUrls() return publishThunk({event, relays}) } // Admin actions export const addSpaceMembers = async ( url: string, pubkeys: string[], ): Promise => { const spaceMembers = get(deriveSpaceMembers(url)) const results = await Promise.all( pubkeys .filter(pubkey => !spaceMembers.includes(pubkey)) .map(pubkey => manageRelay(url, { method: ManagementMethod.AllowPubkey, params: [pubkey], }), ), ) for (const {error} of results) { if (error) { return error } } } export const addRoomMembers = async ( url: string, room: PublishedRoomMeta, pubkeys: string[], ): Promise => { const error = await addSpaceMembers(url, pubkeys) if (error) { return error } const errors = await Promise.all( pubkeys.map(pubkey => waitForThunkError(addRoomMember(url, room, pubkey))), ) for (const error of errors) { if (error) { return error } } }