diff --git a/src/app/components/AlertAdd.svelte b/src/app/components/AlertAdd.svelte index 51717ca..b8f9210 100644 --- a/src/app/components/AlertAdd.svelte +++ b/src/app/components/AlertAdd.svelte @@ -13,7 +13,7 @@ import Spinner from "@lib/components/Spinner.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte" - import {alerts, getMembershipUrls, userMembership} from "@app/core/state" + import {alerts, userSpaceUrls} from "@app/core/state" import {requestRelayClaim} from "@app/core/requests" import {createAlert} from "@app/core/commands" import {canSendPushNotifications} from "@app/util/push" @@ -174,7 +174,7 @@ {#snippet input()} diff --git a/src/app/components/ChannelItemAddMember.svelte b/src/app/components/ChannelItemAddMember.svelte new file mode 100644 index 0000000..8fe4dd6 --- /dev/null +++ b/src/app/components/ChannelItemAddMember.svelte @@ -0,0 +1,18 @@ + + +{#each getPubkeyTagValues(event.tags) as pubkey} +
+ joined the room +
+{/each} diff --git a/src/app/components/ChannelItemRemoveMember.svelte b/src/app/components/ChannelItemRemoveMember.svelte new file mode 100644 index 0000000..bf2c3b3 --- /dev/null +++ b/src/app/components/ChannelItemRemoveMember.svelte @@ -0,0 +1,18 @@ + + +{#each getPubkeyTagValues(event.tags) as pubkey} +
+ left the room +
+{/each} diff --git a/src/app/components/MenuSpace.svelte b/src/app/components/MenuSpace.svelte index eb79053..ab11e2e 100644 --- a/src/app/components/MenuSpace.svelte +++ b/src/app/components/MenuSpace.svelte @@ -39,9 +39,7 @@ import { ENABLE_ZAPS, MESSAGE_FILTER, - userRoomsByUrl, - hasMembershipUrl, - memberships, + deriveSpaceMembers, deriveEventsForUrl, deriveUserRooms, deriveOtherRooms, @@ -62,6 +60,7 @@ const calendarPath = makeSpacePath(url, "calendar") const userRooms = deriveUserRooms(url) const otherRooms = deriveOtherRooms(url) + const members = deriveSpaceMembers(url) const owner = $derived($relay?.profile?.pubkey) const hasAlerts = $derived($alerts.some(a => getTagValue("feed", a.tags)?.includes(url))) @@ -83,7 +82,7 @@ const showMembers = () => pushModal( ProfileList, - {url, pubkeys: members, title: `Members of`, subtitle: displayRelayUrl(url)}, + {url, pubkeys: $members, title: `Members of`, subtitle: displayRelayUrl(url)}, {replaceState}, ) @@ -108,10 +107,6 @@ let replaceState = $state(false) let element: Element | undefined = $state() - const members = $derived( - $memberships.filter(l => hasMembershipUrl(l, url)).map(l => l.event.pubkey), - ) - onMount(() => { replaceState = Boolean(element?.closest(".drawer")) }) @@ -151,7 +146,7 @@
  • {#if owner} @@ -163,7 +158,7 @@ {/if}
  • - {#if $userRoomsByUrl.has(url)} + {#if $userRooms.includes(url)} {/if} - {#if relays.length > 0} + {#if spaceUrls.length > 0} {/if} diff --git a/src/app/components/ProfileDelete.svelte b/src/app/components/ProfileDelete.svelte index 2913662..b1c1cd3 100644 --- a/src/app/components/ProfileDelete.svelte +++ b/src/app/components/ProfileDelete.svelte @@ -19,13 +19,7 @@ import ModalFooter from "@lib/components/ModalFooter.svelte" import {pushToast} from "@app/util/toast" import {logout} from "@app/core/commands" - import { - INDEXER_RELAYS, - PLATFORM_NAME, - userMembership, - getMembershipUrls, - userWriteRelays, - } from "@app/core/state" + import {INDEXER_RELAYS, PLATFORM_NAME, userSpaceUrls, userWriteRelays} from "@app/core/state" let progress: number | undefined = $state(undefined) let confirmText = $state("") @@ -46,11 +40,7 @@ const profileEvent = makeEvent(PROFILE, createProfile({name: "[deleted]"})) const vanishEvent = makeEvent(62, {tags: [["relay", "ALL_RELAYS"]]}) const denominator = chunks.length + 2 - const relays = uniq([ - ...INDEXER_RELAYS, - ...$userWriteRelays, - ...getMembershipUrls($userMembership), - ]) + const relays = uniq([...INDEXER_RELAYS, ...$userWriteRelays, ...$userSpaceUrls]) let step = 0 diff --git a/src/app/components/ProfileSpaces.svelte b/src/app/components/ProfileSpaces.svelte index 73e42ac..6eb93d7 100644 --- a/src/app/components/ProfileSpaces.svelte +++ b/src/app/components/ProfileSpaces.svelte @@ -8,7 +8,7 @@ import SpaceAvatar from "@app/components/SpaceAvatar.svelte" import RelayName from "@app/components/RelayName.svelte" import {makeSpacePath} from "@app/util/routes" - import {getMembershipUrls, membershipsByPubkey} from "@app/core/state" + import {deriveGroupSelections, getSpaceUrlsFromGroupSelections} from "@app/core/state" type Props = { pubkey: string @@ -16,7 +16,8 @@ const {pubkey}: Props = $props() - const spaceUrls = $derived(getMembershipUrls($membershipsByPubkey.get(pubkey))) + const selections = deriveGroupSelections(pubkey) + const spaceUrls = $derived(getSpaceUrlsFromGroupSelections($selections)) const back = () => history.back() diff --git a/src/app/components/RelaySummary.svelte b/src/app/components/RelaySummary.svelte index 822f917..2a2b3cb 100644 --- a/src/app/components/RelaySummary.svelte +++ b/src/app/components/RelaySummary.svelte @@ -1,5 +1,4 @@
    @@ -31,7 +32,7 @@ {/if}
    - {#if $userRoomsByUrl.has(url)} + {#if $rooms.includes(url)}
    @@ -48,10 +49,10 @@
    - {#if gt($membersByUrl.get(url)?.size, 0)} + {#if $members.length > 0}
    Members: - +
    {/if} diff --git a/src/app/components/SpaceInvite.svelte b/src/app/components/SpaceInvite.svelte index b6b4290..c37c98e 100644 --- a/src/app/components/SpaceInvite.svelte +++ b/src/app/components/SpaceInvite.svelte @@ -2,7 +2,7 @@ import {onMount} from "svelte" import {sleep, nthEq} from "@welshman/lib" import {request} from "@welshman/net" - import {displayRelayUrl, AUTH_INVITE} from "@welshman/util" + import {displayRelayUrl, RELAY_INVITE} from "@welshman/util" import LinkRound from "@assets/icons/link-round.svg?dataurl" import Copy from "@assets/icons/copy.svg?dataurl" import Spinner from "@lib/components/Spinner.svelte" @@ -38,7 +38,7 @@ request({ relays: [url], autoClose: true, - filters: [{kinds: [AUTH_INVITE]}], + filters: [{kinds: [RELAY_INVITE]}], }), sleep(2000), ]) @@ -83,7 +83,7 @@ This invite link can be used by clicking "Add Space" and pasting it there. {#if !claim} This space did not issue a claim for this link, so additional steps might be - required for people using this invite link. + required. {/if}

    {/snippet} diff --git a/src/app/core/commands.ts b/src/app/core/commands.ts index 12ec2c6..35c1296 100644 --- a/src/app/core/commands.ts +++ b/src/app/core/commands.ts @@ -35,7 +35,7 @@ import { RELAYS, FOLLOWS, REACTION, - AUTH_JOIN, + RELAY_JOIN, ROOMS, COMMENT, ALERT_EMAIL, @@ -97,15 +97,14 @@ import type {SettingsValues, Alert} from "@app/core/state" import { SETTINGS, PROTECTED, - userMembership, INDEXER_RELAYS, NOTIFIER_PUBKEY, NOTIFIER_RELAY, DEFAULT_BLOSSOM_SERVERS, - userRoomsByUrl, + userSpaceUrls, userSettingsValues, userInboxRelays, - getMembershipUrls, + userGroupSelections, } from "@app/core/state" import {loadAlertStatuses} from "@app/core/requests" import {platform, platformName, getPushInfo} from "@app/util/push" @@ -175,7 +174,7 @@ export const broadcastUserData = async (relays: string[]) => { // List updates export const addSpaceMembership = async (url: string) => { - const list = get(userMembership) || makeList({kind: ROOMS}) + const list = get(userGroupSelections) || makeList({kind: ROOMS}) const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf) const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) @@ -183,7 +182,7 @@ export const addSpaceMembership = async (url: string) => { } export const removeSpaceMembership = async (url: string) => { - const list = get(userMembership) || makeList({kind: ROOMS}) + const list = get(userGroupSelections) || makeList({kind: ROOMS}) const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf) const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) @@ -192,7 +191,7 @@ export const removeSpaceMembership = async (url: string) => { } export const addRoomMembership = async (url: string, room: string) => { - const list = get(userMembership) || makeList({kind: ROOMS}) + const list = get(userGroupSelections) || makeList({kind: ROOMS}) const newTags = [ ["r", url], ["group", room, url], @@ -204,7 +203,7 @@ export const addRoomMembership = async (url: string, room: string) => { } export const removeRoomMembership = async (url: string, room: string) => { - const list = get(userMembership) || makeList({kind: ROOMS}) + const list = get(userGroupSelections) || makeList({kind: ROOMS}) const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3)) const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf) const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) @@ -226,12 +225,7 @@ export const setRelayPolicy = (url: string, read: boolean, write: boolean) => { return publishThunk({ event: makeEvent(list.kind, {tags}), - relays: [ - url, - ...INDEXER_RELAYS, - ...Router.get().FromUser().getUrls(), - ...userRoomsByUrl.get().keys(), - ], + relays: [url, ...INDEXER_RELAYS, ...Router.get().FromUser().getUrls(), ...get(userSpaceUrls)], }) } @@ -248,11 +242,7 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => { return publishThunk({ event: makeEvent(list.kind, {tags}), - relays: [ - ...INDEXER_RELAYS, - ...Router.get().FromUser().getUrls(), - ...userRoomsByUrl.get().keys(), - ], + relays: [...INDEXER_RELAYS, ...Router.get().FromUser().getUrls(), ...get(userSpaceUrls)], }) } } @@ -632,7 +622,7 @@ export type JoinRequestParams = { } export const makeJoinRequest = (params: JoinRequestParams) => - makeEvent(AUTH_JOIN, {tags: [["claim", params.claim]]}) + makeEvent(RELAY_JOIN, {tags: [["claim", params.claim]]}) export const publishJoinRequest = (params: JoinRequestParams) => publishThunk({event: makeJoinRequest(params), relays: [params.url]}) @@ -781,7 +771,7 @@ export const updateProfile = async ({ }) => { const router = Router.get() const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile) - const scenarios = [router.FromRelays(getMembershipUrls(userMembership.get()))] + const scenarios = [router.FromRelays(get(userSpaceUrls))] if (shouldBroadcast) { scenarios.push(router.FromUser(), router.Index()) diff --git a/src/app/core/requests.ts b/src/app/core/requests.ts index 32c4859..2cef795 100644 --- a/src/app/core/requests.ts +++ b/src/app/core/requests.ts @@ -14,7 +14,7 @@ import { } from "@welshman/lib" import { EVENT_TIME, - AUTH_INVITE, + RELAY_INVITE, ALERT_EMAIL, ALERT_WEB, ALERT_IOS, @@ -29,7 +29,7 @@ import { import type {TrustedEvent, Filter, List} from "@welshman/util" import {feedFromFilters, makeRelayFeed, makeIntersectionFeed} from "@welshman/feeds" import {load, request} from "@welshman/net" -import {repository, makeFeedController, loadRelay} from "@welshman/app" +import {repository, makeFeedController, loadRelay, tracker} from "@welshman/app" import {createScroller} from "@lib/html" import {daysBetween} from "@lib/util" import {NOTIFIER_RELAY, getEventsForUrl} from "@app/core/state" @@ -93,7 +93,7 @@ export const makeFeed = ({ } for (const event of added) { - if (matchFilters(filters, event)) { + if (matchFilters(filters, event) && tracker.getRelays(event.id).has(url)) { insertEvent(event) } } @@ -267,7 +267,7 @@ export const discoverRelays = (lists: List[]) => ) export const requestRelayClaim = async (url: string) => { - const filters = [{kinds: [AUTH_INVITE], limit: 1}] + const filters = [{kinds: [RELAY_INVITE], limit: 1}] const events = await load({filters, relays: [url]}) if (events.length > 0) { diff --git a/src/app/core/state.ts b/src/app/core/state.ts index cb490bc..7ccaf1a 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -4,7 +4,9 @@ import {get, derived, writable} from "svelte/store" import * as nip19 from "nostr-tools/nip19" import { on, + spec, call, + first, assoc, remove, uniqBy, @@ -12,9 +14,7 @@ import { sort, prop, uniq, - nth, pushToMapKey, - nthEq, shuffle, parseJson, fromPairs, @@ -24,7 +24,6 @@ import { groupBy, always, tryCatch, - last, } from "@welshman/lib" import type {Socket} from "@welshman/net" import { @@ -46,49 +45,56 @@ import { } from "@welshman/store" import {isKindFeed, findFeed} from "@welshman/feeds" import { - getIdFilters, - WRAP, - DELETE, + ALERT_ANDROID, + ALERT_EMAIL, + ALERT_IOS, + ALERT_STATUS, + ALERT_WEB, + APP_DATA, CLIENT_AUTH, - AUTH_JOIN, - REACTION, - ZAP_REQUEST, - ZAP_RESPONSE, - DIRECT_MESSAGE, + COMMENT, + DELETE, DIRECT_MESSAGE_FILE, - ROOM_META, + 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_META, + ROOM_REMOVE_MEMBER, ROOMS, THREAD, - COMMENT, - REPORT, - ROOM_JOIN, - ROOM_ADD_USER, - ROOM_REMOVE_USER, - ROOM_CREATE_PERMISSION, - ALERT_EMAIL, - ALERT_WEB, - ALERT_IOS, - ALERT_ANDROID, - ALERT_STATUS, - APP_DATA, + WRAP, ZAP_GOAL, - EVENT_TIME, - getGroupTags, - getRelayTagValues, - getPubkeyTagValues, - displayProfile, - readList, - getListTags, + ZAP_REQUEST, + ZAP_RESPONSE, asDecryptedEvent, - normalizeRelayUrl, + displayProfile, + getGroupTags, + getIdFilters, + getListTags, + getPubkeyTagValues, + getRelaysFromList, + getRelayTagValues, getTag, getTagValue, getTagValues, - verifyEvent, + isRelayUrl, makeEvent, + normalizeRelayUrl, + readList, RelayMode, - getRelaysFromList, + verifyEvent, } from "@welshman/util" import type {TrustedEvent, PublishedList, List, Filter} from "@welshman/util" import {decrypt} from "@welshman/signer" @@ -111,6 +117,9 @@ import { publishThunk, userRelaySelections, userInboxRelaySelections, + deriveRelay, + makeUserData, + makeUserLoader, } from "@welshman/app" import type {Thunk, Relay} from "@welshman/app" @@ -158,7 +167,7 @@ export const DUFFLEPUD_URL = "https://dufflepud.onrender.com" export const NIP46_PERMS = "nip44_encrypt,nip44_decrypt," + - [CLIENT_AUTH, AUTH_JOIN, MESSAGE, THREAD, COMMENT, ROOMS, WRAP, REACTION, ZAP_REQUEST] + [CLIENT_AUTH, RELAY_JOIN, MESSAGE, THREAD, COMMENT, ROOMS, WRAP, REACTION, ZAP_REQUEST] .map(k => `sign_event:${k}`) .join(",") @@ -191,7 +200,7 @@ 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 defaultPubkeys = derived(userFollows, $userFollows => { +export const bootstrapPubkeys = derived(userFollows, $userFollows => { const appPubkeys = DEFAULT_PUBKEYS.split(",") const userPubkeys = shuffle(getPubkeyTagValues(getListTags($userFollows))) @@ -221,29 +230,31 @@ export const deriveEvent = (idOrAddress: string, hints: string[] = []) => { ) } -export const getUrlsForEvent = derived([trackerStore, thunks], ([$tracker, $thunks]) => { - const getThunksByEventId = memoize(() => { - const thunksByEventId = new Map() +export const getUrlsForEvent = withGetter( + derived([trackerStore, thunks], ([$tracker, $thunks]) => { + const getThunksByEventId = memoize(() => { + const thunksByEventId = new Map() - for (const thunk of $thunks) { - pushToMapKey(thunksByEventId, thunk.event.id, thunk) - } - - return thunksByEventId - }) - - return (id: string) => { - const urls = Array.from($tracker.getRelays(id)) - - for (const thunk of getThunksByEventId().get(id) || []) { - for (const url of thunk.options.relays) { - urls.push(url) + for (const thunk of $thunks) { + pushToMapKey(thunksByEventId, thunk.event.id, thunk) } - } - return uniq(urls) - } -}) + return thunksByEventId + }) + + return (id: string) => { + const urls = Array.from($tracker.getRelays(id)) + + for (const thunk of getThunksByEventId().get(id) || []) { + for (const url of thunk.options.relays) { + urls.push(url) + } + } + + return uniq(urls) + } + }), +) export const getEventsForUrl = (url: string, filters: Filter[]) => { const ids = uniq([ @@ -266,6 +277,11 @@ export const deriveEventsForUrl = (url: string, filters: Filter[]) => return repository.query(filters.map(assoc("ids", ids))) }) +export const deriveSignedEventsForUrl = (url: string, filters: Filter[]) => + derived([deriveEventsForUrl(url, filters), deriveRelay(url)], ([$events, $relay]) => + $relay?.profile ? $events.filter(spec({pubkey: $relay.profile.self})) : [], + ) + // Context appContext.dufflepudUrl = DUFFLEPUD_URL @@ -295,6 +311,15 @@ export const MESSAGE_FILTER = {kinds: MESSAGE_KINDS} export const COMMENT_FILTER = makeCommentFilter(MESSAGE_KINDS) +export const MEMBERSHIP_KINDS = [ + ROOM_ADD_MEMBER, + ROOM_REMOVE_MEMBER, + RELAY_ADD_MEMBER, + RELAY_REMOVE_MEMBER, +] + +export const MEMBERSHIP_FILTER = {kinds: MEMBERSHIP_KINDS} + // Settings export const SETTINGS = "flotilla/settings" @@ -348,6 +373,19 @@ export const { load: makeOutboxLoader(APP_DATA, {"#d": [SETTINGS]}), }) +export const userSettings = makeUserData({ + mapStore: settingsByPubkey, + loadItem: loadSettings, +}) + +export const loadUserSettings = makeUserLoader(loadSettings) + +export const userSettingsValues = withGetter( + derived(userSettings, $s => $s?.values || defaultSettings), +) + +export const getSetting = (key: keyof Settings["values"]) => userSettingsValues.get()[key] as T + // Relays sending events with empty signatures that the user has to choose to trust export const relaysPendingTrust = withGetter(writable([])) @@ -428,64 +466,6 @@ export const alertStatuses = withGetter( export const deriveAlertStatus = (address: string) => derived(alertStatuses, statuses => statuses.find(s => getTagValue("d", s.event.tags) === address)) -// Membership - -export const hasMembershipUrl = (list: List | undefined, url: string) => - getListTags(list).some(t => { - if (t[0] === "r") return t[1] === url - if (t[0] === "group") return t[2] === url - - return false - }) - -export const getMembershipUrls = (list?: List) => { - const tags = getListTags(list) - - return sort( - uniq([...getRelayTagValues(tags), ...getGroupTags(tags).map(nth(2))]).map(url => - normalizeRelayUrl(url), - ), - ) -} - -export const getMembershipRooms = (list?: List) => - getGroupTags(getListTags(list)).map(([_, room, url, name = ""]) => ({url, room, name})) - -export const getMembershipRoomsByUrl = (url: string, list?: List) => - sort(getGroupTags(getListTags(list)).filter(nthEq(2, url)).map(nth(1))) - -export const memberships = deriveEventsMapped(repository, { - filters: [{kinds: [ROOMS]}], - itemToEvent: item => item.event, - eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), -}) - -export const { - indexStore: membershipsByPubkey, - deriveItem: deriveMembership, - loadItem: loadMembership, -} = collection({ - name: "memberships", - store: memberships, - getKey: list => list.event.pubkey, - load: makeOutboxLoader(ROOMS), -}) - -export const membersByUrl = derived( - [defaultPubkeys, membershipsByPubkey], - ([$defaultPubkeys, $membershipsByPubkey]) => { - const $membersByUrl = new Map>() - - for (const pubkey of $defaultPubkeys) { - for (const url of getMembershipUrls($membershipsByPubkey.get(pubkey))) { - addToMapKey($membersByUrl, url, pubkey) - } - } - - return $membersByUrl - }, -) - // Chats export const chatMessages = deriveEvents(repository, { @@ -556,12 +536,10 @@ export const chatSearch = derived(chats, $chats => }), ) -// Messages +// Channels export const messages = deriveEvents(repository, {filters: [{kinds: [MESSAGE]}]}) -// Channels - export type Channel = { id: string url: string @@ -645,98 +623,243 @@ export const displayChannel = (url: string, room: string) => export const roomComparator = (url: string) => (room: string) => displayChannel(url, room).toLowerCase() -// User stuff +// User space/room selections -export const userSettings = withGetter( - derived([pubkey, settingsByPubkey], ([$pubkey, $settingsByPubkey]) => { - if (!$pubkey) return undefined +export const groupSelections = deriveEventsMapped(repository, { + filters: [{kinds: [ROOMS]}], + itemToEvent: item => item.event, + eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), +}) - loadSettings($pubkey) +export const { + indexStore: groupSelectionsByPubkey, + deriveItem: deriveGroupSelections, + loadItem: loadGroupSelections, +} = collection({ + name: "groupSelections", + store: groupSelections, + getKey: list => list.event.pubkey, + load: makeOutboxLoader(ROOMS), +}) - return $settingsByPubkey.get($pubkey) - }), -) +export const groupSelectionsPubkeysByUrl = derived(groupSelections, $groupSelections => { + const result = new Map>() -export const userSettingsValues = withGetter( - derived(userSettings, $s => $s?.values || defaultSettings), -) - -export const getSetting = (key: keyof Settings["values"]) => userSettingsValues.get()[key] as T - -export const userMembership = withGetter( - derived([pubkey, membershipsByPubkey], ([$pubkey, $membershipsByPubkey]) => { - if (!$pubkey) return undefined - - loadMembership($pubkey) - - return $membershipsByPubkey.get($pubkey) - }), -) - -export const userRoomsByUrl = withGetter( - derived([userMembership, channelsById], ([$userMembership, $channelsById]) => { - const tags = getListTags($userMembership) - const $userRoomsByUrl = new Map>() + for (const list of $groupSelections) { + const tags = getListTags(list) for (const url of getRelayTagValues(tags)) { - $userRoomsByUrl.set(normalizeRelayUrl(url), new Set()) + addToMapKey(result, url, list.event.pubkey) } - for (const [_, room, url] of getGroupTags(tags)) { + for (const tag of getGroupTags(tags)) { + const url = tag[2] || "" + + if (isRelayUrl(url)) { + addToMapKey(result, url, list.event.pubkey) + } + } + } + + return result +}) + +export const getSpaceUrlsFromGroupSelections = ($groupSelections: List | undefined) => { + const tags = getListTags($groupSelections) + 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 getSpaceRoomsFromGroupSelections = ( + url: string, + $groupSelections: List | undefined, +) => { + const rooms: string[] = [] + + for (const [_, room, relay] of getGroupTags(getListTags($groupSelections))) { + if (url === relay) { + rooms.push(room) + } + } + + return sortBy(roomComparator(url), rooms) +} + +export const userGroupSelections = makeUserData({ + mapStore: groupSelectionsByPubkey, + loadItem: loadGroupSelections, +}) + +export const loadUserGroupSelections = makeUserLoader(loadGroupSelections) + +export const userSpaceUrls = derived(userGroupSelections, getSpaceUrlsFromGroupSelections) + +export const deriveUserRooms = (url: string) => + derived([userGroupSelections, channelsById], ([$userGroupSelections, $channelsById]) => { + const rooms: string[] = [] + + for (const room of getSpaceRoomsFromGroupSelections(url, $userGroupSelections)) { if ($channelsById.has(makeChannelId(url, room))) { - addToMapKey($userRoomsByUrl, normalizeRelayUrl(url), room) + rooms.push(room) } } - return $userRoomsByUrl - }), -) - -export const deriveUserRooms = (url: string) => - derived(userRoomsByUrl, $userRoomsByUrl => - sortBy(roomComparator(url), uniq(Array.from($userRoomsByUrl.get(url) || []))), - ) + return sortBy(roomComparator(url), rooms) + }) export const deriveOtherRooms = (url: string) => - derived([deriveUserRooms(url), channelsByUrl], ([$userRooms, $channelsByUrl]) => - sortBy( - roomComparator(url), - ($channelsByUrl.get(url) || []).filter(c => !$userRooms.includes(c.room)).map(c => c.room), - ), + derived([deriveUserRooms(url), channelsByUrl], ([$userRooms, $channelsByUrl]) => { + const rooms: string[] = [] + + for (const {room} of $channelsByUrl.get(url) || []) { + if (!$userRooms.includes(room)) { + rooms.push(room) + } + } + + return sortBy(roomComparator(url), rooms) + }) + +// Space/room memberships + +export const deriveSpaceMembers = (url: string) => + derived( + deriveSignedEventsForUrl(url, [ + {kinds: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, RELAY_MEMBERS]}, + ]), + $events => { + const membersEvent = $events.find(spec({kind: RELAY_MEMBERS})) + + if (membersEvent) { + return getTagValues("member", membersEvent.tags) + } + + const members = new Set() + + for (const event of $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 const deriveRoomMembers = (url: string, room: string) => + derived( + deriveEventsForUrl(url, [ + {kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER, ROOM_MEMBERS], "#h": [room]}, + ]), + $events => { + const membersEvent = $events.find(spec({kind: ROOM_MEMBERS})) + + if (membersEvent) { + return getPubkeyTagValues(membersEvent.tags) + } + + const members = new Set() + + for (const event of $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) + }, + ) + +// User membership status + export enum MembershipStatus { Initial, Pending, Granted, } -export const deriveUserMembershipStatus = (url: string, room: string) => +export const deriveUserSpaceMembershipStatus = (url: string) => derived( [ pubkey, - deriveEventsForUrl(url, [ - {kinds: [ROOM_JOIN, ROOM_ADD_USER, ROOM_REMOVE_USER], "#h": [room]}, - ]), + deriveSpaceMembers(url), + deriveEventsForUrl(url, [{kinds: [RELAY_JOIN, RELAY_LEAVE]}]), ], - ([$pubkey, $events]) => { - let status = MembershipStatus.Initial + ([$pubkey, $members, $events]) => { + const isMember = $members.includes($pubkey) for (const event of $events) { - if (event.kind === ROOM_JOIN && event.pubkey === $pubkey) { - status = MembershipStatus.Pending + if (event.pubkey !== $pubkey) { + continue } - if (event.kind === ROOM_REMOVE_USER && getTagValues("p", event.tags).includes($pubkey!)) { - break + if (event.kind === RELAY_JOIN) { + return isMember ? MembershipStatus.Granted : MembershipStatus.Pending } - if (event.kind === ROOM_ADD_USER && getTagValues("p", event.tags).includes($pubkey!)) { - return MembershipStatus.Granted + if (event.kind === RELAY_LEAVE) { + return MembershipStatus.Initial } } - return status + return isMember ? MembershipStatus.Granted : MembershipStatus.Initial + }, + ) + +export const deriveUserRoomMembershipStatus = (url: string, room: string) => + derived( + [ + pubkey, + deriveRoomMembers(url, room), + deriveEventsForUrl(url, [{kinds: [ROOM_JOIN, ROOM_LEAVE], "#h": [room]}]), + ], + ([$pubkey, $members, $events]) => { + const isMember = $members.includes($pubkey) + + 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 }, ) @@ -744,13 +867,9 @@ export const deriveUserCanCreateRoom = (url: string) => derived( [pubkey, deriveEventsForUrl(url, [{kinds: [ROOM_CREATE_PERMISSION]}])], ([$pubkey, $events]) => { - const latest = last($events) + const event = first($events) - if (!latest) { - return true - } - - return getTagValues("p", latest.tags).includes($pubkey!) + return event ? getPubkeyTagValues(event.tags).includes($pubkey!) : true }, ) @@ -853,7 +972,7 @@ export const deriveRelayAuthError = (url: string, claim = "") => { // Attempt to join the relay const thunk = publishThunk({ - event: makeEvent(AUTH_JOIN, {tags: [["claim", claim]]}), + event: makeEvent(RELAY_JOIN, {tags: [["claim", claim]]}), relays: [url], }) diff --git a/src/app/core/sync.ts b/src/app/core/sync.ts index a6f71d5..e5a1fda 100644 --- a/src/app/core/sync.ts +++ b/src/app/core/sync.ts @@ -19,10 +19,9 @@ import { getRelayTagValues, WRAP, ROOM_META, - ROOM_ADD_USER, - ROOM_REMOVE_USER, + ROOM_ADD_MEMBER, + ROOM_REMOVE_MEMBER, isSignedEvent, - normalizeRelayUrl, } from "@welshman/util" import type {Filter, TrustedEvent} from "@welshman/util" import {request, load, pull} from "@welshman/net" @@ -45,13 +44,14 @@ import { import { MESSAGE_FILTER, COMMENT_FILTER, + MEMBERSHIP_FILTER, INDEXER_RELAYS, REACTION_KINDS, loadSettings, - userMembership, - defaultPubkeys, + loadGroupSelections, + userSpaceUrls, + bootstrapPubkeys, decodeRelay, - loadMembership, getUrlsForEvent, } from "@app/core/state" import {loadAlerts, loadAlertStatuses} from "@app/core/requests" @@ -102,15 +102,15 @@ const syncRelays = () => { } }) - const unsubscribeMembership = userMembership.subscribe($l => { - for (const url of getRelayTagValues(getListTags($l))) { + const unsubscribeSpaceUrls = userSpaceUrls.subscribe(urls => { + for (const url of urls) { loadRelay(url) } }) return () => { unsubscribePage() - unsubscribeMembership() + unsubscribeSpaceUrls() } } @@ -131,7 +131,7 @@ const syncUserData = () => { loadAlertStatuses($pubkey) loadBlossomServers($pubkey) loadFollows($pubkey) - loadMembership($pubkey) + loadGroupSelections($pubkey) loadMutes($pubkey) loadProfile($pubkey) loadSettings($pubkey) @@ -139,13 +139,13 @@ const syncUserData = () => { }) const unsubscribeFollows = userFollows.subscribe(async $l => { - for (const pubkeys of chunk(10, get(defaultPubkeys))) { + for (const pubkeys of chunk(10, get(bootstrapPubkeys))) { // This isn't urgent, avoid clogging other stuff up await sleep(1000) for (const pk of pubkeys) { loadRelaySelections(pk).then(() => { - loadMembership(pk) + loadGroupSelections(pk) loadProfile(pk) loadFollows(pk) loadMutes(pk) @@ -177,14 +177,14 @@ const syncMembership = (url: string) => { pullConservatively({ relays: [url], signal: controller.signal, - filters: [MESSAGE_FILTER, COMMENT_FILTER].map(assoc("since", ago(MONTH))), + filters: [MESSAGE_FILTER, COMMENT_FILTER, MEMBERSHIP_FILTER].map(assoc("since", ago(MONTH))), }) // Listen for new events request({ relays: [url], signal: controller.signal, - filters: [MESSAGE_FILTER, COMMENT_FILTER].map(assoc("since", now())), + filters: [MESSAGE_FILTER, COMMENT_FILTER, MEMBERSHIP_FILTER].map(assoc("since", now())), }) return () => controller.abort() @@ -193,9 +193,7 @@ const syncMembership = (url: string) => { const syncMemberships = () => { const unsubscribersByUrl = new Map() - const unsubscribeMembership = userMembership.subscribe($l => { - const urls = getRelayTagValues(getListTags($l)).map(normalizeRelayUrl) - + const unsubscribeSpaceUrls = userSpaceUrls.subscribe(urls => { // stop syncing removed spaces for (const [url, unsubscribe] of unsubscribersByUrl.entries()) { if (!urls.includes(url)) { @@ -214,7 +212,7 @@ const syncMemberships = () => { return () => { Array.from(unsubscribersByUrl.values()).forEach(call) - unsubscribeMembership() + unsubscribeSpaceUrls() } } @@ -231,7 +229,7 @@ const syncSpace = (url: string) => { signal: controller.signal, filters: [ { - kinds: [ROOM_ADD_USER, ROOM_REMOVE_USER], + kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#p": [$pubkey], }, ], @@ -244,7 +242,7 @@ const syncSpace = (url: string) => { signal: controller.signal, filters: [ { - kinds: [ROOM_ADD_USER, ROOM_REMOVE_USER, ...REACTION_KINDS], + kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER, ...REACTION_KINDS], since: now(), }, ], diff --git a/src/app/util/notifications.ts b/src/app/util/notifications.ts index c104f3d..bdc4bd3 100644 --- a/src/app/util/notifications.ts +++ b/src/app/util/notifications.ts @@ -17,9 +17,11 @@ import { chats, hasNip29, getUrlsForEvent, - userRoomsByUrl, repositoryStore, userSettingsValues, + userGroupSelections, + getSpaceUrlsFromGroupSelections, + getSpaceRoomsFromGroupSelections, } from "@app/core/state" import {preferencesStorageProvider} from "@src/lib/storage" import {Badge} from "@capawesome/capacitor-badge" @@ -42,11 +44,19 @@ export const notifications = derived( throttled( 1000, derived( - [pubkey, checked, chats, userRoomsByUrl, repositoryStore, getUrlsForEvent, relaysByUrl], + [pubkey, checked, chats, userGroupSelections, repositoryStore, getUrlsForEvent, relaysByUrl], identity, ), ), - ([$pubkey, $checked, $chats, $userRoomsByUrl, $repository, $getUrlsForEvent, $relaysByUrl]) => { + ([ + $pubkey, + $checked, + $chats, + $userGroupSelections, + $repository, + $getUrlsForEvent, + $relaysByUrl, + ]) => { const hasNotification = (path: string, latestEvent: TrustedEvent | undefined) => { if (!latestEvent || latestEvent.pubkey === $pubkey) { return false @@ -85,7 +95,7 @@ export const notifications = derived( const allMessages = $repository.query([{kinds: [MESSAGE, THREAD, ZAP_GOAL, EVENT_TIME]}]) - for (const [url, rooms] of $userRoomsByUrl.entries()) { + for (const url of getSpaceUrlsFromGroupSelections($userGroupSelections)) { const spacePath = makeSpacePath(url) const spacePathMobile = spacePath + ":mobile" const goalPath = makeGoalPath(url) @@ -161,7 +171,7 @@ export const notifications = derived( } if (hasNip29($relaysByUrl.get(url))) { - for (const room of rooms) { + for (const room of getSpaceRoomsFromGroupSelections(url, $userGroupSelections)) { const roomPath = makeRoomPath(url, room) const latestEvent = allMessages.find( e => $getUrlsForEvent(e.id).includes(url) && e.tags.find(spec(["h", room])), diff --git a/src/app/util/routes.ts b/src/app/util/routes.ts index 18a201d..1a14cae 100644 --- a/src/app/util/routes.ts +++ b/src/app/util/routes.ts @@ -1,4 +1,5 @@ import type {Page} from "@sveltejs/kit" +import {get} from "svelte/store" import * as nip19 from "nostr-tools/nip19" import {goto} from "$app/navigation" import {nthEq, sleep} from "@welshman/lib" @@ -21,7 +22,7 @@ import { entityLink, decodeRelay, encodeRelay, - userRoomsByUrl, + userSpaceUrls, hasNip29, ROOM, } from "@app/core/state" @@ -66,7 +67,7 @@ export const makeCalendarPath = (url: string, eventId?: string) => export const getPrimaryNavItem = ($page: Page) => $page.route?.id?.split("/")[1] export const getPrimaryNavItemIndex = ($page: Page) => { - const urls = Array.from(userRoomsByUrl.get().keys()) + const urls = get(userSpaceUrls) switch (getPrimaryNavItem($page)) { case "discover": diff --git a/src/routes/discover/+page.svelte b/src/routes/discover/+page.svelte index 21af285..34040d2 100644 --- a/src/routes/discover/+page.svelte +++ b/src/routes/discover/+page.svelte @@ -6,7 +6,7 @@ import {Router} from "@welshman/router" import {load} from "@welshman/net" import type {Relay} from "@welshman/app" - import {relays, createSearch, loadRelay, loadRelaySelections} from "@welshman/app" + import {relays, createSearch, loadRelay} from "@welshman/app" import {createScroller} from "@lib/html" import {fly} from "@lib/transition" import QrCode from "@assets/icons/qr-code.svg?dataurl" @@ -23,7 +23,12 @@ import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte" import RelaySummary from "@app/components/RelaySummary.svelte" import SpaceCheck from "@app/components/SpaceCheck.svelte" - import {getMembershipUrls, loadMembership, defaultPubkeys, membersByUrl} from "@app/core/state" + import { + bootstrapPubkeys, + loadGroupSelections, + getSpaceUrlsFromGroupSelections, + groupSelectionsPubkeysByUrl, + } from "@app/core/state" import {pushModal} from "@app/util/modal" const openMenu = () => pushModal(SpaceAdd) @@ -45,11 +50,9 @@ filters: [{kinds: [ROOMS]}], relays: Router.get().Index().getUrls(), }), - ...$defaultPubkeys.map(async pubkey => { - await loadRelaySelections(pubkey) - - const membership = await loadMembership(pubkey) - const urls = getMembershipUrls(membership) + ...$bootstrapPubkeys.map(async pubkey => { + const list = await loadGroupSelections(pubkey) + const urls = getSpaceUrlsFromGroupSelections(list) await Promise.all(urls.map(url => loadRelay(url))) }), @@ -57,13 +60,13 @@ const relaySearch = $derived( createSearch( - $relays.filter(r => $membersByUrl.has(r.url) && r.url !== termUrl), + $relays.filter(r => $groupSelectionsPubkeysByUrl.has(r.url) && r.url !== termUrl), { getValue: (relay: Relay) => relay.url, sortFn: ({score, item}) => { if (score && score > 0.1) return -score! - const wotScore = $membersByUrl.get(item.url)?.size || 0 + const wotScore = $groupSelectionsPubkeysByUrl.get(item.url)!.size return score ? dec(score) * wotScore : -wotScore }, diff --git a/src/routes/people/+page.svelte b/src/routes/people/+page.svelte index 3effc34..694a7d9 100644 --- a/src/routes/people/+page.svelte +++ b/src/routes/people/+page.svelte @@ -7,13 +7,13 @@ import Page from "@lib/components/Page.svelte" import ContentSearch from "@lib/components/ContentSearch.svelte" import PeopleItem from "@app/components/PeopleItem.svelte" - import {defaultPubkeys} from "@app/core/state" + import {bootstrapPubkeys} from "@app/core/state" let term = $state("") let limit = $state(10) let element: Element | undefined = $state() - const pubkeys = $derived(term ? $profileSearch.searchValues(term) : $defaultPubkeys) + const pubkeys = $derived(term ? $profileSearch.searchValues(term) : $bootstrapPubkeys) onMount(() => { const scroller = createScroller({ diff --git a/src/routes/spaces/[relay]/[room]/+page.svelte b/src/routes/spaces/[relay]/[room]/+page.svelte index 16094fb..565ad14 100644 --- a/src/routes/spaces/[relay]/[room]/+page.svelte +++ b/src/routes/spaces/[relay]/[room]/+page.svelte @@ -7,7 +7,13 @@ import type {MakeNonOptional} from "@welshman/lib" import {now, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib" import type {TrustedEvent, EventContent} from "@welshman/util" - import {makeEvent, makeRoomMeta, MESSAGE} from "@welshman/util" + import { + makeEvent, + makeRoomMeta, + MESSAGE, + ROOM_ADD_MEMBER, + ROOM_REMOVE_MEMBER, + } from "@welshman/util" import {pubkey, publishThunk, waitForThunkError, joinRoom, leaveRoom} from "@welshman/app" import {slide, fade, fly} from "@lib/transition" import Hashtag from "@assets/icons/hashtag.svg?dataurl" @@ -26,13 +32,16 @@ import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte" import ChannelName from "@app/components/ChannelName.svelte" import ChannelItem from "@app/components/ChannelItem.svelte" + import ChannelItemAddMember from "@src/app/components/ChannelItemAddMember.svelte" + import ChannelItemRemoveMember from "@src/app/components/ChannelItemRemoveMember.svelte" import ChannelCompose from "@app/components/ChannelCompose.svelte" + import ChannelComposeEdit from "@src/app/components/ChannelComposeEdit.svelte" import ChannelComposeParent from "@app/components/ChannelComposeParent.svelte" import { - userRoomsByUrl, + deriveUserRooms, userSettingsValues, decodeRelay, - deriveUserMembershipStatus, + deriveUserRoomMembershipStatus, deriveChannel, MembershipStatus, PROTECTED, @@ -49,16 +58,16 @@ import {makeFeed} from "@app/core/requests" import {popKey} from "@lib/implicit" import {pushToast} from "@app/util/toast" - import ChannelComposeEdit from "@src/app/components/ChannelComposeEdit.svelte" const {room, relay} = $page.params as MakeNonOptional const mounted = now() const lastChecked = $checked[$page.url.pathname] const url = decodeRelay(relay) const channel = deriveChannel(url, room) - const isFavorite = $derived($userRoomsByUrl.get(url)?.has(room)) const shouldProtect = canEnforceNip70(url) - const membershipStatus = deriveUserMembershipStatus(url, room) + const userRooms = deriveUserRooms(url) + const isFavorite = $derived($userRooms.includes(room)) + const membershipStatus = deriveUserRoomMembershipStatus(url, room) const addFavorite = () => addRoomMembership(url, room) @@ -256,7 +265,7 @@ const feed = makeFeed({ url, element: element!, - filters: [{kinds: MESSAGE_KINDS, "#h": [room]}], + filters: [{kinds: [...MESSAGE_KINDS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [room]}], onExhausted: () => { loadingEvents = false }, @@ -398,15 +407,22 @@ {:else if type === "date"} {value} {:else} -
    - -
    + {@const event = $state.snapshot(value as TrustedEvent)} + {#if event.kind === ROOM_ADD_MEMBER} + + {:else if event.kind === ROOM_REMOVE_MEMBER} + + {:else} +
    + +
    + {/if} {/if} {/each}

    diff --git a/src/routes/spaces/[relay]/chat/+page.svelte b/src/routes/spaces/[relay]/chat/+page.svelte index 19a0d10..af41400 100644 --- a/src/routes/spaces/[relay]/chat/+page.svelte +++ b/src/routes/spaces/[relay]/chat/+page.svelte @@ -5,9 +5,9 @@ import {readable} from "svelte/store" import {now, formatTimestampAsDate, MINUTE, ago} from "@welshman/lib" import type {TrustedEvent, EventContent} from "@welshman/util" - import {makeEvent, MESSAGE} from "@welshman/util" + import {makeEvent, MESSAGE, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER} from "@welshman/util" import {pubkey, publishThunk} from "@welshman/app" - import {slide, fade, fly} from "@lib/transition" + import {fade, fly} from "@lib/transition" import ChatRound from "@assets/icons/chat-round.svg?dataurl" import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl" import Icon from "@lib/components/Icon.svelte" @@ -19,15 +19,17 @@ import ThunkToast from "@app/components/ThunkToast.svelte" import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte" import ChannelItem from "@app/components/ChannelItem.svelte" + import ChannelItemAddMember from "@src/app/components/ChannelItemAddMember.svelte" + import ChannelItemRemoveMember from "@src/app/components/ChannelItemRemoveMember.svelte" import ChannelCompose from "@app/components/ChannelCompose.svelte" + import ChannelComposeEdit from "@src/app/components/ChannelComposeEdit.svelte" import ChannelComposeParent from "@app/components/ChannelComposeParent.svelte" - import {userSettingsValues, decodeRelay, MESSAGE_FILTER, PROTECTED} from "@app/core/state" + import {userSettingsValues, decodeRelay, PROTECTED, MESSAGE_KINDS} from "@app/core/state" import {prependParent, canEnforceNip70, publishDelete} from "@app/core/commands" import {setChecked, checked} from "@app/util/notifications" import {pushToast} from "@app/util/toast" import {makeFeed} from "@app/core/requests" import {popKey} from "@lib/implicit" - import ChannelComposeEdit from "@src/app/components/ChannelComposeEdit.svelte" const mounted = now() const lastChecked = $checked[$page.url.pathname] @@ -218,7 +220,7 @@ const feed = makeFeed({ url, element: element!, - filters: [MESSAGE_FILTER], + filters: [{kinds: [...MESSAGE_KINDS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]}], onExhausted: () => { loadingEvents = false }, @@ -271,15 +273,21 @@ {value} {:else} {@const event = $state.snapshot(value as TrustedEvent)} -

    - -
    + {#if event.kind === RELAY_ADD_MEMBER} + + {:else if event.kind === RELAY_REMOVE_MEMBER} + + {:else} +
    + +
    + {/if} {/if} {/each}

    diff --git a/tailwind.config.js b/tailwind.config.js index 77a9a61..ea7fe03 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -9,6 +9,10 @@ config({path: ".env.template"}) export default { content: ["./src/**/*.{html,js,svelte,ts}"], darkMode: ['selector', '[data-theme="dark"]'], + safelist: [ + 'bg-success', + 'bg-warning', + ], theme: { extend: {}, zIndex: {