Add contributing file, rename some files

This commit is contained in:
Jon Staab
2025-08-21 15:01:31 -07:00
parent d4943daa82
commit ba80ebac63
155 changed files with 438 additions and 349 deletions
+482
View File
@@ -0,0 +1,482 @@
import {nwc} from "@getalby/sdk"
import * as nip19 from "nostr-tools/nip19"
import {get} from "svelte/store"
import {randomId, flatten, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib"
import type {Feed} from "@welshman/feeds"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {
DELETE,
REPORT,
PROFILE,
INBOX_RELAYS,
RELAYS,
FOLLOWS,
REACTION,
AUTH_JOIN,
ROOMS,
COMMENT,
ALERT_EMAIL,
ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID,
isSignedEvent,
makeEvent,
displayProfile,
normalizeRelayUrl,
makeList,
addToListPublicly,
removeFromListByPredicate,
getTag,
getListTags,
getRelayTags,
getRelayTagValues,
toNostrURI,
getRelaysFromList,
RelayMode,
} from "@welshman/util"
import {Pool, AuthStatus, SocketStatus} from "@welshman/net"
import {Router} from "@welshman/router"
import {
pubkey,
signer,
session,
repository,
publishThunk,
profilesByPubkey,
relaySelectionsByPubkey,
tagEvent,
tagEventForReaction,
userRelaySelections,
userInboxRelaySelections,
nip44EncryptToSelf,
loadRelay,
clearStorage,
dropSession,
tagEventForComment,
tagEventForQuote,
getThunkError,
} from "@welshman/app"
import {
PROTECTED,
userMembership,
INDEXER_RELAYS,
NOTIFIER_PUBKEY,
NOTIFIER_RELAY,
userRoomsByUrl,
} from "@app/core/state"
// Utils
export const getPubkeyHints = (pubkey: string) => {
const selections = relaySelectionsByPubkey.get().get(pubkey)
const relays = selections ? getRelaysFromList(selections, RelayMode.Write) : []
const hints = relays.length ? relays : INDEXER_RELAYS
return hints
}
export const getPubkeyPetname = (pubkey: string) => {
const profile = profilesByPubkey.get().get(pubkey)
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,
relays: Router.get().Event(parent).limit(3).getUrls(),
})
tags = [...tags, tagEventForQuote(parent)]
content = toNostrURI(nevent) + "\n\n" + content
}
return {content, tags}
}
// Log out
export const logout = async () => {
const $pubkey = pubkey.get()
if ($pubkey) {
dropSession($pubkey)
}
await clearStorage()
localStorage.clear()
}
// 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)) {
await publishThunk({event, relays}).result
}
}
}
// List updates
export const addSpaceMembership = async (url: string) => {
const list = get(userMembership) || 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(userMembership) || makeList({kind: ROOMS})
const pred = (t: string[]) => 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 addRoomMembership = async (url: string, room: string) => {
const list = get(userMembership) || makeList({kind: ROOMS})
const newTags = [
["r", url],
["group", room, 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, room: string) => {
const list = get(userMembership) || makeList({kind: ROOMS})
const pred = (t: string[]) => equals(["group", room, 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})
}
export const setRelayPolicy = (url: string, read: boolean, write: boolean) => {
const list = get(userRelaySelections) || makeList({kind: RELAYS})
const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url)
if (read && write) {
tags.push(["r", url])
} else if (read) {
tags.push(["r", url, "read"])
} else if (write) {
tags.push(["r", url, "write"])
}
return publishThunk({
event: makeEvent(list.kind, {tags}),
relays: [
url,
...INDEXER_RELAYS,
...Router.get().FromUser().getUrls(),
...userRoomsByUrl.get().keys(),
],
})
}
export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
const list = get(userInboxRelaySelections) || makeList({kind: INBOX_RELAYS})
// Only update inbox policies if they already exist or we're adding them
if (enabled || getRelaysFromList(list).includes(url)) {
const tags = getRelayTags(getListTags(list)).filter(t => normalizeRelayUrl(t[1]) !== url)
if (enabled) {
tags.push(["relay", url])
}
return publishThunk({
event: makeEvent(list.kind, {tags}),
relays: [
...INDEXER_RELAYS,
...Router.get().FromUser().getUrls(),
...userRoomsByUrl.get().keys(),
],
})
}
}
// 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
}
export const checkRelayAccess = async (url: string, claim = "") => {
const socket = Pool.get().get(url)
await attemptAuth(url)
const thunk = publishThunk({
event: makeEvent(AUTH_JOIN, {tags: [["claim", claim]]}),
relays: [url],
})
const error = await getThunkError(thunk)
if (error) {
const message =
socket.auth.details?.replace(/^\w+: /, "") ||
error.replace(/^\w+: /, "") ||
"join request rejected"
// If it's a strict NIP 29 relay don't worry about requesting access
// TODO: remove this if relay29 ever gets less strict
if (message === "missing group (`h`) tag") return
// Ignore messages about the relay ignoring ours
if (error?.startsWith("mute: ")) return
// Ignore rejected empty claims
if (!claim && error?.includes("invite code")) return
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)
socket.attemptToOpen()
await poll({
signal: AbortSignal.timeout(3000),
condition: () => socket.status === SocketStatus.Open,
})
if (socket.status !== SocketStatus.Open) {
return `Failed to connect`
}
}
export const checkRelayAuth = async (url: string, timeout = 3000) => {
const socket = Pool.get().get(url)
const okStatuses = [AuthStatus.None, AuthStatus.Ok]
await attemptAuth(url)
// 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 (!okStatuses.includes(socket.auth.status) && socket.auth.details) {
return `Failed to authenticate (${socket.auth.details})`
}
}
export const attemptRelayAccess = async (url: string, claim = "") => {
const checks = [
() => checkRelayConnection(url),
() => checkRelayAccess(url, claim),
() => checkRelayAuth(url),
]
for (const check of checks) {
const error = await check()
if (error) {
return error
}
}
}
// Actions
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})
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})
export type ReactionParams = {
protect: boolean
event: TrustedEvent
content: string
tags?: string[][]
}
export const makeReaction = ({protect, content, event, tags: paramTags = []}: ReactionParams) => {
const tags = [...paramTags, ...tagEventForReaction(event)]
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(params), relays})
export type CommentParams = {
event: TrustedEvent
content: string
tags?: string[][]
}
export const makeComment = ({event, content, tags = []}: CommentParams) =>
makeEvent(COMMENT, {content, tags: [...tags, ...tagEventForComment(event)]})
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
publishThunk({event: makeComment(params), relays})
export type AlertParams = {
feed: Feed
description: string
claims: Record<string, string>
email?: {
cron: string
email: string
handler: string[]
}
web?: {
endpoint: string
p256dh: string
auth: string
}
ios?: {
device_token: string
bundle_identifier: string
}
android?: {
device_token: string
}
}
export const makeAlert = async (params: AlertParams) => {
const tags = [
["feed", JSON.stringify(params.feed)],
["locale", LOCALE],
["timezone", TIMEZONE],
["description", params.description],
]
for (const [relay, claim] of Object.entries(params.claims)) {
tags.push(["claim", relay, claim])
}
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")
}
return makeEvent(kind, {
content: await signer.get().nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)),
tags: [
["d", randomId()],
["p", NOTIFIER_PUBKEY],
],
})
}
export const publishAlert = async (params: AlertParams) =>
publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
// Lightning
export const getWebLn = () => (window as any).webln
export const payInvoice = async (invoice: string) => {
const $session = session.get()
if (!$session?.wallet) {
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") {
return getWebLn()
.enable()
.then(() => getWebLn().sendPayment(invoice))
}
}
+456
View File
@@ -0,0 +1,456 @@
import {get, writable} from "svelte/store"
import {
partition,
chunk,
sample,
sleep,
shuffle,
uniq,
int,
YEAR,
DAY,
insertAt,
sortBy,
assoc,
now,
isNotNil,
filterVals,
fromPairs,
} from "@welshman/lib"
import {
MESSAGE,
DELETE,
THREAD,
EVENT_TIME,
AUTH_INVITE,
COMMENT,
ALERT_EMAIL,
ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID,
ALERT_STATUS,
matchFilters,
getTagValues,
getTagValue,
getAddress,
isShareableRelayUrl,
getRelaysFromList,
} from "@welshman/util"
import type {TrustedEvent, Filter, List} from "@welshman/util"
import {feedFromFilters, makeRelayFeed, makeIntersectionFeed} from "@welshman/feeds"
import {load, request} from "@welshman/net"
import type {AppSyncOpts, Thunk} from "@welshman/app"
import {
repository,
pull,
hasNegentropy,
thunkQueue,
makeFeedController,
loadRelay,
loadMutes,
loadFollows,
loadProfile,
loadBlossomServers,
loadRelaySelections,
loadInboxRelaySelections,
} from "@welshman/app"
import {createScroller} from "@lib/html"
import {daysBetween} from "@lib/util"
import {
NOTIFIER_RELAY,
INDEXER_RELAYS,
getDefaultPubkeys,
userRoomsByUrl,
getUrlsForEvent,
loadMembership,
loadSettings,
} from "@app/core/state"
// Utils
export const pullConservatively = ({relays, filters}: AppSyncOpts) => {
const $getUrlsForEvent = get(getUrlsForEvent)
const [smart, dumb] = partition(hasNegentropy, relays)
const promises = [pull({relays: smart, filters})]
const allEvents = repository.query(filters, {shouldSort: false})
// Since pulling from relays without negentropy is expensive, limit how many
// duplicates we repeatedly download
for (const url of dumb) {
const events = allEvents.filter(e => $getUrlsForEvent(e.id).includes(url))
if (events.length > 100) {
filters = filters.map(assoc("since", events[10]!.created_at))
}
promises.push(pull({relays: [url], filters}))
}
return Promise.all(promises)
}
export const makeFeed = ({
relays,
feedFilters,
subscriptionFilters,
element,
onEvent,
onExhausted,
initialEvents = [],
}: {
relays: string[]
feedFilters: Filter[]
subscriptionFilters: Filter[]
element: HTMLElement
onEvent?: (event: TrustedEvent) => void
onExhausted?: () => void
initialEvents?: TrustedEvent[]
}) => {
const seen = new Set<string>()
const buffer = writable<TrustedEvent[]>([])
const events = writable(initialEvents)
const controller = new AbortController()
const markEvent = (event: TrustedEvent) => {
if (!seen.has(event.id)) {
seen.add(event.id)
onEvent?.(event)
}
}
const insertEvent = (event: TrustedEvent) => {
let handled = false
events.update($events => {
for (let i = 0; i < $events.length; i++) {
if ($events[i].id === event.id) return $events
if ($events[i].created_at < event.created_at) {
handled = true
return insertAt(i, event, $events)
}
}
return $events
})
if (!handled) {
buffer.update($buffer => {
for (let i = 0; i < $buffer.length; i++) {
if ($buffer[i].id === event.id) return $buffer
if ($buffer[i].created_at < event.created_at) return insertAt(i, event, $buffer)
}
return [...$buffer, event]
})
}
markEvent(event)
}
const removeEvents = (ids: string[]) => {
buffer.update($buffer => $buffer.filter(e => !ids.includes(e.id)))
events.update($events => $events.filter(e => !ids.includes(e.id)))
}
const handleDelete = (e: TrustedEvent) => removeEvents(getTagValues(["e", "a"], e.tags))
const onThunk = (thunk: Thunk) => {
if (matchFilters(feedFilters, thunk.event)) {
insertEvent(thunk.event)
thunk.controller.signal.addEventListener("abort", () => {
removeEvents([thunk.event.id])
})
} else if (thunk.event.kind === DELETE) {
handleDelete(thunk.event)
}
}
const ctrl = makeFeedController({
useWindowing: true,
feed: makeIntersectionFeed(makeRelayFeed(...relays), feedFromFilters(feedFilters)),
onEvent: insertEvent,
onExhausted,
})
for (const event of initialEvents) {
markEvent(event)
}
request({
relays,
signal: controller.signal,
filters: subscriptionFilters,
onEvent: (e: TrustedEvent) => {
if (matchFilters(feedFilters, e)) insertEvent(e)
if (e.kind === DELETE) handleDelete(e)
},
})
const scroller = createScroller({
element,
delay: 300,
threshold: 10_000,
onScroll: async () => {
const $buffer = get(buffer)
events.update($events => sortBy(e => -e.created_at, [...$events, ...$buffer.splice(0, 100)]))
if ($buffer.length < 100) {
ctrl.load(100)
}
},
})
const unsubscribe = thunkQueue.subscribe(onThunk)
return {
events,
cleanup: () => {
unsubscribe()
scroller.stop()
controller.abort()
},
}
}
export const makeCalendarFeed = ({
relays,
feedFilters,
subscriptionFilters,
element,
onExhausted,
initialEvents = [],
}: {
relays: string[]
feedFilters: Filter[]
subscriptionFilters: Filter[]
element: HTMLElement
onExhausted?: () => void
initialEvents?: TrustedEvent[]
}) => {
const interval = int(5, DAY)
const controller = new AbortController()
let exhaustedScrollers = 0
let backwardWindow = [now() - interval, now()]
let forwardWindow = [now(), now() + interval]
const getStart = (event: TrustedEvent) => parseInt(getTagValue("start", event.tags) || "")
const getEnd = (event: TrustedEvent) => parseInt(getTagValue("end", event.tags) || "")
const events = writable(sortBy(getStart, initialEvents))
const insertEvent = (event: TrustedEvent) => {
const start = getStart(event)
const address = getAddress(event)
if (isNaN(start) || isNaN(getEnd(event))) return
events.update($events => {
for (let i = 0; i < $events.length; i++) {
if ($events[i].id === event.id) return $events
if (getStart($events[i]) > start) return insertAt(i, event, $events)
}
return [...$events.filter(e => getAddress(e) !== address), event]
})
}
const removeEvents = (ids: string[]) => {
events.update($events => $events.filter(e => !ids.includes(e.id)))
}
const onThunk = (thunk: Thunk) => {
if (matchFilters(feedFilters, thunk.event)) {
insertEvent(thunk.event)
thunk.controller.signal.addEventListener("abort", () => {
removeEvents([thunk.event.id])
})
}
}
request({
relays,
signal: controller.signal,
filters: subscriptionFilters,
onEvent: (e: TrustedEvent) => {
if (matchFilters(feedFilters, e)) insertEvent(e)
},
})
const loadTimeframe = (since: number, until: number) => {
const hashes = daysBetween(since, until).map(String)
request({
relays,
signal: controller.signal,
autoClose: true,
filters: [{kinds: [EVENT_TIME], "#D": hashes}],
onEvent: insertEvent,
})
}
const maybeExhausted = () => {
if (++exhaustedScrollers === 2) {
onExhausted?.()
}
}
const backwardScroller = createScroller({
element,
reverse: true,
onScroll: () => {
const [since, until] = backwardWindow
backwardWindow = [since - interval, since]
if (until > now() - int(2, YEAR)) {
loadTimeframe(since, until)
} else {
backwardScroller.stop()
maybeExhausted()
}
},
})
const forwardScroller = createScroller({
element,
onScroll: () => {
const [since, until] = forwardWindow
forwardWindow = [until, until + interval]
if (until < now() + int(2, YEAR)) {
loadTimeframe(since, until)
} else {
forwardScroller.stop()
maybeExhausted()
}
},
})
const unsubscribe = thunkQueue.subscribe(onThunk)
return {
events,
cleanup: () => {
backwardScroller.stop()
forwardScroller.stop()
controller.abort()
unsubscribe()
},
}
}
// Domain specific
export const loadAlerts = (pubkey: string) =>
load({
relays: [NOTIFIER_RELAY],
filters: [{kinds: [ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID], authors: [pubkey]}],
})
export const loadAlertStatuses = (pubkey: string) =>
load({
relays: [NOTIFIER_RELAY],
filters: [{kinds: [ALERT_STATUS], "#p": [pubkey]}],
})
// Application requests
export const listenForNotifications = () => {
const controller = new AbortController()
for (const [url, allRooms] of userRoomsByUrl.get()) {
// Limit how many rooms we load at a time, since we have to send a separate filter
// for each one due to relay29 being picky
const rooms = shuffle(Array.from(allRooms)).slice(0, 30)
load({
signal: controller.signal,
relays: [url],
filters: [
{kinds: [THREAD], limit: 1},
{kinds: [MESSAGE], limit: 1},
{kinds: [COMMENT], "#K": [String(THREAD)], limit: 1},
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], limit: 1})),
],
})
request({
signal: controller.signal,
relays: [url],
filters: [
{kinds: [THREAD], since: now()},
{kinds: [MESSAGE], since: now()},
{kinds: [COMMENT], "#K": [String(THREAD)], since: now()},
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], since: now()})),
],
})
}
return () => controller.abort()
}
export const loadUserData = async (pubkey: string, relays: string[] = []) => {
await Promise.race([sleep(3000), loadRelaySelections(pubkey, relays)])
const promise = Promise.race([
sleep(3000),
Promise.all([
loadInboxRelaySelections(pubkey, relays),
loadBlossomServers(pubkey, relays),
loadMembership(pubkey, relays),
loadSettings(pubkey, relays),
loadProfile(pubkey, relays),
loadFollows(pubkey, relays),
loadMutes(pubkey, relays),
loadAlertStatuses(pubkey),
loadAlerts(pubkey),
]),
])
// Load followed profiles slowly in the background without clogging other stuff up. Only use a single
// indexer relay to avoid too many redundant validations, which slow things down and eat bandwidth
promise.then(async () => {
for (const pubkeys of chunk(50, getDefaultPubkeys())) {
const relays = sample(1, INDEXER_RELAYS)
await sleep(1000)
for (const pubkey of pubkeys) {
loadMembership(pubkey, relays)
loadProfile(pubkey, relays)
loadFollows(pubkey, relays)
loadMutes(pubkey, relays)
}
}
})
return promise
}
export const discoverRelays = (lists: List[]) =>
Promise.all(
uniq(lists.flatMap($l => getRelaysFromList($l)))
.filter(isShareableRelayUrl)
.map(url => loadRelay(url)),
)
export const requestRelayClaim = async (url: string) => {
const filters = [{kinds: [AUTH_INVITE], limit: 1}]
const events = await load({filters, relays: [url]})
if (events.length > 0) {
return getTagValue("claim", events[0].tags)
}
}
export const requestRelayClaims = async (urls: string[]) =>
filterVals(
isNotNil,
fromPairs(await Promise.all(urls.map(async url => [url, await requestRelayClaim(url)]))),
)
+727
View File
@@ -0,0 +1,727 @@
import twColors from "tailwindcss/colors"
import {Capacitor} from "@capacitor/core"
import {get, derived} from "svelte/store"
import * as nip19 from "nostr-tools/nip19"
import {
on,
call,
remove,
uniqBy,
sortBy,
sort,
uniq,
nth,
pushToMapKey,
nthEq,
shuffle,
parseJson,
fromPairs,
memoize,
addToMapKey,
identity,
groupBy,
always,
} from "@welshman/lib"
import type {Socket} from "@welshman/net"
import {Pool, load, AuthStateEvent, SocketEvent} from "@welshman/net"
import {
collection,
custom,
deriveEvents,
deriveEventsMapped,
withGetter,
synced,
localStorageProvider,
} from "@welshman/store"
import {
getIdFilters,
WRAP,
CLIENT_AUTH,
AUTH_JOIN,
REACTION,
ZAP_REQUEST,
ZAP_RESPONSE,
DIRECT_MESSAGE,
DIRECT_MESSAGE_FILE,
ROOM_META,
MESSAGE,
ROOMS,
THREAD,
COMMENT,
ROOM_JOIN,
ROOM_ADD_USER,
ROOM_REMOVE_USER,
ALERT_EMAIL,
ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID,
ALERT_STATUS,
getGroupTags,
getRelayTagValues,
getPubkeyTagValues,
isHashedEvent,
displayProfile,
readList,
getListTags,
asDecryptedEvent,
normalizeRelayUrl,
getTag,
getTagValue,
getTagValues,
} from "@welshman/util"
import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util"
import {Nip59, decrypt} from "@welshman/signer"
import {routerContext, Router} from "@welshman/router"
import {
pubkey,
repository,
profilesByPubkey,
tracker,
makeTrackerStore,
makeRepositoryStore,
relay,
getSession,
getSigner,
createSearch,
userFollows,
ensurePlaintext,
thunks,
flattenThunks,
signer,
makeOutboxLoader,
appContext,
} from "@welshman/app"
import type {Thunk, Relay} from "@welshman/app"
export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
export const ROOM = "h"
export const PROTECTED = ["-"]
export const ENABLE_ZAPS = Capacitor.getPlatform() != "ios"
export const REACTION_KINDS = ENABLE_ZAPS ? [REACTION, ZAP_RESPONSE] : [REACTION]
export const NOTIFIER_PUBKEY = import.meta.env.VITE_NOTIFIER_PUBKEY
export const NOTIFIER_RELAY = import.meta.env.VITE_NOTIFIER_RELAY
export const VAPID_PUBLIC_KEY = import.meta.env.VITE_VAPID_PUBLIC_KEY
export const INDEXER_RELAYS = fromCsv(import.meta.env.VITE_INDEXER_RELAYS)
export const SIGNER_RELAYS = fromCsv(import.meta.env.VITE_SIGNER_RELAYS)
export const PLATFORM_URL = window.location.origin
export const PLATFORM_TERMS = import.meta.env.VITE_PLATFORM_TERMS
export const PLATFORM_PRIVACY = import.meta.env.VITE_PLATFORM_PRIVACY
export const PLATFORM_LOGO = PLATFORM_URL + "/pwa-192x192.png"
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME
export const PLATFORM_RELAYS = fromCsv(import.meta.env.VITE_PLATFORM_RELAYS)
export const PLATFORM_ACCENT = import.meta.env.VITE_PLATFORM_ACCENT
export const PLATFORM_DESCRIPTION = import.meta.env.VITE_PLATFORM_DESCRIPTION
export const BURROW_URL = import.meta.env.VITE_BURROW_URL
export const DEFAULT_PUBKEYS = import.meta.env.VITE_DEFAULT_PUBKEYS
export const DUFFLEPUD_URL = "https://dufflepud.onrender.com"
export const IMGPROXY_URL = "https://imgproxy.coracle.social"
export const NIP46_PERMS =
"nip44_encrypt,nip44_decrypt," +
[CLIENT_AUTH, AUTH_JOIN, MESSAGE, THREAD, COMMENT, ROOMS, WRAP, REACTION, ZAP_REQUEST]
.map(k => `sign_event:${k}`)
.join(",")
export const colors = [
["amber", twColors.amber[600]],
["blue", twColors.blue[600]],
["cyan", twColors.cyan[600]],
["emerald", twColors.emerald[600]],
["fuchsia", twColors.fuchsia[600]],
["green", twColors.green[600]],
["indigo", twColors.indigo[600]],
["sky", twColors.sky[600]],
["lime", twColors.lime[600]],
["orange", twColors.orange[600]],
["pink", twColors.pink[600]],
["purple", twColors.purple[600]],
["red", twColors.red[600]],
["rose", twColors.rose[600]],
["sky", twColors.sky[600]],
["teal", twColors.teal[600]],
["violet", twColors.violet[600]],
["yellow", twColors.yellow[600]],
["zinc", twColors.zinc[600]],
]
export const dufflepud = (path: string) => DUFFLEPUD_URL + "/" + path
export const imgproxy = (url: string, {w = 640, h = 1024} = {}) => {
if (!url || url.match("gif$")) {
return url
}
url = url.split("?")[0]
try {
return url ? `${IMGPROXY_URL}/x/s:${w}:${h}/${btoa(url)}` : url
} catch (e) {
return url
}
}
export const entityLink = (entity: string) => `https://coracle.social/${entity}`
export const pubkeyLink = (pubkey: string, relays = Router.get().FromPubkeys([pubkey]).getUrls()) =>
entityLink(nip19.nprofileEncode({pubkey, relays}))
export const getDefaultPubkeys = () => {
const appPubkeys = DEFAULT_PUBKEYS.split(",")
const userPubkeys = shuffle(getPubkeyTagValues(getListTags(get(userFollows))))
return userPubkeys.length > 5 ? userPubkeys : [...userPubkeys, ...appPubkeys]
}
const failedUnwraps = new Set()
export const ensureUnwrapped = async (event: TrustedEvent) => {
if (event.kind !== WRAP) {
return event
}
let rumor = repository.eventsByWrap.get(event.id)
if (rumor || failedUnwraps.has(event.id)) {
return rumor
}
for (const recipient of getPubkeyTagValues(event.tags)) {
const session = getSession(recipient)
const signer = getSigner(session)
if (signer) {
try {
rumor = await Nip59.fromSigner(signer).unwrap(event as SignedEvent)
break
} catch (e) {
// pass
}
}
}
if (rumor && isHashedEvent(rumor)) {
// Copy urls over to the rumor
tracker.copy(event.id, rumor.id)
// Send the rumor via our relay so listeners get updated
relay.send("EVENT", rumor)
} else {
failedUnwraps.add(event.id)
}
return rumor
}
export const trackerStore = makeTrackerStore()
export const repositoryStore = makeRepositoryStore()
export const deriveEvent = (idOrAddress: string, hints: string[] = []) => {
let attempted = false
const filters = getIdFilters([idOrAddress])
const relays = [...hints, ...INDEXER_RELAYS]
return derived(
deriveEvents(repository, {filters, includeDeleted: true}),
(events: TrustedEvent[]) => {
if (!attempted && events.length === 0) {
load({relays, filters})
attempted = true
}
return events[0]
},
)
}
export const getUrlsForEvent = derived([trackerStore, thunks], ([$tracker, $thunks]) => {
const getThunksByEventId = memoize(() => {
const thunksByEventId = new Map<string, Thunk[]>()
for (const thunk of flattenThunks(Object.values($thunks))) {
pushToMapKey(thunksByEventId, thunk.event.id, thunk)
}
return thunksByEventId
})
return (id: string) => {
const urls = Array.from($tracker.getRelays(id))
for (const thunk of getThunksByEventId().get(id) || []) {
for (const url of thunk.options.relays) {
urls.push(url)
}
}
return uniq(urls)
}
})
export const getEventsForUrl = (url: string, filters: Filter[]) => {
const $getUrlsForEvent = get(getUrlsForEvent)
const $events = repository.query(filters)
return sortBy(
e => -e.created_at,
$events.filter(e => $getUrlsForEvent(e.id).includes(url)),
)
}
export const deriveEventsForUrl = (url: string, filters: Filter[]) =>
derived([deriveEvents(repository, {filters}), getUrlsForEvent], ([$events, $getUrlsForEvent]) =>
sortBy(
e => -e.created_at,
$events.filter(e => $getUrlsForEvent(e.id).includes(url)),
),
)
// Context
appContext.dufflepudUrl = DUFFLEPUD_URL
routerContext.getIndexerRelays = always(INDEXER_RELAYS)
// Settings
export const canDecrypt = synced({
key: "canDecrypt",
defaultValue: false,
storage: localStorageProvider,
})
export const SETTINGS = 38489
export type Settings = {
event: TrustedEvent
values: {
show_media: boolean
hide_sensitive: boolean
report_usage: boolean
report_errors: boolean
send_delay: number
font_size: number
}
}
export const defaultSettings = {
show_media: true,
hide_sensitive: true,
report_usage: true,
report_errors: true,
send_delay: 3000,
font_size: 1,
}
export const settings = deriveEventsMapped<Settings>(repository, {
filters: [{kinds: [SETTINGS]}],
itemToEvent: item => item.event,
eventToItem: async (event: TrustedEvent) => ({
event,
values: {...defaultSettings, ...parseJson(await ensurePlaintext(event))},
}),
})
export const {
indexStore: settingsByPubkey,
deriveItem: deriveSettings,
loadItem: loadSettings,
} = collection({
name: "settings",
store: settings,
getKey: settings => settings.event.pubkey,
load: makeOutboxLoader(SETTINGS),
})
// Alerts
export type Alert = {
event: TrustedEvent
tags: string[][]
}
export const alerts = withGetter(
deriveEventsMapped<Alert>(repository, {
filters: [{kinds: [ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID]}],
itemToEvent: item => item.event,
eventToItem: async event => {
const tags = parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, event.content))
return {event, tags}
},
}),
)
// Alert Statuses
export type AlertStatus = {
event: TrustedEvent
tags: string[][]
}
export const alertStatuses = withGetter(
deriveEventsMapped<AlertStatus>(repository, {
filters: [{kinds: [ALERT_STATUS]}],
itemToEvent: item => item.event,
eventToItem: async event => {
const tags = parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, event.content))
return {event, tags}
},
}),
)
export const deriveAlertStatus = (address: string) =>
derived(alertStatuses, statuses => statuses.find(s => getTagValue("d", s.event.tags) === address))
// Membership
export const hasMembershipUrl = (list: List | undefined, url: string) =>
getListTags(list).some(t => {
if (t[0] === "r") return t[1] === url
if (t[0] === "group") return t[2] === url
return false
})
export const getMembershipUrls = (list?: List) => {
const tags = getListTags(list)
return sort(
uniq([...getRelayTagValues(tags), ...getGroupTags(tags).map(nth(2))]).map(url =>
normalizeRelayUrl(url),
),
)
}
export const getMembershipRooms = (list?: List) =>
getGroupTags(getListTags(list)).map(([_, room, url, name = ""]) => ({url, room, name}))
export const getMembershipRoomsByUrl = (url: string, list?: List) =>
sort(getGroupTags(getListTags(list)).filter(nthEq(2, url)).map(nth(1)))
export const memberships = deriveEventsMapped<PublishedList>(repository, {
filters: [{kinds: [ROOMS]}],
itemToEvent: item => item.event,
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
})
export const {
indexStore: membershipsByPubkey,
deriveItem: deriveMembership,
loadItem: loadMembership,
} = collection({
name: "memberships",
store: memberships,
getKey: list => list.event.pubkey,
load: makeOutboxLoader(ROOMS),
})
// Chats
export const chatMessages = deriveEvents(repository, {
filters: [{kinds: [DIRECT_MESSAGE, DIRECT_MESSAGE_FILE]}],
})
export type Chat = {
id: string
pubkeys: string[]
messages: TrustedEvent[]
last_activity: number
search_text: string
}
export const makeChatId = (pubkeys: string[]) => sort(uniq(pubkeys.concat(pubkey.get()!))).join(",")
export const splitChatId = (id: string) => id.split(",")
export const chats = derived(
[pubkey, chatMessages, profilesByPubkey],
([$pubkey, $messages, $profilesByPubkey]) => {
const messagesByChatId = new Map<string, TrustedEvent[]>()
for (const message of $messages) {
const chatId = makeChatId(getPubkeyTagValues(message.tags).concat(message.pubkey))
pushToMapKey(messagesByChatId, chatId, message)
}
const displayPubkey = (pubkey: string) => {
const profile = $profilesByPubkey.get(pubkey)
return profile ? displayProfile(profile) : ""
}
return sortBy(
c => -c.last_activity,
Array.from(messagesByChatId.entries()).map(([id, events]): Chat => {
const pubkeys = remove($pubkey!, splitChatId(id))
const messages = sortBy(e => -e.created_at, events)
const last_activity = messages[0].created_at
const search_text =
pubkeys.length === 0
? displayPubkey($pubkey!) + " note to self"
: pubkeys.map(displayPubkey).join(" ")
return {id, pubkeys, messages, last_activity, search_text}
}),
)
},
)
export const {
indexStore: chatsById,
deriveItem: deriveChat,
loadItem: loadChat,
} = collection({
name: "chats",
store: chats,
getKey: chat => chat.id,
load: always(Promise.resolve()),
})
export const chatSearch = derived(chats, $chats =>
createSearch($chats, {
getValue: (chat: Chat) => chat.id,
fuseOptions: {keys: ["search_text"]},
}),
)
// Messages
export const messages = deriveEvents(repository, {filters: [{kinds: [MESSAGE]}]})
// Channels
export type Channel = {
id: string
url: string
room: string
name: string
event: TrustedEvent
closed: boolean
private: boolean
picture?: string
about?: string
}
export const makeChannelId = (url: string, room: string) => `${url}'${room}`
export const splitChannelId = (id: string) => id.split("'")
export const hasNip29 = (relay?: Relay) =>
relay?.profile?.supported_nips?.map?.(String)?.includes?.("29")
export const channelEvents = deriveEvents(repository, {filters: [{kinds: [ROOM_META]}]})
export const channels = derived(
[channelEvents, getUrlsForEvent],
([$channelEvents, $getUrlsForEvent]) => {
const $channels: Channel[] = []
for (const event of $channelEvents) {
const meta = fromPairs(event.tags)
const room = meta.d
if (room) {
for (const url of $getUrlsForEvent(event.id)) {
const id = makeChannelId(url, room)
$channels.push({
id,
url,
room,
event,
name: meta.name || room,
closed: Boolean(getTag("closed", event.tags)),
private: Boolean(getTag("private", event.tags)),
picture: meta.picture,
about: meta.about,
})
}
}
}
return uniqBy(c => c.id, $channels)
},
)
export const channelsByUrl = derived(channels, $channels => groupBy(c => c.url, $channels))
export const {
indexStore: channelsById,
deriveItem: _deriveChannel,
loadItem: _loadChannel,
} = collection({
name: "channels",
store: channels,
getKey: channel => channel.id,
load: async (id: string) => {
const [url, room] = splitChannelId(id)
await load({
relays: [url],
filters: [{kinds: [ROOM_META], "#d": [room]}],
})
},
})
export const deriveChannel = (url: string, room: string) => _deriveChannel(makeChannelId(url, room))
export const loadChannel = (url: string, room: string) => _loadChannel(makeChannelId(url, room))
export const displayChannel = (url: string, room: string) =>
channelsById.get().get(makeChannelId(url, room))?.name || room
export const roomComparator = (url: string) => (room: string) =>
displayChannel(url, room).toLowerCase()
// User stuff
export const userSettings = withGetter(
derived([pubkey, settingsByPubkey], ([$pubkey, $settingsByPubkey]) => {
if (!$pubkey) return undefined
loadSettings($pubkey)
return $settingsByPubkey.get($pubkey)
}),
)
export const userSettingValues = withGetter(
derived(userSettings, $s => $s?.values || defaultSettings),
)
export const getSetting = <T>(key: keyof Settings["values"]) => userSettingValues.get()[key] as T
export const userMembership = withGetter(
derived([pubkey, membershipsByPubkey], ([$pubkey, $membershipsByPubkey]) => {
if (!$pubkey) return undefined
loadMembership($pubkey)
return $membershipsByPubkey.get($pubkey)
}),
)
export const userRoomsByUrl = withGetter(
derived([userMembership, channelsById], ([$userMembership, $channelsById]) => {
const tags = getListTags($userMembership)
const $userRoomsByUrl = new Map<string, Set<string>>()
for (const url of getRelayTagValues(tags)) {
$userRoomsByUrl.set(normalizeRelayUrl(url), new Set())
}
for (const [_, room, url] of getGroupTags(tags)) {
if ($channelsById.has(makeChannelId(url, room))) {
addToMapKey($userRoomsByUrl, normalizeRelayUrl(url), room)
}
}
return $userRoomsByUrl
}),
)
export const deriveUserRooms = (url: string) =>
derived(userRoomsByUrl, $userRoomsByUrl =>
sortBy(roomComparator(url), uniq(Array.from($userRoomsByUrl.get(url) || []))),
)
export const deriveOtherRooms = (url: string) =>
derived([deriveUserRooms(url), channelsByUrl], ([$userRooms, $channelsByUrl]) =>
sortBy(
roomComparator(url),
($channelsByUrl.get(url) || []).filter(c => !$userRooms.includes(c.room)).map(c => c.room),
),
)
export enum MembershipStatus {
Initial,
Pending,
Granted,
}
export const deriveUserMembershipStatus = (url: string, room: string) =>
derived(
[
pubkey,
deriveEventsForUrl(url, [
{kinds: [ROOM_JOIN, ROOM_ADD_USER, ROOM_REMOVE_USER], "#h": [room]},
]),
],
([$pubkey, $events]) => {
let status = MembershipStatus.Initial
for (const event of $events) {
if (event.kind === ROOM_JOIN && event.pubkey === $pubkey) {
status = MembershipStatus.Pending
}
if (event.kind === ROOM_REMOVE_USER && getTagValues("p", event.tags).includes($pubkey!)) {
break
}
if (event.kind === ROOM_ADD_USER && getTagValues("p", event.tags).includes($pubkey!)) {
return MembershipStatus.Granted
}
}
return status
},
)
// Other utils
export const encodeRelay = (url: string) =>
encodeURIComponent(
normalizeRelayUrl(url)
.replace(/^wss:\/\//, "")
.replace(/\/$/, ""),
)
export const decodeRelay = (url: string) => normalizeRelayUrl(decodeURIComponent(url))
export const displayReaction = (content: string) => {
if (!content || content === "+") return "❤️"
if (content === "-") return "👎"
return content
}
export const deriveSocket = (url: string) =>
custom<Socket>(set => {
const pool = Pool.get()
const socket = pool.get(url)
set(socket)
const subs = [
on(socket, SocketEvent.Error, () => set(socket)),
on(socket, SocketEvent.Status, () => set(socket)),
on(socket.auth, AuthStateEvent.Status, () => set(socket)),
]
return () => subs.forEach(call)
})