From 0b98197a8621522b456eb7c54aa492fc2852c154 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Fri, 24 Oct 2025 13:36:59 -0700 Subject: [PATCH] Add room deletion --- src/app/components/MenuSpaceRoomItem.svelte | 7 +- src/app/components/ModalContainer.svelte | 2 +- src/app/core/state.ts | 82 ++++++++----- src/app/core/sync.ts | 15 ++- src/app/util/storage.ts | 110 +++++++++--------- src/routes/+layout.svelte | 23 +--- src/routes/spaces/[relay]/[room]/+page.svelte | 44 ++++++- 7 files changed, 173 insertions(+), 110 deletions(-) diff --git a/src/app/components/MenuSpaceRoomItem.svelte b/src/app/components/MenuSpaceRoomItem.svelte index 0088e666..9117790d 100644 --- a/src/app/components/MenuSpaceRoomItem.svelte +++ b/src/app/components/MenuSpaceRoomItem.svelte @@ -26,7 +26,12 @@ {replaceState} notification={notify ? $notifications.has(path) : false}> {#if $channel?.picture} - + {@const src = $channel.picture} + {#if src.match("\.(png|svg)$") || src.match("image/(png|svg)")} + + {:else} + Room icon + {/if} {:else if $channel?.closed || $channel?.private} {:else} diff --git a/src/app/components/ModalContainer.svelte b/src/app/components/ModalContainer.svelte index 16ab720b..54e785fd 100644 --- a/src/app/components/ModalContainer.svelte +++ b/src/app/components/ModalContainer.svelte @@ -14,7 +14,7 @@ let instance: any | undefined onMount(() => { - modal.subscribe($modal => { + return modal.subscribe($modal => { if (instance) { unmount(instance, {outro: true}) instance = undefined diff --git a/src/app/core/state.ts b/src/app/core/state.ts index 11c47686..f08d65db 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -70,7 +70,9 @@ import { ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS, + ROOM_ADMINS, ROOM_META, + ROOM_DELETE, ROOM_REMOVE_MEMBER, ROOMS, THREAD, @@ -278,8 +280,11 @@ export const deriveEventsForUrl = (url: string, filters: Filter[]) => }) export const deriveSignedEventsForUrl = (url: string, filters: Filter[]) => - derived([deriveEventsForUrl(url, filters), deriveRelay(url)], ([$events, $relay]) => - $relay?.self ? $events.filter(spec({pubkey: $relay.self})) : [], + derived( + [deriveEventsForUrl(url, filters), deriveRelay(url)], + ([$events, $relay]) => $events, + // Disable this check for now since khatru doesn't support self + // $relay?.self ? $events.filter(spec({pubkey: $relay.self})) : [], ) // Context @@ -559,37 +564,43 @@ export const splitChannelId = (id: string) => id.split("'") export const hasNip29 = (relay?: RelayProfile) => relay?.supported_nips?.map?.(String)?.includes?.("29") -export const channelEvents = deriveEvents(repository, {filters: [{kinds: [ROOM_META]}]}) - export const channels = derived( - [channelEvents, getUrlsForEvent], - ([$channelEvents, $getUrlsForEvent]) => { - const $channels: Channel[] = [] + [deriveEvents(repository, {filters: [{kinds: [ROOM_META, ROOM_DELETE]}]}), getUrlsForEvent], + ([$events, $getUrlsForEvent]) => { + const result = new Map() - for (const event of $channelEvents) { - const meta = fromPairs(event.tags) - const room = meta.d + for (const event of sortBy(e => e.created_at, $events)) { + for (const url of $getUrlsForEvent(event.id)) { + if (event.kind === ROOM_META) { + const meta = fromPairs(event.tags) + const room = meta.d - if (room) { - for (const url of $getUrlsForEvent(event.id)) { - const id = makeChannelId(url, room) + if (room) { + const id = makeChannelId(url, room) - $channels.push({ - id, - url, - room, - event, - name: meta.name || room, - closed: Boolean(getTag("closed", event.tags)), - private: Boolean(getTag("private", event.tags)), - picture: meta.picture, - about: meta.about, - }) + result.set(id, { + id, + url, + room, + event, + name: meta.name || room, + closed: Boolean(getTag("closed", event.tags)), + private: Boolean(getTag("private", event.tags)), + picture: meta.picture, + about: meta.about, + }) + } + } + + if (event.kind === ROOM_DELETE) { + for (const room of getTagValues("h", event.tags)) { + result.delete(makeChannelId(url, room)) + } } } } - return uniqBy(c => c.id, $channels) + return Array.from(result.values()) }, ) @@ -745,7 +756,7 @@ export const deriveSpaceMembers = (url: string) => const members = new Set() - for (const event of $events) { + for (const event of sortBy(e => e.created_at, $events)) { const pubkeys = getPubkeyTagValues(event.tags) if (event.kind === RELAY_ADD_MEMBER) { @@ -768,7 +779,8 @@ export const deriveSpaceMembers = (url: string) => export const deriveRoomMembers = (url: string, room: string) => derived( deriveEventsForUrl(url, [ - {kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER, ROOM_MEMBERS], "#h": [room]}, + {kinds: [ROOM_MEMBERS], "#d": [room]}, + {kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [room]}, ]), $events => { const membersEvent = $events.find(spec({kind: ROOM_MEMBERS})) @@ -779,7 +791,7 @@ export const deriveRoomMembers = (url: string, room: string) => const members = new Set() - for (const event of $events) { + for (const event of sortBy(e => -e.created_at, $events)) { const pubkeys = getPubkeyTagValues(event.tags) if (event.kind === ROOM_ADD_MEMBER) { @@ -799,6 +811,17 @@ export const deriveRoomMembers = (url: string, room: string) => }, ) +export const deriveRoomAdmins = (url: string, room: string) => + derived(deriveEventsForUrl(url, [{kinds: [ROOM_ADMINS], "#d": [room]}]), $events => { + const adminsEvent = first($events) + + if (adminsEvent) { + return getPubkeyTagValues(adminsEvent.tags) + } + + return [] + }) + // User membership status export enum MembershipStatus { @@ -873,6 +896,9 @@ export const deriveUserCanCreateRoom = (url: string) => }, ) +export const deriveUserIsRoomAdmin = (url: string, room: string) => + derived([pubkey, deriveRoomAdmins(url, room)], ([$pubkey, $admins]) => $admins.includes($pubkey!)) + // Other utils export const encodeRelay = (url: string) => diff --git a/src/app/core/sync.ts b/src/app/core/sync.ts index e5a1fda8..07e929b9 100644 --- a/src/app/core/sync.ts +++ b/src/app/core/sync.ts @@ -19,8 +19,13 @@ import { getRelayTagValues, WRAP, ROOM_META, + ROOM_DELETE, + ROOM_ADMINS, + ROOM_MEMBERS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER, + ROOM_CREATE_PERMISSION, + RELAY_MEMBERS, isSignedEvent, } from "@welshman/util" import type {Filter, TrustedEvent} from "@welshman/util" @@ -165,12 +170,14 @@ const syncUserData = () => { const syncMembership = (url: string) => { const controller = new AbortController() + const relayFilter = {kinds: [RELAY_MEMBERS, ROOM_CREATE_PERMISSION]} + const roomsFilter = {kinds: [ROOM_ADMINS, ROOM_MEMBERS, ROOM_META, ROOM_DELETE]} - // Load group metadata + // Load group metadata and member lists pullConservatively({ relays: [url], signal: controller.signal, - filters: [{kinds: [ROOM_META]}], + filters: [relayFilter, roomsFilter], }) // Load historical data from up to a month ago for quick page loading @@ -184,7 +191,9 @@ const syncMembership = (url: string) => { request({ relays: [url], signal: controller.signal, - filters: [MESSAGE_FILTER, COMMENT_FILTER, MEMBERSHIP_FILTER].map(assoc("since", now())), + filters: [relayFilter, roomsFilter, MESSAGE_FILTER, COMMENT_FILTER, MEMBERSHIP_FILTER].map( + assoc("since", now()), + ), }) return () => controller.abort() diff --git a/src/app/util/storage.ts b/src/app/util/storage.ts index a77b7079..75dafda8 100644 --- a/src/app/util/storage.ts +++ b/src/app/util/storage.ts @@ -1,24 +1,35 @@ import {prop, call, on, throttle, fromPairs, batch} from "@welshman/lib" import {throttled, freshness} from "@welshman/store" import { - PROFILE, - FOLLOWS, - MUTES, - RELAYS, - BLOSSOM_SERVERS, - INBOX_RELAYS, - ROOMS, - APP_DATA, - ALERT_STATUS, - ALERT_EMAIL, - ALERT_WEB, - ALERT_IOS, ALERT_ANDROID, - EVENT_TIME, - THREAD, - MESSAGE, - DIRECT_MESSAGE, + ALERT_EMAIL, + ALERT_IOS, + ALERT_STATUS, + ALERT_WEB, + APP_DATA, + BLOSSOM_SERVERS, DIRECT_MESSAGE_FILE, + DIRECT_MESSAGE, + EVENT_TIME, + FOLLOWS, + INBOX_RELAYS, + MESSAGE, + MUTES, + PROFILE, + RELAY_ADD_MEMBER, + RELAY_JOIN, + RELAY_LEAVE, + RELAY_MEMBERS, + RELAY_REMOVE_MEMBER, + RELAYS, + ROOM_ADD_MEMBER, + ROOM_CREATE_PERMISSION, + ROOM_MEMBERS, + ROOM_META, + ROOM_REMOVE_MEMBER, + ROOMS, + THREAD, + ZAP_GOAL, verifiedSymbol, } from "@welshman/util" import type {Zapper, TrustedEvent, RelayProfile} from "@welshman/util" @@ -50,47 +61,34 @@ const syncEvents = async () => { repository.load(initialEvents) + const metaKinds = [ + PROFILE, + FOLLOWS, + MUTES, + RELAYS, + BLOSSOM_SERVERS, + INBOX_RELAYS, + APP_DATA, + ROOMS, + ] + const alertKinds = [ALERT_STATUS, ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID] + const spaceKinds = [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, RELAY_MEMBERS, RELAY_JOIN, RELAY_LEAVE] + const roomKinds = [ + ROOM_META, + ROOM_MEMBERS, + ROOM_ADD_MEMBER, + ROOM_REMOVE_MEMBER, + ROOM_CREATE_PERMISSION, + ] + const contentKinds = [EVENT_TIME, THREAD, MESSAGE, ZAP_GOAL, DIRECT_MESSAGE, DIRECT_MESSAGE_FILE] + const rankEvent = (event: TrustedEvent) => { - switch (event.kind) { - case PROFILE: - return 1 - case FOLLOWS: - return 1 - case MUTES: - return 1 - case RELAYS: - return 1 - case BLOSSOM_SERVERS: - return 1 - case INBOX_RELAYS: - return 1 - case ROOMS: - return 1 - case APP_DATA: - return 1 - case ALERT_STATUS: - return 1 - case ALERT_EMAIL: - return 1 - case ALERT_WEB: - return 1 - case ALERT_IOS: - return 1 - case ALERT_ANDROID: - return 1 - case EVENT_TIME: - return 0.9 - case THREAD: - return 0.9 - case MESSAGE: - return 0.9 - case DIRECT_MESSAGE: - return 0.9 - case DIRECT_MESSAGE_FILE: - return 0.9 - default: - return 0 - } + if (metaKinds.includes(event.kind)) return 9 + if (alertKinds.includes(event.kind)) return 8 + if (spaceKinds.includes(event.kind)) return 7 + if (roomKinds.includes(event.kind)) return 6 + if (contentKinds.includes(event.kind)) return 5 + return 0 } return on( diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 4f596fb1..3a5ab44e 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -9,21 +9,14 @@ import {dev} from "$app/environment" import {goto} from "$app/navigation" import {sync} from "@welshman/store" - import {call, on, spec} from "@welshman/lib" + import {call, spec} from "@welshman/lib" import {defaultSocketPolicies} from "@welshman/net" - import { - repository, - pubkey, - sessions, - signerLog, - shouldUnwrap, - loadRelaySelections, - SignerLogEntryStatus, - } from "@welshman/app" + import {pubkey, sessions, signerLog, shouldUnwrap, SignerLogEntryStatus} from "@welshman/app" import * as lib from "@welshman/lib" import * as util from "@welshman/util" import * as feeds from "@welshman/feeds" import * as router from "@welshman/router" + import * as store from "@welshman/store" import * as welshmanSigner from "@welshman/signer" import * as net from "@welshman/net" import * as app from "@welshman/app" @@ -58,6 +51,7 @@ ...lib, ...welshmanSigner, ...router, + ...store, ...util, ...feeds, ...net, @@ -128,15 +122,6 @@ // History, navigation, bug tracking, application data unsubscribers.push(setupHistory(), setupAnalytics(), setupTracking(), syncApplicationData()) - // Whenever we see a new pubkey, load their outbox event - unsubscribers.push( - on(repository, "update", ({added}) => { - for (const event of added) { - loadRelaySelections(event.pubkey) - } - }), - ) - // Subscribe to badge count for changes unsubscribers.push(notifications.badgeCount.subscribe(notifications.handleBadgeCountChanges)) diff --git a/src/routes/spaces/[relay]/[room]/+page.svelte b/src/routes/spaces/[relay]/[room]/+page.svelte index 565ad149..8eb3495b 100644 --- a/src/routes/spaces/[relay]/[room]/+page.svelte +++ b/src/routes/spaces/[relay]/[room]/+page.svelte @@ -3,6 +3,7 @@ import {readable} from "svelte/store" import {onMount, onDestroy} from "svelte" import {page} from "$app/stores" + import {goto} from "$app/navigation" import type {Readable} from "svelte/store" import type {MakeNonOptional} from "@welshman/lib" import {now, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib" @@ -14,11 +15,20 @@ ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER, } from "@welshman/util" - import {pubkey, publishThunk, waitForThunkError, joinRoom, leaveRoom} from "@welshman/app" + import { + pubkey, + publishThunk, + waitForThunkError, + deleteRoom, + joinRoom, + leaveRoom, + repository, + } from "@welshman/app" import {slide, fade, fly} from "@lib/transition" import Hashtag from "@assets/icons/hashtag.svg?dataurl" import ClockCircle from "@assets/icons/clock-circle.svg?dataurl" import Login2 from "@assets/icons/login-3.svg?dataurl" + import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl" import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl" import Logout2 from "@assets/icons/logout-3.svg?dataurl" import Bookmark from "@assets/icons/bookmark.svg?dataurl" @@ -28,6 +38,7 @@ import PageBar from "@lib/components/PageBar.svelte" import PageContent from "@lib/components/PageContent.svelte" import Divider from "@lib/components/Divider.svelte" + import Confirm from "@lib/components/Confirm.svelte" import ThunkToast from "@app/components/ThunkToast.svelte" import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte" import ChannelName from "@app/components/ChannelName.svelte" @@ -46,6 +57,7 @@ MembershipStatus, PROTECTED, MESSAGE_KINDS, + deriveUserIsRoomAdmin, } from "@app/core/state" import {setChecked, checked} from "@app/util/notifications" import { @@ -58,6 +70,8 @@ import {makeFeed} from "@app/core/requests" import {popKey} from "@lib/implicit" import {pushToast} from "@app/util/toast" + import {pushModal} from "@app/util/modal" + import {makeSpacePath} from "@app/util/routes" const {room, relay} = $page.params as MakeNonOptional const mounted = now() @@ -66,6 +80,7 @@ const channel = deriveChannel(url, room) const shouldProtect = canEnforceNip70(url) const userRooms = deriveUserRooms(url) + const userIsAdmin = deriveUserIsRoomAdmin(url, room) const isFavorite = $derived($userRooms.includes(room)) const membershipStatus = deriveUserRoomMembershipStatus(url, room) @@ -292,6 +307,24 @@ } } + const startDelete = () => + pushModal(Confirm, { + title: "Are you sure you want to delete this room?", + message: + "This room will no longer be accessible to space members, and all messages posted to it will be deleted.", + confirm: async () => { + const thunk = deleteRoom(url, makeRoomMeta({id: room})) + const message = await waitForThunkError(thunk) + + if (message) { + repository.removeEvent(thunk.event.id) + pushToast({theme: "error", message}) + } else { + goto(makeSpacePath(url)) + } + }, + }) + onMount(() => { const observer = new ResizeObserver(() => { if (dynamicPadding && chatCompose) { @@ -332,7 +365,14 @@ {/snippet} {#snippet action()}
- {#if $membershipStatus === MembershipStatus.Initial} + {#if $userIsAdmin || true} + + {:else if $membershipStatus === MembershipStatus.Initial}