Files
flotilla/src/app/core/state.ts
T
mplorentz 16a73f27c9 Add a dialog before joining voice rooms (#109)
After using the voice rooms more since we removed the option for voice-only rooms I think you were right to suggest a dialog box before joining rooms. It felt far to clunky to have to join the voice call any time you just wanted to try to view room members, edit room settings, or just view the recent text chat.

This adds a dialog that allows the user to decline to join the call but still access the text part of the room along with associated settings and controls. It also acts as another confirmation step before turning on the user's microphone, and allows them to choose an audio input so they don't have to mess with the (generally terrible) browser controls for doing so. We should probably have controls to change your audio input and output from within the call as well, but I think this is enough for an MVP.

![Screenshot 2026-03-27 at 11.10.53 AM.png](/attachments/3ac271a6-5d17-4063-9ac6-3e5bdef10ccf)

Co-authored-by: mplorentz <mplorentz@noreply.gitea.coracle.social>
Reviewed-on: coracle/flotilla#109
Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social>
Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
2026-03-27 19:02:56 +00:00

1323 lines
35 KiB
TypeScript

import twColors from "tailwindcss/colors"
import {context as pomadeContext} from "@pomade/core"
import {Capacitor} from "@capacitor/core"
import {derived, readable, writable} from "svelte/store"
import * as nip19 from "nostr-tools/nip19"
import {
on,
gt,
max,
find,
spec,
call,
first,
uniqBy,
sortBy,
append,
reject,
sort,
uniq,
indexBy,
partition,
shuffle,
parseJson,
memoize,
addToMapKey,
identity,
always,
randomId,
tryCatch,
fromPairs,
groupBy,
remove,
simpleCache,
removeUndefined,
} from "@welshman/lib"
import type {Override} from "@welshman/lib"
import type {RepositoryUpdate} from "@welshman/net"
import {
Pool,
load,
SocketStatus,
AuthStateEvent,
AuthStatus,
SocketEvent,
netContext,
} from "@welshman/net"
import {
getter,
throttled,
withGetter,
deriveArray,
makeDeriveEvent,
makeLoadItem,
makeDeriveItem,
deriveItems,
deriveItemsByKey,
deriveDeduplicated,
deriveEventsById,
deriveEventsByIdByUrl,
deriveEventsByIdForUrl,
getEventsByIdForUrl,
deriveEventsAsc,
deriveEventsDesc,
} from "@welshman/store"
import {
FEED,
FEEDS,
APP_DATA,
CLIENT_AUTH,
COMMENT,
DELETE,
DIRECT_MESSAGE_FILE,
DIRECT_MESSAGE,
EVENT_TIME,
MESSAGE,
REACTION,
RELAY_ADD_MEMBER,
RELAY_JOIN,
RELAY_LEAVE,
RELAY_MEMBERS,
RELAY_REMOVE_MEMBER,
REPORT,
ROOM_ADD_MEMBER,
ROOM_CREATE_PERMISSION,
ROOM_JOIN,
ROOM_LEAVE,
ROOM_MEMBERS,
ROOM_ADMINS,
ROOM_META,
ROOM_DELETE,
ROOM_REMOVE_MEMBER,
ROOMS,
THREAD,
CLASSIFIED,
WRAP,
PROFILE,
ZAP_GOAL,
ZAP_REQUEST,
ZAP_RESPONSE,
REPOST,
GENERIC_REPOST,
asDecryptedEvent,
getTagValue,
getGroupTags,
getListTags,
getPubkeyTagValues,
getRelayTagValues,
getTagValues,
isRelayUrl,
normalizeRelayUrl,
readList,
verifyEvent,
readRoomMeta,
makeRoomMeta,
ManagementMethod,
sortEventsAsc,
sortEventsDesc,
getAddress,
Address,
getIdFilters,
getEventTagValues,
getAddressTagValues,
getParentIds,
getParentAddrs,
} from "@welshman/util"
import type {
TrustedEvent,
RelayProfile,
PublishedList,
PublishedRoomMeta,
RoomMeta,
List,
Filter,
} from "@welshman/util"
import {routerContext, Router} from "@welshman/router"
import {
pubkey,
repository,
tracker,
createSearch,
userMuteList,
userFollowList,
ensurePlaintext,
makeOutboxLoader,
appContext,
deriveRelay,
makeUserData,
makeUserLoader,
manageRelay,
displayProfileByPubkey,
getProfile,
} from "@welshman/app"
import {checkRelayHasLivekit} from "$lib/livekit"
import {readFeed} from "@lib/feeds"
export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
export const ROOM = "h"
export const PROTECTED = ["-"]
export const IMAGE_CONTENT_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]
export const VIDEO_CONTENT_TYPES = ["video/quicktime", "video/webm", "video/mp4"]
export const ENABLE_ZAPS = Capacitor.getPlatform() != "ios"
export const PUSH_SERVER = import.meta.env.VITE_PUSH_SERVER
export const PUSH_BRIDGE = normalizeRelayUrl(import.meta.env.VITE_PUSH_BRIDGE)
export const VAPID_PUBLIC_KEY = import.meta.env.VITE_VAPID_PUBLIC_KEY
export const SIGNER_RELAYS = fromCsv(import.meta.env.VITE_SIGNER_RELAYS).map(normalizeRelayUrl)
export const BLOCKED_RELAYS = fromCsv(import.meta.env.VITE_BLOCKED_RELAYS).map(normalizeRelayUrl)
export const INDEXER_RELAYS = fromCsv(import.meta.env.VITE_INDEXER_RELAYS).map(normalizeRelayUrl)
export const DEFAULT_RELAYS = fromCsv(import.meta.env.VITE_DEFAULT_RELAYS).map(normalizeRelayUrl)
export const DEFAULT_MESSAGING_RELAYS = fromCsv(import.meta.env.VITE_DEFAULT_MESSAGING_RELAYS).map(
normalizeRelayUrl,
)
export const PLATFORM_RELAYS = fromCsv(import.meta.env.VITE_PLATFORM_RELAYS).map(normalizeRelayUrl)
export const PLATFORM_URL = import.meta.env.VITE_PLATFORM_URL
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 + "/logo.png"
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME
export const PLATFORM_ACCENT = import.meta.env.VITE_PLATFORM_ACCENT
export const PLATFORM_DESCRIPTION = import.meta.env.VITE_PLATFORM_DESCRIPTION
export const POMADE_SIGNERS = fromCsv(import.meta.env.VITE_POMADE_SIGNERS)
export const DEFAULT_BLOSSOM_SERVERS = fromCsv(import.meta.env.VITE_DEFAULT_BLOSSOM_SERVERS)
export const DEFAULT_PUBKEYS = import.meta.env.VITE_DEFAULT_PUBKEYS
export const DUFFLEPUD_URL = "https://dufflepud.onrender.com"
export const NIP46_PERMS =
"nip44_encrypt,nip44_decrypt," +
[
CLIENT_AUTH,
RELAY_JOIN,
MESSAGE,
THREAD,
CLASSIFIED,
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 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 bootstrapPubkeys = derived(userFollowList, $userFollowList => {
const appPubkeys = DEFAULT_PUBKEYS.split(",")
const userPubkeys = shuffle(getPubkeyTagValues(getListTags($userFollowList)))
return userPubkeys.length > 5 ? userPubkeys : [...userPubkeys, ...appPubkeys]
})
export const deriveEvent = makeDeriveEvent({
repository,
includeDeleted: true,
onDerive: (filters: Filter[], relays: string[]) => load({filters, relays}),
})
export const deriveEvents = (filters: Filter[] = [{}]) =>
deriveEventsDesc(deriveEventsById({repository, filters}))
export const getEventsForUrl = (url: string, filters: Filter[] = [{}]) =>
getEventsByIdForUrl({url, tracker, repository, filters}).values()
export const deriveEventsForUrl = (url: string, filters: Filter[] = [{}]) =>
deriveArray(deriveEventsByIdForUrl({url, tracker, repository, filters}))
export const deriveEventsForUrlAsc = (url: string, filters: Filter[] = [{}]) =>
deriveEventsAsc(deriveEventsByIdForUrl({url, tracker, repository, filters}))
export const deriveEventsForUrlDesc = (url: string, filters: Filter[] = [{}]) =>
deriveEventsDesc(deriveEventsByIdForUrl({url, tracker, repository, filters}))
export const deriveLatestEventForUrl = (url: string, filters: Filter[] = [{}]) =>
deriveDeduplicated(deriveEventsByIdForUrl({url, tracker, repository, filters}), $eventsById =>
first(sortEventsDesc($eventsById.values())),
)
export const deriveRelaySignedEvents = (url: string, filters: Filter[] = [{}]) =>
derived(
[deriveRelay(url), deriveEventsForUrl(url, filters)],
([relay, events]) => events,
// TODO: khatru doesn't support relay.self, uncomment when it's ready
// filter(spec({pubkey: relay.self}), events)
)
// Context
pomadeContext.setSignerUrls(POMADE_SIGNERS)
pomadeContext.setArgonWorker(import("@pomade/core/argon-worker.js?worker"))
appContext.dufflepudUrl = DUFFLEPUD_URL
routerContext.getIndexerRelays = always(INDEXER_RELAYS)
netContext.isEventValid = (event: TrustedEvent, url: string) =>
getSetting<string[]>("trusted_relays").includes(url) || verifyEvent(event)
// Filters
export const makeCommentFilter = (kinds: number[], extra: Filter = {}) => ({
kinds: [COMMENT],
"#K": kinds.map(String),
...extra,
})
export const REPOST_KINDS = [REPOST, GENERIC_REPOST]
export const REACTION_KINDS = [REPORT, DELETE, REACTION]
if (ENABLE_ZAPS) {
REACTION_KINDS.push(ZAP_RESPONSE)
}
export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED]
export const MESSAGE_KINDS = [...CONTENT_KINDS, MESSAGE]
export const DM_KINDS = [DIRECT_MESSAGE, DIRECT_MESSAGE_FILE]
// Settings
export const SETTINGS = "flotilla/settings"
export enum RelayAuthMode {
Aggressive = "aggressive",
Conservative = "conservative",
}
export type SpaceNotificationSettings = {
url: string
notify: boolean
exceptions: string[]
}
export type SettingsValues = {
show_media: boolean
hide_sensitive: boolean
trusted_relays: string[]
report_usage: boolean
report_errors: boolean
relay_auth: RelayAuthMode
send_delay: number
font_size: number
alerts: SpaceNotificationSettings[]
}
export type Settings = {
event: TrustedEvent
values: SettingsValues
}
export const defaultSettings: SettingsValues = {
show_media: true,
hide_sensitive: true,
trusted_relays: [],
report_usage: true,
report_errors: true,
relay_auth: RelayAuthMode.Conservative,
send_delay: 0,
font_size: 1.1,
alerts: [],
}
export const settingsByPubkey = deriveItemsByKey({
repository,
getKey: settings => settings.event.pubkey,
filters: [{kinds: [APP_DATA], "#d": [SETTINGS]}],
eventToItem: async (event: TrustedEvent) => {
const values = {...defaultSettings, ...parseJson(await ensurePlaintext(event))}
return {event, values}
},
})
export const getSettingsByPubkey = getter(settingsByPubkey)
export const loadSettings = makeLoadItem(
makeOutboxLoader(APP_DATA, {"#d": [SETTINGS]}),
(pubkey: string) => getSettingsByPubkey().get(pubkey),
)
export const userSettings = makeUserData(settingsByPubkey, loadSettings)
export const loadUserSettings = makeUserLoader(loadSettings)
export const userSettingsValues = derived(userSettings, $s => $s?.values || defaultSettings)
export const getSettings = getter(userSettingsValues)
export const getSetting = <T>(key: keyof Settings["values"]) => getSettings()[key] as T
// Relays sending events with empty signatures that the user has to choose to trust
export const relaysPendingTrust = writable<string[]>([])
// Relays that mostly send restricted responses to requests and events
export const relaysMostlyRestricted = writable<Record<string, string>>({})
// Push notifications
export const device = withGetter(writable(randomId()))
export const notificationSettings = withGetter(
writable({
push: false,
sound: false,
badge: false,
spaces: true,
mentions: true,
messages: true,
}),
)
export type PushSubscription = {
key: string
callback: string
}
export type PushState = {
token?: string
useFallback?: boolean
subscription?: PushSubscription
}
export const pushState = withGetter(writable<PushState>({}))
// Chats
export type Chat = {
id: string
pubkeys: string[]
messages: TrustedEvent[]
last_activity: number
search_text: string
}
export const getChatPubkeys = (pubkeys: string[]) => sort(uniq(append(pubkey.get()!, pubkeys)))
export const getChatPubkeysFromEvent = (event: TrustedEvent) =>
getChatPubkeys(getPubkeyTagValues(event.tags).concat(event.pubkey))
export const makeChatId = (pubkeys: string[]) => {
const userPubkey = pubkey.get()!
const otherPubkeys = remove(userPubkey, uniq(pubkeys))
const visiblePubkeys = otherPubkeys.length === 0 ? [userPubkey] : otherPubkeys
return sort(visiblePubkeys).join(",")
}
export const splitChatId = (id: string) => getChatPubkeys(id.split(","))
export const chatsById = call(() => {
const chatsById = new Map<string, Chat>()
const chatsByPubkey = new Map<string, string[]>()
const addSearchText = (chat: Override<Chat, {search_text?: string}>) => {
chat.search_text =
chat.pubkeys.length === 1
? displayProfileByPubkey(chat.pubkeys[0]) + " note to self"
: remove(pubkey.get()!, chat.pubkeys).map(displayProfileByPubkey).join(" ")
return chat as Chat
}
return readable(chatsById, set => {
const indexChatByPubkeys = (chat: Chat) => {
for (const pubkey of chat.pubkeys) {
chatsByPubkey.set(pubkey, uniq(append(chat.id, chatsByPubkey.get(pubkey) || [])))
}
}
const addEvents = (events: TrustedEvent[]) => {
let dirty = false
for (const event of events) {
if (DM_KINDS.includes(event.kind)) {
const pubkeys = getChatPubkeysFromEvent(event)
const id = makeChatId(pubkeys)
const chat = chatsById.get(id)
const messages = sortBy(
e => -e.created_at,
uniqBy(e => e.id, append(event, chat?.messages || [])),
)
const last_activity = Math.max(chat?.last_activity || 0, event.created_at)
const updatedChat = addSearchText({id, pubkeys, messages, last_activity})
chatsById.set(id, updatedChat)
indexChatByPubkeys(updatedChat)
dirty = true
}
if (event.kind === PROFILE) {
for (const chatId of chatsByPubkey.get(event.pubkey) || []) {
const chat = chatsById.get(chatId)
if (chat) {
addSearchText(chat)
dirty = true
}
}
}
}
if (dirty) {
set(chatsById)
}
}
const removeEvents = (removed: Set<string>) => {
let dirty = false
for (const id of removed) {
const event = repository.getEvent(id)
if (event && DM_KINDS.includes(event.kind)) {
for (const chatId of chatsByPubkey.get(event.pubkey) || []) {
const chat = chatsById.get(chatId)
if (chat) {
chat.messages = reject(spec({id: event.id}), chat.messages)
dirty = true
}
}
}
}
if (dirty) {
set(chatsById)
}
}
addEvents(repository.query([{kinds: [...DM_KINDS, DELETE, PROFILE]}]))
const unsubscribers = [
on(repository, "update", ({added, removed}: RepositoryUpdate) => {
addEvents(added)
removeEvents(removed)
}),
]
return () => unsubscribers.forEach(call)
})
})
export const deriveChat = call(() => {
const _deriveChat = makeDeriveItem(chatsById)
return (pubkeys: string[]) => _deriveChat(makeChatId(pubkeys))
})
export const chatSearch = derived(throttled(800, chatsById), $chatsByPubkey => {
return createSearch(
sortBy(c => -c.last_activity, Array.from($chatsByPubkey.values())),
{
getValue: (chat: Chat) => chat.id,
fuseOptions: {keys: ["search_text"]},
},
)
})
// Rooms
export enum RoomType {
Text = "text",
Voice = "voice",
}
export type Room = PublishedRoomMeta & {
id: string
url: string
}
export const getRoomType = (room: RoomMeta): RoomType =>
room.livekit ? RoomType.Voice : RoomType.Text
export const makeRoomId = (url: string, h: string) => `${url}'${h}`
export const splitRoomId = (id: string) => id.split("'")
export const hasNip29 = (relay?: RelayProfile) =>
Boolean(relay?.supported_nips?.map?.(String)?.includes?.("29"))
export const roomMetaEventsByIdByUrl = deriveEventsByIdByUrl({
tracker,
repository,
filters: [{kinds: [ROOM_META, ROOM_DELETE]}],
})
export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByUrl => {
const metaByIdByUrl = new Map<string, Map<string, Room>>()
for (const [url, events] of roomMetaEventsByIdByUrl.entries()) {
const [metaEvents, deleteEvents] = partition(spec({kind: ROOM_META}), events.values())
const deletedByH = new Map<string, number>()
for (const event of deleteEvents) {
for (const h of getTagValues("h", event.tags)) {
deletedByH.set(h, max([deletedByH.get(h), event.created_at]))
}
}
for (const event of metaEvents) {
const meta = tryCatch(() => readRoomMeta(event))
if (!meta || gt(deletedByH.get(meta.h), meta.event.created_at)) {
continue
}
let metaById = metaByIdByUrl.get(url)
if (!metaById) {
metaById = new Map()
metaByIdByUrl.set(url, metaById)
}
const id = makeRoomId(url, meta.h)
metaById.set(id, {...meta, url, id})
}
}
const result = new Map<string, Room[]>()
for (const [url, metaById] of metaByIdByUrl.entries()) {
result.set(url, Array.from(metaById.values()))
}
return result
})
export const roomsById = derived(roomsByUrl, roomsByUrl =>
indexBy(room => room.id, Array.from(roomsByUrl.values()).flatMap(identity)),
)
export const getRoomsById = getter(roomsById)
export const getRoom = (id: string) => getRoomsById().get(id)
export const loadRoom = call(() => {
const _fetchRoom = async (id: string) => {
const [url, h] = splitRoomId(id)
await load({
relays: [url],
filters: [{kinds: [ROOM_META], "#d": [h]}],
})
}
const _loadRoom = makeLoadItem(_fetchRoom, getRoom)
return (url: string, h: string) => _loadRoom(makeRoomId(url, h))
})
export const deriveRoom = call(() => {
const _deriveRoom = makeDeriveItem(roomsById, loadRoom)
return (url: string, h: string) =>
derived(
_deriveRoom(makeRoomId(url, h)),
room => (room || {url, id: makeRoomId(url, h), ...makeRoomMeta({h})}) as Room,
)
})
export const displayRoom = (url: string, h: string) => getRoom(makeRoomId(url, h))?.name || h
export const roomComparator = (url: string) => (h: string) => displayRoom(url, h).toLowerCase()
export const deriveVoiceRooms = (url: string) =>
derived(roomsById, $roomsById => {
const set = new Set<string>()
for (const room of $roomsById.values()) {
if (room.url === url && room.livekit) {
set.add(room.h)
}
}
return set
})
export const deriveOtherVoiceRooms = (url: string) =>
derived([deriveVoiceRooms(url), deriveUserRooms(url)], ([$roomsWithLivekit, $userRooms]) => {
const rooms: string[] = []
for (const h of $roomsWithLivekit) {
if (!$userRooms.includes(h)) {
rooms.push(h)
}
}
return sortBy(roomComparator(url), uniq(rooms))
})
// User space/room lists
export const groupListsByPubkey = deriveItemsByKey({
repository,
filters: [{kinds: [ROOMS]}],
getKey: list => list.event.pubkey,
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
})
export const getGroupListsByPubkey = getter(groupListsByPubkey)
export const getGroupList = (pubkey: string) => getGroupListsByPubkey().get(pubkey)
export const loadGroupList = makeLoadItem(makeOutboxLoader(ROOMS), getGroupList)
export const deriveGroupList = makeDeriveItem(groupListsByPubkey, loadGroupList)
export const groupListPubkeysByUrl = derived(groupListsByPubkey, $groupListsByPubkey => {
const result = new Map<string, Set<string>>()
for (const list of $groupListsByPubkey.values()) {
const tags = getListTags(list)
for (const url of getRelayTagValues(tags)) {
addToMapKey(result, url, list.event.pubkey)
}
for (const tag of getGroupTags(tags)) {
const url = tag[2] || ""
if (isRelayUrl(url)) {
addToMapKey(result, url, list.event.pubkey)
}
}
}
return result
})
export const deriveGroupListPubkeys = (url: string) =>
derived(groupListPubkeysByUrl, $groupListPubkeysByUrl => new Set($groupListPubkeysByUrl.get(url)))
export const getSpaceUrlsFromGroupList = (groupList: List | undefined) => {
const tags = getListTags(groupList)
const urls = getRelayTagValues(tags)
for (const tag of getGroupTags(tags)) {
const url = tag[2] || ""
if (isRelayUrl(url)) {
urls.push(url)
}
}
return uniq(urls.map(normalizeRelayUrl))
}
export const getSpaceRoomsFromGroupList = (url: string, groupList: List | undefined) => {
const rooms: string[] = []
for (const [_, h, relay] of getGroupTags(getListTags(groupList))) {
if (url === relay) {
rooms.push(h)
}
}
return sortBy(roomComparator(url), uniq(rooms))
}
export const userGroupList = makeUserData(groupListsByPubkey, loadGroupList)
export const loadUserGroupList = makeUserLoader(loadGroupList)
export const userSpaceUrls = derived(userGroupList, getSpaceUrlsFromGroupList)
export const deriveUserRooms = (url: string) =>
derived([userGroupList, roomsById], ([$userGroupList, $roomsById]) => {
const rooms: string[] = []
for (const h of getSpaceRoomsFromGroupList(url, $userGroupList)) {
if ($roomsById.has(makeRoomId(url, h))) {
rooms.push(h)
}
}
return sortBy(roomComparator(url), rooms)
})
export const deriveOtherRooms = (url: string) =>
derived(
[deriveUserRooms(url), deriveVoiceRooms(url), roomsByUrl],
([$userRooms, voiceRooms, $roomsByUrl]) => {
const rooms: string[] = []
for (const {h} of $roomsByUrl.get(url) || []) {
if (!$userRooms.includes(h) && !voiceRooms.has(h)) {
rooms.push(h)
}
}
return sortBy(roomComparator(url), uniq(rooms))
},
)
// Space/room memberships
export const deriveSpaceMembers = (url: string) =>
derived(
deriveRelaySignedEvents(url, [{kinds: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, RELAY_MEMBERS]}]),
$events => {
const membersEvent = $events.find(spec({kind: RELAY_MEMBERS}))
if (membersEvent) {
return uniq(getTagValues("member", membersEvent.tags))
}
const members = new Set<string>()
for (const event of sortBy(e => e.created_at, $events)) {
const pubkeys = getPubkeyTagValues(event.tags)
if (event.kind === RELAY_ADD_MEMBER) {
for (const pubkey of pubkeys) {
members.add(pubkey)
}
}
if (event.kind === RELAY_REMOVE_MEMBER) {
for (const pubkey of pubkeys) {
members.delete(pubkey)
}
}
}
return Array.from(members)
},
)
export type BannedPubkeyItem = {
pubkey: string
reason: string
}
export const spaceBannedPubkeyItems = new Map<string, BannedPubkeyItem[]>()
export const deriveSpaceBannedPubkeyItems = (url: string) => {
const store = writable(spaceBannedPubkeyItems.get(url) || [])
manageRelay(url, {method: ManagementMethod.ListBannedPubkeys, params: []}).then(res => {
spaceBannedPubkeyItems.set(url, res.result)
store.set(res.result)
})
return store
}
export const deriveRoomMembers = (url: string, h: string) => {
const filters: Filter[] = [
{kinds: [ROOM_MEMBERS], "#d": [h]},
{kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]},
]
return derived(deriveEventsForUrl(url, filters), $events => {
const membersEvent = find(spec({kind: ROOM_MEMBERS}), $events)
if (membersEvent) {
return uniq(getPubkeyTagValues(membersEvent.tags))
}
const members = new Set<string>()
for (const event of sortEventsAsc($events)) {
const pubkeys = getPubkeyTagValues(event.tags)
if (event.kind === ROOM_ADD_MEMBER) {
for (const pubkey of pubkeys) {
members.add(pubkey)
}
}
if (event.kind === ROOM_REMOVE_MEMBER) {
for (const pubkey of pubkeys) {
members.delete(pubkey)
}
}
}
return Array.from(members)
})
}
export const deriveRoomAdmins = (url: string, h: string) => {
const filters: Filter[] = [{kinds: [ROOM_ADMINS], "#d": [h]}]
return derived(deriveEventsForUrl(url, filters), $events => {
const adminsEvent = first($events)
if (adminsEvent) {
return getPubkeyTagValues(adminsEvent.tags)
}
return []
})
}
// Action items (admin review queue)
// const pendingJoins: TrustedEvent[] = []
export const deriveSpaceActionItems = (url: string) =>
derived(
deriveEventsForUrl(url, [
{
kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS],
},
]),
$events => {
const getRoomId = (e: TrustedEvent) =>
getTagValue(e.kind === ROOM_MEMBERS ? "d" : "h", e.tags)
const reports = $events.filter(e => e.kind === REPORT)
const pendingJoins: TrustedEvent[] = []
// Room-level join requests — most recent per pubkey+h
for (const [h, roomEvents] of groupBy(getRoomId, $events)) {
if (!h) continue
const roomJoins = roomEvents.filter(spec({kind: ROOM_JOIN}))
const roomLeaves = roomEvents.filter(spec({kind: ROOM_LEAVE}))
const roomMembersEvent = roomEvents.find(spec({kind: ROOM_MEMBERS}))
const roomMembers = getTagValues("p", roomMembersEvent?.tags ?? [])
pendingJoins.push(
...removeUndefined(
Array.from(groupBy(e => e.pubkey, roomJoins).values())
.map(sortEventsDesc)
.map(first),
).filter(({pubkey, created_at}) => {
if (roomMembers.includes(pubkey)) return false
if (gt(roomMembersEvent?.created_at, created_at)) return false
if (roomLeaves.some(e => e.pubkey === pubkey && e.created_at > created_at)) return false
return true
}),
)
}
return sortEventsDesc([...reports, ...pendingJoins])
},
)
// User membership status
export enum MembershipStatus {
Initial,
Pending,
Granted,
}
export const deriveUserIsSpaceAdmin = memoize((url?: string) => {
const store = writable(false)
if (url) {
manageRelay(url, {method: ManagementMethod.SupportedMethods, params: []}).then(res =>
store.set(Boolean(res.result?.length)),
)
}
return store
})
export const deriveUserSpaceMembershipStatus = (url: string) => {
const filters: Filter[] = [{kinds: [RELAY_JOIN, RELAY_LEAVE]}]
return derived(
[
pubkey,
deriveSpaceMembers(url),
deriveEventsForUrl(url, filters),
deriveUserIsSpaceAdmin(url),
],
([$pubkey, $members, $events, $isAdmin]) => {
const isMember = $members.includes($pubkey!) || $isAdmin
for (const event of $events) {
if (event.pubkey !== $pubkey) {
continue
}
if (event.kind === RELAY_JOIN) {
return isMember ? MembershipStatus.Granted : MembershipStatus.Pending
}
if (event.kind === RELAY_LEAVE) {
return MembershipStatus.Initial
}
}
return isMember ? MembershipStatus.Granted : MembershipStatus.Initial
},
)
}
export const deriveUserIsRoomAdmin = (url: string, h: string) =>
derived(
[pubkey, deriveRoomAdmins(url, h), deriveUserIsSpaceAdmin(url)],
([$pubkey, $admins, $isSpaceAdmin]) => $isSpaceAdmin || $admins.includes($pubkey!),
)
export const deriveUserRoomMembershipStatus = (url: string, h: string) => {
const filters: Filter[] = [{kinds: [ROOM_JOIN, ROOM_LEAVE], "#h": [h]}]
return derived(
[
pubkey,
deriveRoomMembers(url, h),
deriveEventsForUrl(url, filters),
deriveUserIsRoomAdmin(url, h),
],
([$pubkey, $members, $events, $isAdmin]) => {
const isMember = $members.includes($pubkey!) || $isAdmin
for (const event of $events) {
if (event.pubkey !== $pubkey) {
continue
}
if (event.kind === ROOM_JOIN) {
return isMember ? MembershipStatus.Granted : MembershipStatus.Pending
}
if (event.kind === ROOM_LEAVE) {
return MembershipStatus.Initial
}
}
return isMember ? MembershipStatus.Granted : MembershipStatus.Initial
},
)
}
export const deriveUserCanCreateRoom = (url: string) => {
const filters: Filter[] = [{kinds: [ROOM_CREATE_PERMISSION]}]
return derived(
[pubkey, deriveEventsForUrl(url, filters), deriveUserIsSpaceAdmin(url)],
([$pubkey, $events, $isAdmin]) => {
for (const event of $events) {
if (getPubkeyTagValues(event.tags).includes($pubkey!)) {
return true
}
}
return $isAdmin
},
)
}
// Feeds
export const feedsByAddress = deriveItemsByKey({
repository,
getKey: feed => getAddress(feed.event),
filters: [{kinds: [FEED]}],
eventToItem: readFeed,
})
export const getFeedsByAddress = getter(feedsByAddress)
export const feeds = deriveItems(feedsByAddress)
export const getFeeds = getter(feeds)
export const getFeed = (address: string) => getFeedsByAddress().get(address)
export const fetchFeed = (address: string) => {
const {pubkey} = Address.from(address)
return load({
relays: Router.get().FromPubkey(pubkey).getUrls(),
filters: getIdFilters([address]),
})
}
export const loadFeed = makeLoadItem(fetchFeed, getFeed)
export const deriveFeed = makeDeriveItem(feedsByAddress, loadFeed)
// Feeds by pubkey
export const feedsByPubkey = derived(feeds, $feeds => groupBy(f => f.event.pubkey, $feeds))
export const getFeedsByPubkey = getter(feedsByPubkey)
export const getFeedsForPubkey = (pubkey: string) => getFeedsByPubkey().get(pubkey)
export const loadFeedsForPubkey = makeLoadItem(makeOutboxLoader(FEED), getFeedsForPubkey)
export const userFeeds = makeUserData(feedsByPubkey, loadFeedsForPubkey)
export const loadUserFeeds = makeUserLoader(loadFeedsForPubkey)
// Feed favorites
export const feedFavoritesByPubkey = deriveItemsByKey<PublishedList>({
repository,
getKey: list => list.event.pubkey,
filters: [{kinds: [FEEDS]}],
eventToItem: async event =>
readList(
asDecryptedEvent(event, {
content: await ensurePlaintext(event),
}),
),
})
export const getFeedFavoritesByPubkey = getter(feedFavoritesByPubkey)
export const getFeedFavorites = (pubkey: string) => getFeedFavoritesByPubkey().get(pubkey)
export const loadFeedFavorites = makeLoadItem(makeOutboxLoader(FEEDS), getFeedFavorites)
export const userFeedFavorites = makeUserData(feedFavoritesByPubkey, loadFeedFavorites)
export const loadUserFeedFavorites = makeUserLoader(loadFeedFavorites)
// Mutes
export const isEventMuted = withGetter(
derived(userMuteList, $userMuteList => {
const pubkey = $userMuteList?.event.pubkey
const tags = getListTags($userMuteList)
const mutedEvents = new Set(getEventTagValues(tags))
const mutedPubkeys = new Set(getPubkeyTagValues(tags))
const mutedAddresses = new Set(getAddressTagValues(tags))
const mutedTopics = new Set(getTagValues("t", tags))
const mutedWords = getTagValues("word", tags)
const regex =
mutedWords.length > 0
? new RegExp(`\\b(${mutedWords.map(w => w.toLowerCase().trim()).join("|")})\\b`)
: null
return (e: TrustedEvent) => {
if (!pubkey) return false
if (pubkey === e.pubkey) return false
if (mutedPubkeys.has(e.pubkey)) return true
if (mutedEvents.has(e.id)) return true
if (mutedAddresses.has(getAddress(e))) return true
if (getParentIds(e).some(id => mutedEvents.has(id))) return true
if (getParentAddrs(e).some(address => mutedAddresses.has(address))) return true
if (getTagValues("t", e.tags).some(t => mutedTopics.has(t))) return true
if (regex) {
if (e.content?.toLowerCase().match(regex)) return true
if (displayProfileByPubkey(e.pubkey).toLowerCase().match(regex)) return true
if (tryCatch(() => getProfile(e.pubkey)?.nip05?.match(regex))) return true
}
return false
}
}),
)
// 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) => {
const socket = Pool.get().get(url)
return readable(socket, set => {
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)
})
}
export const deriveSocketStatus = (url: string) =>
throttled(
800,
derived([deriveSocket(url), relaysMostlyRestricted], ([$socket, $relaysMostlyRestricted]) => {
if ($socket.status === SocketStatus.Opening) {
return {theme: "warning", title: "Connecting"}
}
if ($socket.status === SocketStatus.Closing) {
return {theme: "gray-500", title: "Not Connected"}
}
if ($socket.status === SocketStatus.Closed) {
return {theme: "gray-500", title: "Not Connected"}
}
if ($socket.status === SocketStatus.Error) {
return {theme: "error", title: "Failed to Connect"}
}
if ($socket.auth.status === AuthStatus.Requested) {
return {theme: "warning", title: "Authenticating"}
}
if ($socket.auth.status === AuthStatus.PendingSignature) {
return {theme: "warning", title: "Authenticating"}
}
if ($socket.auth.status === AuthStatus.DeniedSignature) {
return {theme: "error", title: "Failed to Authenticate"}
}
if ($socket.auth.status === AuthStatus.PendingResponse) {
return {theme: "warning", title: "Authenticating"}
}
if ($socket.auth.status === AuthStatus.Forbidden) {
return {theme: "error", title: "Access Denied"}
}
if ($relaysMostlyRestricted[url]) {
return {theme: "error", title: "Access Denied"}
}
return {theme: "success", title: "Connected"}
}),
)
export const deriveSupportedMethods = simpleCache(([url]: [string]) => {
return readable<ManagementMethod[]>([], set => {
manageRelay(url, {
method: ManagementMethod.SupportedMethods,
params: [],
}).then(({result = []}) => set(result))
})
})
export const deriveHasLivekit = simpleCache(([url]: [string]) =>
readable<boolean | undefined>(undefined, set => {
checkRelayHasLivekit(url).then(has => set(has))
}),
)
export const deriveTimeout = (timeout: number) => {
const store = writable<boolean>(false)
setTimeout(() => store.set(true), timeout)
return derived(store, identity)
}
export const shouldIgnoreError = (error: string) => {
const isIgnored = error.startsWith("mute: ")
const isAborted = error.includes("Signing was aborted")
const isStrictNip29Relay = error.includes("missing group (`h`) tag")
return isIgnored || isAborted || isStrictNip29Relay
}
export const stripPrefix = (m: string) => m.replace(/^\w+: /, "")
export type InviteData = {url: string; claim: string}
export const parseInviteLink = (invite: string): InviteData | undefined => {
if (invite.length < 3 || !invite.includes(".")) {
return
}
return (
tryCatch(() => {
const {r: relay = "", c: claim = ""} = fromPairs(Array.from(new URL(invite).searchParams))
const url = normalizeRelayUrl(relay)
if (isRelayUrl(url)) {
return {url, claim}
}
}) ||
tryCatch(() => {
const url = normalizeRelayUrl(invite)
if (isRelayUrl(url)) {
return {url, claim: ""}
}
})
)
}
// Hierarchical notification helpers
export const getShouldNotify = ({alerts}: SettingsValues, url: string, h?: string) => {
const pref = alerts.find(spec({url}))
if (!pref) return true
if (!h) return pref.notify
if (pref.notify) return !pref.exceptions.includes(h)
if (!pref.notify) return pref.exceptions.includes(h)
}
export const shouldNotify = (url: string, h?: string) => getShouldNotify(getSettings(), url, h)
export const deriveShouldNotify = (url: string, h?: string) =>
derived(userSettingsValues, $settings => getShouldNotify($settings, url, h))
// Whatever who cares
export const hasNip50 = (relay?: RelayProfile) =>
Boolean(relay?.supported_nips?.map?.(String)?.includes?.("50"))