From 88ca2d532b05a03196446554113384cf0b83123f Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 12 Mar 2026 02:44:42 +0100 Subject: [PATCH] feat: channel deletion UI with full backend cleanup Add Delete Channel button to channel settings modal with ConfirmDialog confirmation. Fix backend DELETE route to disconnect voice users, clean up attachment files from disk, and remove orphaned read_states. Make deleteChannel federation-aware in spaceStore and clean up voiceUsers on channel_deleted WebSocket event. --- packages/server/src/routes/channels.ts | 34 +++- .../modals/ChannelSettingsModal.tsx | 185 ++++++++++++------ packages/web/src/hooks/useWebSocket.ts | 9 + packages/web/src/stores/spaceStore.ts | 4 +- 4 files changed, 171 insertions(+), 61 deletions(-) diff --git a/packages/server/src/routes/channels.ts b/packages/server/src/routes/channels.ts index 12c59082..6b5bfc78 100644 --- a/packages/server/src/routes/channels.ts +++ b/packages/server/src/routes/channels.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from 'fastify'; -import { eq, and } from 'drizzle-orm'; +import { eq, and, inArray } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { authenticate } from '../utils/auth.js'; import { generateSnowflake } from '../utils/snowflake.js'; @@ -7,6 +7,7 @@ import { isMember, hasPermission, getChannelSpaceId, PermissionBits, computePerm import { permissionsToString } from '@backspace/shared/src/permissions.js'; import { connectionManager } from '../ws/handler.js'; import { checkVoicePermissions } from '../ws/events.js'; +import { deleteAttachmentFiles } from '../utils/fileCleanup.js'; import type { CreateChannelRequest, UpdateChannelRequest, @@ -285,6 +286,21 @@ export async function channelRoutes(app: FastifyInstance): Promise { return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 }); } + // Disconnect voice users before deletion + const participants = connectionManager.getRoomParticipants(id); + if (participants.size > 0) { + for (const participantId of Array.from(participants)) { + connectionManager.leaveRoom(id, participantId); + connectionManager.clearVoiceUserStatus(participantId); + connectionManager.sendToSpace(spaceId, { + type: 'voice_state_update', channelId: id, userId: participantId, action: 'leave', + }); + connectionManager.sendToUser(participantId, { + type: 'voice_disconnected', userId: participantId, channelId: id, + }); + } + } + // Collect viewers BEFORE deleting (overrides CASCADE-delete with the channel) const viewerIds: string[] = []; for (const [uid, spaceIds] of connectionManager.getUserSpaceEntries()) { @@ -296,10 +312,26 @@ export async function channelRoutes(app: FastifyInstance): Promise { } } + // Collect attachment filenames BEFORE cascade deletes DB records + const channelMsgIds = db.select({ id: schema.messages.id }) + .from(schema.messages).where(eq(schema.messages.channelId, id)).all().map(m => m.id); + + let attachmentRows: { filename: string }[] = []; + if (channelMsgIds.length > 0) { + attachmentRows = db.select({ filename: schema.attachments.filename }) + .from(schema.attachments).where(inArray(schema.attachments.messageId, channelMsgIds)).all(); + } + + // Clean up read_states (no FK, rows would be orphaned) + db.delete(schema.readStates).where(eq(schema.readStates.channelId, id)).run(); + // Delete messages in channel (attachments cascade), then channel db.delete(schema.messages).where(eq(schema.messages.channelId, id)).run(); db.delete(schema.channels).where(eq(schema.channels.id, id)).run(); + // Delete attachment files from disk + deleteAttachmentFiles(attachmentRows); + // Broadcast channel_deleted only to users who could see the channel const deleteEvent = { type: 'channel_deleted' as const, channelId: id, spaceId }; for (const uid of viewerIds) { diff --git a/packages/web/src/components/modals/ChannelSettingsModal.tsx b/packages/web/src/components/modals/ChannelSettingsModal.tsx index e457bd23..72cf828b 100644 --- a/packages/web/src/components/modals/ChannelSettingsModal.tsx +++ b/packages/web/src/components/modals/ChannelSettingsModal.tsx @@ -1,9 +1,12 @@ import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Modal } from '../ui/Modal'; +import { ConfirmDialog } from '../ui/ConfirmDialog'; import { useUIStore } from '../../stores/uiStore'; -import { useSpaceStore } from '../../stores/spaceStore'; +import { useSpaceStore, getApiForOrigin } from '../../stores/spaceStore'; +import { useChatStore } from '../../stores/chatStore'; import { api } from '../../api/client'; -import { PermissionBits, permissionsToString, stringToPermissions } from '../../utils/permissions'; +import { PermissionBits, permissionsToString, stringToPermissions, hasPermissionBit } from '../../utils/permissions'; interface ChannelOverride { channelId: string; @@ -19,16 +22,33 @@ export function ChannelSettingsModal() { const closeModal = useUIStore((s) => s.closeModal); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const channels = useSpaceStore((s) => s.channels); + const spaces = useSpaceStore((s) => s.spaces); + const spacePermissions = useSpaceStore((s) => s.spacePermissions); + const currentChannelId = useChatStore((s) => s.currentChannelId); + const navigate = useNavigate(); const [isPrivate, setIsPrivate] = useState(false); const [isLoading, setIsLoading] = useState(false); const [isFetching, setIsFetching] = useState(true); const [error, setError] = useState(''); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); const isOpen = activeModal === 'channelSettings'; const channelId = modalData?.channelId as string | undefined; const channel = channels.find(c => c.id === channelId); + const myPerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined; + const canManageChannels = myPerms !== undefined && hasPermissionBit(myPerms, PermissionBits.MANAGE_CHANNELS); + + // Reset delete state when modal closes + useEffect(() => { + if (!isOpen) { + setShowDeleteConfirm(false); + setIsDeleting(false); + } + }, [isOpen]); + // Fetch overrides when modal opens useEffect(() => { if (!isOpen || !channelId || !currentSpaceId) { @@ -88,67 +108,114 @@ export function ChannelSettingsModal() { } }; + const handleDeleteChannel = async () => { + if (!channelId || !currentSpaceId) return; + setIsDeleting(true); + try { + const space = spaces.find(s => s.id === currentSpaceId); + const channelApi = getApiForOrigin(space?._instanceOrigin ?? ''); + await channelApi.channels.delete(channelId); + closeModal(); + if (currentChannelId === channelId) { + navigate('/channels/@me'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete channel'); + setIsDeleting(false); + } + }; + return ( - -
-
- -
- - - - {channel.name} -
-
- - {error && ( -
- {error} -
- )} - -
-
-
-
Private Channel
-
- Only selected members and roles will be able to view this channel. -
+ <> + +
+
+ +
+ + + + {channel.name}
-
-
- {isPrivate && !isFetching && ( -
- - - - - This channel is hidden from members without explicit access. Users with the Administrator permission or space owners can always see all channels. - + {error && ( +
+ {error} +
+ )} + +
+
+
+
Private Channel
+
+ Only selected members and roles will be able to view this channel. +
+
+ +
- )} -
-
+ + {isPrivate && !isFetching && ( +
+ + + + + This channel is hidden from members without explicit access. Users with the Administrator permission or space owners can always see all channels. + +
+ )} + + {canManageChannels && ( +
+ + +
+ )} +
+ + + setShowDeleteConfirm(false)} + onConfirm={handleDeleteChannel} + title={`Delete #${channel.name}?`} + description={<> + This will permanently delete #{channel.name} and all of its messages. + {channel.type === 'voice' && ' Any users currently in this voice channel will be disconnected.'} + {' '}This action cannot be undone. + } + confirmLabel="Delete Channel" + variant="danger" + loading={isDeleting} + /> + ); } diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 2bce1e5c..25aaa0b9 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -624,6 +624,15 @@ function handleEvent(origin: string, event: ServerEvent): void { useChatStore.setState({ unreadChannels: newUnread, readStates: newRS }); } } + // Clean up voice users for the deleted channel + { + const vs = useVoiceStore.getState(); + if (vs.voiceUsers.has(event.channelId)) { + const newVoiceUsers = new Map(vs.voiceUsers); + newVoiceUsers.delete(event.channelId); + useVoiceStore.setState({ voiceUsers: newVoiceUsers }); + } + } { const { currentChannelId } = useChatStore.getState(); if (currentChannelId === event.channelId) { diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index b6559ef1..4898e8d1 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -318,7 +318,9 @@ export const useSpaceStore = create((set, get) => ({ }, deleteChannel: async (channelId: string) => { - await api.channels.delete(channelId); + const origin = get().channelOriginMap.get(channelId) ?? ''; + const channelApi = getApiForOrigin(origin); + await channelApi.channels.delete(channelId); set((state) => ({ channels: state.channels.filter(c => c.id !== channelId), }));