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, sort, uniq, indexBy, partition, shuffle, parseJson, memoize, addToMapKey, identity, always, randomId, tryCatch, fromPairs, groupBy, remove, } 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, asDecryptedEvent, getGroupTags, getListTags, getPubkeyTagValues, getRelayTagValues, getTagValues, isRelayUrl, normalizeRelayUrl, readList, verifyEvent, readRoomMeta, makeRoomMeta, ManagementMethod, sortEventsDesc, getAddress, Address, getIdFilters, } from "@welshman/util" import type { TrustedEvent, RelayProfile, PublishedList, PublishedRoomMeta, List, Filter, } from "@welshman/util" import {routerContext, Router} from "@welshman/router" import { pubkey, repository, tracker, createSearch, userFollowList, ensurePlaintext, makeOutboxLoader, appContext, deriveRelay, makeUserData, makeUserLoader, manageRelay, displayProfileByPubkey, } from "@welshman/app" 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, // khatru doesn't support relay.self, uncomment when it's ready // filter(spec({pubkey: relay.self}), events) ) // Context pomadeContext.setSignerPubkeys(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("trusted_relays").includes(url) || verifyEvent(event) // Filters export const makeCommentFilter = (kinds: number[], extra: Filter = {}) => ({ kinds: [COMMENT], "#K": kinds.map(String), ...extra, }) 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 = (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([]) // Relays that mostly send restricted responses to requests and events export const relaysMostlyRestricted = writable>({}) // 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 subscription?: PushSubscription } export const notificationState = withGetter(writable({})) // 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() const chatsByPubkey = new Map() const addSearchText = (chat: Override) => { 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 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) for (const pubkey of pubkeys) { const pubkeyChats = chatsByPubkey.get(pubkey) || [] const uniqueChats = uniqBy(chat => chat.id, append(updatedChat, pubkeyChats)) chatsByPubkey.set(pubkey, uniqueChats) } dirty = true } if (event.kind === PROFILE) { for (const chat of chatsByPubkey.get(event.pubkey) || []) { addSearchText(chat) dirty = true } } } if (dirty) { set(chatsById) } } addEvents(repository.query([{kinds: [...DM_KINDS, PROFILE]}])) const unsubscribers = [ on(repository, "update", ({added}: RepositoryUpdate) => addEvents(added)), ] 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 type Room = PublishedRoomMeta & { id: string url: string } export const makeRoomId = (url: string, h: string) => `${url}'${h}` export const splitRoomId = (id: string) => id.split("'") export const hasNip29 = (relay?: RelayProfile) => 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>() for (const [url, events] of roomMetaEventsByIdByUrl.entries()) { const [metaEvents, deleteEvents] = partition(spec({kind: ROOM_META}), events.values()) const deletedByH = new Map() 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() 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})}, ) }) export const displayRoom = (url: string, h: string) => getRoom(makeRoomId(url, h))?.name || h export const roomComparator = (url: string) => (h: string) => displayRoom(url, h).toLowerCase() // 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>() 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), roomsByUrl], ([$userRooms, $roomsByUrl]) => { const rooms: string[] = [] for (const {h} of $roomsByUrl.get(url) || []) { if (!$userRooms.includes(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() 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() 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() for (const event of sortBy(e => -e.created_at, $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 [] }) } // 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({ 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) // 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 deriveTimeout = (timeout: number) => { const store = writable(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))