Add room deletion

This commit is contained in:
Jon Staab
2025-10-24 13:36:59 -07:00
parent 0e94a9c33f
commit 0b98197a86
7 changed files with 173 additions and 110 deletions
+6 -1
View File
@@ -26,7 +26,12 @@
{replaceState} {replaceState}
notification={notify ? $notifications.has(path) : false}> notification={notify ? $notifications.has(path) : false}>
{#if $channel?.picture} {#if $channel?.picture}
<Icon icon={$channel.picture} /> {@const src = $channel.picture}
{#if src.match("\.(png|svg)$") || src.match("image/(png|svg)")}
<Icon icon={src} />
{:else}
<img alt="Room icon" {src} class="h-6 w-6 rounded-lg" />
{/if}
{:else if $channel?.closed || $channel?.private} {:else if $channel?.closed || $channel?.private}
<Icon icon={Lock} /> <Icon icon={Lock} />
{:else} {:else}
+1 -1
View File
@@ -14,7 +14,7 @@
let instance: any | undefined let instance: any | undefined
onMount(() => { onMount(() => {
modal.subscribe($modal => { return modal.subscribe($modal => {
if (instance) { if (instance) {
unmount(instance, {outro: true}) unmount(instance, {outro: true})
instance = undefined instance = undefined
+54 -28
View File
@@ -70,7 +70,9 @@ import {
ROOM_JOIN, ROOM_JOIN,
ROOM_LEAVE, ROOM_LEAVE,
ROOM_MEMBERS, ROOM_MEMBERS,
ROOM_ADMINS,
ROOM_META, ROOM_META,
ROOM_DELETE,
ROOM_REMOVE_MEMBER, ROOM_REMOVE_MEMBER,
ROOMS, ROOMS,
THREAD, THREAD,
@@ -278,8 +280,11 @@ export const deriveEventsForUrl = (url: string, filters: Filter[]) =>
}) })
export const deriveSignedEventsForUrl = (url: string, filters: Filter[]) => export const deriveSignedEventsForUrl = (url: string, filters: Filter[]) =>
derived([deriveEventsForUrl(url, filters), deriveRelay(url)], ([$events, $relay]) => derived(
$relay?.self ? $events.filter(spec({pubkey: $relay.self})) : [], [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 // Context
@@ -559,37 +564,43 @@ export const splitChannelId = (id: string) => id.split("'")
export const hasNip29 = (relay?: RelayProfile) => export const hasNip29 = (relay?: RelayProfile) =>
relay?.supported_nips?.map?.(String)?.includes?.("29") relay?.supported_nips?.map?.(String)?.includes?.("29")
export const channelEvents = deriveEvents(repository, {filters: [{kinds: [ROOM_META]}]})
export const channels = derived( export const channels = derived(
[channelEvents, getUrlsForEvent], [deriveEvents(repository, {filters: [{kinds: [ROOM_META, ROOM_DELETE]}]}), getUrlsForEvent],
([$channelEvents, $getUrlsForEvent]) => { ([$events, $getUrlsForEvent]) => {
const $channels: Channel[] = [] const result = new Map<string, Channel>()
for (const event of $channelEvents) { for (const event of sortBy(e => e.created_at, $events)) {
const meta = fromPairs(event.tags) for (const url of $getUrlsForEvent(event.id)) {
const room = meta.d if (event.kind === ROOM_META) {
const meta = fromPairs(event.tags)
const room = meta.d
if (room) { if (room) {
for (const url of $getUrlsForEvent(event.id)) { const id = makeChannelId(url, room)
const id = makeChannelId(url, room)
$channels.push({ result.set(id, {
id, id,
url, url,
room, room,
event, event,
name: meta.name || room, name: meta.name || room,
closed: Boolean(getTag("closed", event.tags)), closed: Boolean(getTag("closed", event.tags)),
private: Boolean(getTag("private", event.tags)), private: Boolean(getTag("private", event.tags)),
picture: meta.picture, picture: meta.picture,
about: meta.about, 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() const members = new Set()
for (const event of $events) { for (const event of sortBy(e => e.created_at, $events)) {
const pubkeys = getPubkeyTagValues(event.tags) const pubkeys = getPubkeyTagValues(event.tags)
if (event.kind === RELAY_ADD_MEMBER) { if (event.kind === RELAY_ADD_MEMBER) {
@@ -768,7 +779,8 @@ export const deriveSpaceMembers = (url: string) =>
export const deriveRoomMembers = (url: string, room: string) => export const deriveRoomMembers = (url: string, room: string) =>
derived( derived(
deriveEventsForUrl(url, [ 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 => { $events => {
const membersEvent = $events.find(spec({kind: ROOM_MEMBERS})) const membersEvent = $events.find(spec({kind: ROOM_MEMBERS}))
@@ -779,7 +791,7 @@ export const deriveRoomMembers = (url: string, room: string) =>
const members = new Set() const members = new Set()
for (const event of $events) { for (const event of sortBy(e => -e.created_at, $events)) {
const pubkeys = getPubkeyTagValues(event.tags) const pubkeys = getPubkeyTagValues(event.tags)
if (event.kind === ROOM_ADD_MEMBER) { 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 // User membership status
export enum MembershipStatus { 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 // Other utils
export const encodeRelay = (url: string) => export const encodeRelay = (url: string) =>
+12 -3
View File
@@ -19,8 +19,13 @@ import {
getRelayTagValues, getRelayTagValues,
WRAP, WRAP,
ROOM_META, ROOM_META,
ROOM_DELETE,
ROOM_ADMINS,
ROOM_MEMBERS,
ROOM_ADD_MEMBER, ROOM_ADD_MEMBER,
ROOM_REMOVE_MEMBER, ROOM_REMOVE_MEMBER,
ROOM_CREATE_PERMISSION,
RELAY_MEMBERS,
isSignedEvent, isSignedEvent,
} from "@welshman/util" } from "@welshman/util"
import type {Filter, TrustedEvent} from "@welshman/util" import type {Filter, TrustedEvent} from "@welshman/util"
@@ -165,12 +170,14 @@ const syncUserData = () => {
const syncMembership = (url: string) => { const syncMembership = (url: string) => {
const controller = new AbortController() 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({ pullConservatively({
relays: [url], relays: [url],
signal: controller.signal, signal: controller.signal,
filters: [{kinds: [ROOM_META]}], filters: [relayFilter, roomsFilter],
}) })
// Load historical data from up to a month ago for quick page loading // Load historical data from up to a month ago for quick page loading
@@ -184,7 +191,9 @@ const syncMembership = (url: string) => {
request({ request({
relays: [url], relays: [url],
signal: controller.signal, 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() return () => controller.abort()
+54 -56
View File
@@ -1,24 +1,35 @@
import {prop, call, on, throttle, fromPairs, batch} from "@welshman/lib" import {prop, call, on, throttle, fromPairs, batch} from "@welshman/lib"
import {throttled, freshness} from "@welshman/store" import {throttled, freshness} from "@welshman/store"
import { import {
PROFILE,
FOLLOWS,
MUTES,
RELAYS,
BLOSSOM_SERVERS,
INBOX_RELAYS,
ROOMS,
APP_DATA,
ALERT_STATUS,
ALERT_EMAIL,
ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID, ALERT_ANDROID,
EVENT_TIME, ALERT_EMAIL,
THREAD, ALERT_IOS,
MESSAGE, ALERT_STATUS,
DIRECT_MESSAGE, ALERT_WEB,
APP_DATA,
BLOSSOM_SERVERS,
DIRECT_MESSAGE_FILE, 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, verifiedSymbol,
} from "@welshman/util" } from "@welshman/util"
import type {Zapper, TrustedEvent, RelayProfile} from "@welshman/util" import type {Zapper, TrustedEvent, RelayProfile} from "@welshman/util"
@@ -50,47 +61,34 @@ const syncEvents = async () => {
repository.load(initialEvents) 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) => { const rankEvent = (event: TrustedEvent) => {
switch (event.kind) { if (metaKinds.includes(event.kind)) return 9
case PROFILE: if (alertKinds.includes(event.kind)) return 8
return 1 if (spaceKinds.includes(event.kind)) return 7
case FOLLOWS: if (roomKinds.includes(event.kind)) return 6
return 1 if (contentKinds.includes(event.kind)) return 5
case MUTES: return 0
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
}
} }
return on( return on(
+4 -19
View File
@@ -9,21 +9,14 @@
import {dev} from "$app/environment" import {dev} from "$app/environment"
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {sync} from "@welshman/store" import {sync} from "@welshman/store"
import {call, on, spec} from "@welshman/lib" import {call, spec} from "@welshman/lib"
import {defaultSocketPolicies} from "@welshman/net" import {defaultSocketPolicies} from "@welshman/net"
import { import {pubkey, sessions, signerLog, shouldUnwrap, SignerLogEntryStatus} from "@welshman/app"
repository,
pubkey,
sessions,
signerLog,
shouldUnwrap,
loadRelaySelections,
SignerLogEntryStatus,
} from "@welshman/app"
import * as lib from "@welshman/lib" import * as lib from "@welshman/lib"
import * as util from "@welshman/util" import * as util from "@welshman/util"
import * as feeds from "@welshman/feeds" import * as feeds from "@welshman/feeds"
import * as router from "@welshman/router" import * as router from "@welshman/router"
import * as store from "@welshman/store"
import * as welshmanSigner from "@welshman/signer" import * as welshmanSigner from "@welshman/signer"
import * as net from "@welshman/net" import * as net from "@welshman/net"
import * as app from "@welshman/app" import * as app from "@welshman/app"
@@ -58,6 +51,7 @@
...lib, ...lib,
...welshmanSigner, ...welshmanSigner,
...router, ...router,
...store,
...util, ...util,
...feeds, ...feeds,
...net, ...net,
@@ -128,15 +122,6 @@
// History, navigation, bug tracking, application data // History, navigation, bug tracking, application data
unsubscribers.push(setupHistory(), setupAnalytics(), setupTracking(), syncApplicationData()) 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 // Subscribe to badge count for changes
unsubscribers.push(notifications.badgeCount.subscribe(notifications.handleBadgeCountChanges)) unsubscribers.push(notifications.badgeCount.subscribe(notifications.handleBadgeCountChanges))
+42 -2
View File
@@ -3,6 +3,7 @@
import {readable} from "svelte/store" import {readable} from "svelte/store"
import {onMount, onDestroy} from "svelte" import {onMount, onDestroy} from "svelte"
import {page} from "$app/stores" import {page} from "$app/stores"
import {goto} from "$app/navigation"
import type {Readable} from "svelte/store" import type {Readable} from "svelte/store"
import type {MakeNonOptional} from "@welshman/lib" import type {MakeNonOptional} from "@welshman/lib"
import {now, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib" import {now, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib"
@@ -14,11 +15,20 @@
ROOM_ADD_MEMBER, ROOM_ADD_MEMBER,
ROOM_REMOVE_MEMBER, ROOM_REMOVE_MEMBER,
} from "@welshman/util" } 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 {slide, fade, fly} from "@lib/transition"
import Hashtag from "@assets/icons/hashtag.svg?dataurl" import Hashtag from "@assets/icons/hashtag.svg?dataurl"
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl" import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import Login2 from "@assets/icons/login-3.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 AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import Logout2 from "@assets/icons/logout-3.svg?dataurl" import Logout2 from "@assets/icons/logout-3.svg?dataurl"
import Bookmark from "@assets/icons/bookmark.svg?dataurl" import Bookmark from "@assets/icons/bookmark.svg?dataurl"
@@ -28,6 +38,7 @@
import PageBar from "@lib/components/PageBar.svelte" import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte" import ThunkToast from "@app/components/ThunkToast.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte" import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
import ChannelName from "@app/components/ChannelName.svelte" import ChannelName from "@app/components/ChannelName.svelte"
@@ -46,6 +57,7 @@
MembershipStatus, MembershipStatus,
PROTECTED, PROTECTED,
MESSAGE_KINDS, MESSAGE_KINDS,
deriveUserIsRoomAdmin,
} from "@app/core/state" } from "@app/core/state"
import {setChecked, checked} from "@app/util/notifications" import {setChecked, checked} from "@app/util/notifications"
import { import {
@@ -58,6 +70,8 @@
import {makeFeed} from "@app/core/requests" import {makeFeed} from "@app/core/requests"
import {popKey} from "@lib/implicit" import {popKey} from "@lib/implicit"
import {pushToast} from "@app/util/toast" 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<typeof $page.params> const {room, relay} = $page.params as MakeNonOptional<typeof $page.params>
const mounted = now() const mounted = now()
@@ -66,6 +80,7 @@
const channel = deriveChannel(url, room) const channel = deriveChannel(url, room)
const shouldProtect = canEnforceNip70(url) const shouldProtect = canEnforceNip70(url)
const userRooms = deriveUserRooms(url) const userRooms = deriveUserRooms(url)
const userIsAdmin = deriveUserIsRoomAdmin(url, room)
const isFavorite = $derived($userRooms.includes(room)) const isFavorite = $derived($userRooms.includes(room))
const membershipStatus = deriveUserRoomMembershipStatus(url, 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(() => { onMount(() => {
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
if (dynamicPadding && chatCompose) { if (dynamicPadding && chatCompose) {
@@ -332,7 +365,14 @@
{/snippet} {/snippet}
{#snippet action()} {#snippet action()}
<div class="row-2"> <div class="row-2">
{#if $membershipStatus === MembershipStatus.Initial} {#if $userIsAdmin || true}
<Button
class="btn btn-neutral btn-sm tooltip tooltip-left"
data-tip="Delete this room"
onclick={startDelete}>
<Icon size={4} icon={TrashBin2} />
</Button>
{:else if $membershipStatus === MembershipStatus.Initial}
<Button <Button
class="btn btn-neutral btn-sm tooltip tooltip-left" class="btn btn-neutral btn-sm tooltip tooltip-left"
data-tip="Request to be added to the member list" data-tip="Request to be added to the member list"