flotilla/src/app/commands.ts

483 lines
12 KiB
TypeScript
Raw Normal View History

2025-07-04 13:22:19 +00:00
import {nwc} from "@getalby/sdk"
import * as nip19 from "nostr-tools/nip19"
2024-10-08 18:39:16 +00:00
import {get} from "svelte/store"
2025-06-19 17:22:17 +00:00
import {randomId, flatten, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib"
2025-04-28 16:48:09 +00:00
import type {Feed} from "@welshman/feeds"
2025-05-05 17:11:02 +00:00
import type {TrustedEvent, EventContent} from "@welshman/util"
2024-10-08 18:39:16 +00:00
import {
DELETE,
2025-01-15 01:23:27 +00:00
REPORT,
2024-10-08 18:39:16 +00:00
PROFILE,
INBOX_RELAYS,
RELAYS,
FOLLOWS,
REACTION,
2024-10-16 17:14:33 +00:00
AUTH_JOIN,
2025-06-16 17:12:24 +00:00
ROOMS,
2024-12-10 21:07:17 +00:00
COMMENT,
2025-06-19 17:22:17 +00:00
ALERT_EMAIL,
ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID,
2024-10-08 18:39:16 +00:00
isSignedEvent,
2025-06-10 20:35:57 +00:00
makeEvent,
2024-10-08 18:39:16 +00:00
displayProfile,
normalizeRelayUrl,
2024-10-08 22:30:18 +00:00
makeList,
addToListPublicly,
removeFromListByPredicate,
getTag,
2024-10-08 22:30:18 +00:00
getListTags,
getRelayTags,
getRelayTagValues,
toNostrURI,
2025-04-25 17:41:38 +00:00
getRelaysFromList,
RelayMode,
2024-10-08 18:39:16 +00:00
} from "@welshman/util"
2025-05-29 17:42:43 +00:00
import {Pool, AuthStatus, SocketStatus} from "@welshman/net"
import {Router} from "@welshman/router"
2024-08-16 22:17:30 +00:00
import {
2024-08-30 23:25:41 +00:00
pubkey,
2024-10-08 18:39:16 +00:00
signer,
session,
2024-08-30 23:25:41 +00:00
repository,
publishThunk,
profilesByPubkey,
relaySelectionsByPubkey,
2024-09-25 22:49:17 +00:00
tagEvent,
2025-01-17 23:01:05 +00:00
tagEventForReaction,
2024-10-08 22:30:18 +00:00
userRelaySelections,
2024-10-08 18:39:16 +00:00
userInboxRelaySelections,
2024-10-08 22:30:18 +00:00
nip44EncryptToSelf,
loadRelay,
2024-12-04 18:10:41 +00:00
clearStorage,
dropSession,
2025-01-17 23:01:05 +00:00
tagEventForComment,
tagEventForQuote,
2025-05-28 23:46:41 +00:00
getThunkError,
2024-08-30 23:25:41 +00:00
} from "@welshman/app"
2024-10-17 23:03:50 +00:00
import {
PROTECTED,
2024-10-17 23:03:50 +00:00
userMembership,
INDEXER_RELAYS,
2025-02-25 21:36:32 +00:00
NOTIFIER_PUBKEY,
NOTIFIER_RELAY,
2024-12-17 01:07:52 +00:00
userRoomsByUrl,
2024-10-17 23:03:50 +00:00
} from "@app/state"
2024-08-16 19:42:00 +00:00
2024-08-23 20:33:36 +00:00
// Utils
export const getPubkeyHints = (pubkey: string) => {
2024-08-30 23:25:41 +00:00
const selections = relaySelectionsByPubkey.get().get(pubkey)
2025-04-25 17:41:38 +00:00
const relays = selections ? getRelaysFromList(selections, RelayMode.Write) : []
2024-08-23 20:33:36 +00:00
const hints = relays.length ? relays : INDEXER_RELAYS
return hints
}
export const getPubkeyPetname = (pubkey: string) => {
2024-08-30 23:25:41 +00:00
const profile = profilesByPubkey.get().get(pubkey)
2024-08-23 20:33:36 +00:00
const display = displayProfile(profile)
return display
}
export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => {
if (parent) {
const nevent = nip19.neventEncode({
id: parent.id,
kind: parent.kind,
author: parent.pubkey,
2025-04-09 22:32:18 +00:00
relays: Router.get().Event(parent).limit(3).getUrls(),
})
tags = [...tags, tagEventForQuote(parent)]
content = toNostrURI(nevent) + "\n\n" + content
}
return {content, tags}
}
2024-12-04 18:10:41 +00:00
// Log out
export const logout = async () => {
const $pubkey = pubkey.get()
if ($pubkey) {
dropSession($pubkey)
}
await clearStorage()
2024-12-04 23:11:32 +00:00
2024-12-04 18:10:41 +00:00
localStorage.clear()
}
2024-10-03 19:21:03 +00:00
// Synchronization
export const broadcastUserData = async (relays: string[]) => {
const authors = [pubkey.get()!]
const kinds = [RELAYS, INBOX_RELAYS, FOLLOWS, PROFILE]
const events = repository.query([{kinds, authors}])
for (const event of events) {
if (isSignedEvent(event)) {
2024-10-17 18:56:13 +00:00
await publishThunk({event, relays}).result
2024-10-03 19:21:03 +00:00
}
}
}
// List updates
2024-08-23 20:33:36 +00:00
2024-10-08 22:30:18 +00:00
export const addSpaceMembership = async (url: string) => {
2025-06-16 17:12:24 +00:00
const list = get(userMembership) || makeList({kind: ROOMS})
2024-10-08 22:30:18 +00:00
const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf)
2025-04-09 22:32:18 +00:00
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
2024-08-14 23:25:36 +00:00
2024-11-15 22:03:54 +00:00
return publishThunk({event, relays})
2024-08-14 23:25:36 +00:00
}
2024-10-08 22:30:18 +00:00
export const removeSpaceMembership = async (url: string) => {
2025-06-16 17:12:24 +00:00
const list = get(userMembership) || makeList({kind: ROOMS})
2024-10-21 22:19:55 +00:00
const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url
2024-10-08 22:30:18 +00:00
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
2025-04-09 22:32:18 +00:00
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
2024-08-23 21:42:50 +00:00
2024-11-15 22:03:54 +00:00
return publishThunk({event, relays})
2024-10-08 22:30:18 +00:00
}
2024-08-23 21:42:50 +00:00
2025-05-28 23:46:41 +00:00
export const addRoomMembership = async (url: string, room: string) => {
2025-06-16 17:12:24 +00:00
const list = get(userMembership) || makeList({kind: ROOMS})
2025-05-29 17:42:43 +00:00
const newTags = [
["r", url],
["group", room, url],
]
const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf)
2025-04-09 22:32:18 +00:00
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
2024-09-25 22:49:17 +00:00
2024-11-15 22:03:54 +00:00
return publishThunk({event, relays})
2024-10-08 22:30:18 +00:00
}
2024-10-03 18:49:52 +00:00
2024-10-08 22:30:18 +00:00
export const removeRoomMembership = async (url: string, room: string) => {
2025-06-16 17:12:24 +00:00
const list = get(userMembership) || makeList({kind: ROOMS})
const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3))
2024-10-08 22:30:18 +00:00
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
2025-04-09 22:32:18 +00:00
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
2024-10-03 18:49:52 +00:00
2024-11-15 22:03:54 +00:00
return publishThunk({event, relays})
2024-10-08 22:30:18 +00:00
}
2024-10-03 18:49:52 +00:00
2024-10-08 22:30:18 +00:00
export const setRelayPolicy = (url: string, read: boolean, write: boolean) => {
const list = get(userRelaySelections) || makeList({kind: RELAYS})
2024-10-09 23:15:09 +00:00
const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url)
2024-10-03 19:21:03 +00:00
2024-10-08 22:30:18 +00:00
if (read && write) {
tags.push(["r", url])
} else if (read) {
tags.push(["r", url, "read"])
} else if (write) {
tags.push(["r", url, "write"])
}
2024-10-03 19:21:03 +00:00
2024-10-08 22:30:18 +00:00
return publishThunk({
2025-06-10 20:35:57 +00:00
event: makeEvent(list.kind, {tags}),
2024-11-15 22:03:54 +00:00
relays: [
url,
...INDEXER_RELAYS,
2025-04-09 22:32:18 +00:00
...Router.get().FromUser().getUrls(),
2024-12-17 01:07:52 +00:00
...userRoomsByUrl.get().keys(),
2024-11-15 22:03:54 +00:00
],
})
2024-10-08 22:30:18 +00:00
}
2024-10-03 19:21:03 +00:00
export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
2024-10-08 22:30:18 +00:00
const list = get(userInboxRelaySelections) || makeList({kind: INBOX_RELAYS})
2024-10-03 19:21:03 +00:00
// Only update inbox policies if they already exist or we're adding them
2025-04-25 17:41:38 +00:00
if (enabled || getRelaysFromList(list).includes(url)) {
2024-10-09 23:15:09 +00:00
const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url)
2024-10-03 19:21:03 +00:00
2024-10-08 22:30:18 +00:00
if (enabled) {
tags.push(["relay", url])
}
2024-10-03 19:21:03 +00:00
2024-10-08 22:30:18 +00:00
return publishThunk({
2025-06-10 20:35:57 +00:00
event: makeEvent(list.kind, {tags}),
2024-11-15 22:03:54 +00:00
relays: [
...INDEXER_RELAYS,
2025-04-09 22:32:18 +00:00
...Router.get().FromUser().getUrls(),
2024-12-17 01:07:52 +00:00
...userRoomsByUrl.get().keys(),
2024-11-15 22:03:54 +00:00
],
})
2024-10-03 19:21:03 +00:00
}
}
// Relay access
export const attemptAuth = async (url: string) => {
const socket = Pool.get().get(url)
await socket.auth.attemptAuth(e => signer.get()?.sign(e))
}
export const canEnforceNip70 = async (url: string) => {
const socket = Pool.get().get(url)
await socket.auth.attemptAuth(e => signer.get()?.sign(e))
return socket.auth.status !== AuthStatus.None
}
2025-06-25 00:23:07 +00:00
export const checkRelayAccess = async (url: string, claim = "") => {
const socket = Pool.get().get(url)
2024-10-14 22:29:30 +00:00
2025-06-25 00:23:07 +00:00
await attemptAuth(url)
2024-10-16 22:57:18 +00:00
const thunk = publishThunk({
2025-06-10 20:35:57 +00:00
event: makeEvent(AUTH_JOIN, {tags: [["claim", claim]]}),
relays: [url],
})
const error = await getThunkError(thunk)
if (error) {
2024-10-17 19:49:15 +00:00
const message =
socket.auth.details?.replace(/^\w+: /, "") ||
2025-07-30 22:54:10 +00:00
error.replace(/^\w+: /, "") ||
2024-10-17 19:49:15 +00:00
"join request rejected"
2024-12-10 17:44:04 +00:00
// If it's a strict NIP 29 relay don't worry about requesting access
// TODO: remove this if relay29 ever gets less strict
2025-05-12 16:01:13 +00:00
if (message === "missing group (`h`) tag") return
// Ignore messages about the relay ignoring ours
if (error?.startsWith("mute: ")) return
// Ignore rejected empty claims
2025-07-02 23:48:44 +00:00
if (!claim && error?.includes("invite code")) return
2025-05-12 16:01:13 +00:00
return message
}
}
export const checkRelayProfile = async (url: string) => {
const relay = await loadRelay(url)
if (!relay?.profile) {
return "Sorry, we weren't able to find that relay."
}
}
export const checkRelayConnection = async (url: string) => {
const socket = Pool.get().get(url)
2025-04-09 22:32:18 +00:00
socket.attemptToOpen()
2025-04-09 22:32:18 +00:00
await poll({
signal: AbortSignal.timeout(3000),
condition: () => socket.status === SocketStatus.Open,
})
if (socket.status !== SocketStatus.Open) {
2024-10-14 22:29:30 +00:00
return `Failed to connect`
2024-10-03 19:21:03 +00:00
}
}
2024-10-03 19:21:03 +00:00
export const checkRelayAuth = async (url: string, timeout = 3000) => {
const socket = Pool.get().get(url)
2024-10-14 22:29:30 +00:00
const okStatuses = [AuthStatus.None, AuthStatus.Ok]
2025-06-25 00:23:07 +00:00
await attemptAuth(url)
2024-11-14 00:14:05 +00:00
// Only raise an error if it's not a timeout.
// If it is, odds are the problem is with our signer, not the relay
2025-04-09 22:32:18 +00:00
if (!okStatuses.includes(socket.auth.status) && socket.auth.details) {
return `Failed to authenticate (${socket.auth.details})`
}
2024-10-03 19:21:03 +00:00
}
export const attemptRelayAccess = async (url: string, claim = "") => {
2024-11-19 04:31:17 +00:00
const checks = [
() => checkRelayConnection(url),
() => checkRelayAccess(url, claim),
() => checkRelayAuth(url),
]
for (const check of checks) {
2024-11-19 04:31:17 +00:00
const error = await check()
if (error) {
return error
}
}
}
2024-09-25 22:49:17 +00:00
// Actions
export type DeleteParams = {
protect: boolean
event: TrustedEvent
tags?: string[][]
}
export const makeDelete = ({protect, event, tags = []}: DeleteParams) => {
2025-06-27 21:36:09 +00:00
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)
}
2025-06-27 21:36:09 +00:00
return makeEvent(DELETE, {tags: thisTags})
}
export const publishDelete = ({relays, ...params}: DeleteParams & {relays: string[]}) =>
publishThunk({event: makeDelete(params), relays})
2025-01-15 01:23:27 +00:00
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],
]
2025-06-10 20:35:57 +00:00
return makeEvent(REPORT, {content, tags})
2025-01-15 01:23:27 +00:00
}
export const publishReport = ({
relays,
event,
reason,
content,
}: ReportParams & {relays: string[]}) =>
publishThunk({event: makeReport({event, reason, content}), relays})
2024-10-23 21:20:24 +00:00
export type ReactionParams = {
protect: boolean
2024-10-08 18:39:16 +00:00
event: TrustedEvent
content: string
2025-05-12 22:10:24 +00:00
tags?: string[][]
2024-10-23 21:20:24 +00:00
}
2024-09-25 22:49:17 +00:00
export const makeReaction = ({protect, content, event, tags: paramTags = []}: ReactionParams) => {
2025-05-12 22:10:24 +00:00
const tags = [...paramTags, ...tagEventForReaction(event)]
const groupTag = getTag("h", event.tags)
if (groupTag) {
tags.push(groupTag)
}
if (protect) {
tags.push(PROTECTED)
}
2025-06-10 20:35:57 +00:00
return makeEvent(REACTION, {content, tags})
}
2024-10-23 21:20:24 +00:00
export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) =>
publishThunk({event: makeReaction(params), relays})
2025-01-17 23:01:05 +00:00
export type CommentParams = {
2024-10-08 18:39:16 +00:00
event: TrustedEvent
content: string
tags?: string[][]
2024-10-23 21:20:24 +00:00
}
2025-01-17 23:01:05 +00:00
export const makeComment = ({event, content, tags = []}: CommentParams) =>
2025-06-10 20:35:57 +00:00
makeEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event)]})
2024-10-23 21:20:24 +00:00
2025-01-17 23:01:05 +00:00
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
2024-10-24 20:05:25 +00:00
publishThunk({event: makeComment(params), relays})
2025-02-25 21:36:32 +00:00
2025-06-19 17:22:17 +00:00
export type AlertParams = {
2025-04-28 16:48:09 +00:00
feed: Feed
description: string
claims: Record<string, string>
2025-06-19 17:22:17 +00:00
email?: {
cron: string
email: string
handler: string[]
}
web?: {
endpoint: string
p256dh: string
auth: string
}
ios?: {
2025-06-24 21:15:36 +00:00
device_token: string
2025-06-27 15:33:31 +00:00
bundle_identifier: string
2025-06-19 17:22:17 +00:00
}
android?: {
2025-06-24 21:15:36 +00:00
device_token: string
2025-06-19 17:22:17 +00:00
}
2025-02-25 21:36:32 +00:00
}
2025-06-19 17:22:17 +00:00
export const makeAlert = async (params: AlertParams) => {
2025-03-20 19:55:12 +00:00
const tags = [
2025-06-19 17:22:17 +00:00
["feed", JSON.stringify(params.feed)],
2025-05-05 22:39:07 +00:00
["locale", LOCALE],
["timezone", TIMEZONE],
2025-06-19 17:22:17 +00:00
["description", params.description],
2025-03-20 19:55:12 +00:00
]
2025-06-19 17:22:17 +00:00
for (const [relay, claim] of Object.entries(params.claims)) {
tags.push(["claim", relay, claim])
2025-03-20 19:55:12 +00:00
}
2025-03-20 16:38:57 +00:00
2025-06-19 17:22:17 +00:00
let kind: number
if (params.email) {
kind = ALERT_EMAIL
tags.push(...Object.entries(params.email).map(flatten))
} else if (params.web) {
kind = ALERT_WEB
tags.push(...Object.entries(params.web).map(flatten))
} else if (params.ios) {
kind = ALERT_IOS
tags.push(...Object.entries(params.ios).map(flatten))
} else if (params.android) {
kind = ALERT_ANDROID
tags.push(...Object.entries(params.android).map(flatten))
} else {
throw new Error("Alert has invalid params")
}
2025-06-19 17:22:17 +00:00
return makeEvent(kind, {
content: await signer.get().nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)),
tags: [
["d", randomId()],
["p", NOTIFIER_PUBKEY],
],
})
}
2025-06-30 17:25:34 +00:00
export const publishAlert = async (params: AlertParams) =>
publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
2025-07-04 13:22:19 +00:00
// Lightning
export const getWebLn = () => (window as any).webln
2025-07-04 13:22:19 +00:00
export const payInvoice = async (invoice: string) => {
const $session = session.get()
2025-07-04 13:22:19 +00:00
if (!$session?.wallet) {
2025-07-04 13:22:19 +00:00
throw new Error("No wallet is connected")
}
if ($session.wallet.type === "nwc") {
return new nwc.NWCClient($session.wallet.info).payInvoice({invoice})
} else if ($session.wallet.type === "webln") {
2025-07-04 13:22:19 +00:00
return getWebLn()
.enable()
.then(() => getWebLn().sendPayment(invoice))
}
}