forked from coracle/flotilla
16a73f27c9
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.  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>
1323 lines
35 KiB
TypeScript
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"))
|