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.
This commit is contained in:
Jannis Braun
2026-03-12 02:44:42 +01:00
parent 5193d79c39
commit 88ca2d532b
4 changed files with 171 additions and 61 deletions
+33 -1
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from 'fastify'; 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 { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js'; import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.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 { permissionsToString } from '@backspace/shared/src/permissions.js';
import { connectionManager } from '../ws/handler.js'; import { connectionManager } from '../ws/handler.js';
import { checkVoicePermissions } from '../ws/events.js'; import { checkVoicePermissions } from '../ws/events.js';
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
import type { import type {
CreateChannelRequest, CreateChannelRequest,
UpdateChannelRequest, UpdateChannelRequest,
@@ -285,6 +286,21 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 }); 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) // Collect viewers BEFORE deleting (overrides CASCADE-delete with the channel)
const viewerIds: string[] = []; const viewerIds: string[] = [];
for (const [uid, spaceIds] of connectionManager.getUserSpaceEntries()) { for (const [uid, spaceIds] of connectionManager.getUserSpaceEntries()) {
@@ -296,10 +312,26 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
} }
} }
// 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 // Delete messages in channel (attachments cascade), then channel
db.delete(schema.messages).where(eq(schema.messages.channelId, id)).run(); db.delete(schema.messages).where(eq(schema.messages.channelId, id)).run();
db.delete(schema.channels).where(eq(schema.channels.id, 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 // Broadcast channel_deleted only to users who could see the channel
const deleteEvent = { type: 'channel_deleted' as const, channelId: id, spaceId }; const deleteEvent = { type: 'channel_deleted' as const, channelId: id, spaceId };
for (const uid of viewerIds) { for (const uid of viewerIds) {
@@ -1,9 +1,12 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Modal } from '../ui/Modal'; import { Modal } from '../ui/Modal';
import { ConfirmDialog } from '../ui/ConfirmDialog';
import { useUIStore } from '../../stores/uiStore'; 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 { api } from '../../api/client';
import { PermissionBits, permissionsToString, stringToPermissions } from '../../utils/permissions'; import { PermissionBits, permissionsToString, stringToPermissions, hasPermissionBit } from '../../utils/permissions';
interface ChannelOverride { interface ChannelOverride {
channelId: string; channelId: string;
@@ -19,16 +22,33 @@ export function ChannelSettingsModal() {
const closeModal = useUIStore((s) => s.closeModal); const closeModal = useUIStore((s) => s.closeModal);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const channels = useSpaceStore((s) => s.channels); 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 [isPrivate, setIsPrivate] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isFetching, setIsFetching] = useState(true); const [isFetching, setIsFetching] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isOpen = activeModal === 'channelSettings'; const isOpen = activeModal === 'channelSettings';
const channelId = modalData?.channelId as string | undefined; const channelId = modalData?.channelId as string | undefined;
const channel = channels.find(c => c.id === channelId); 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 // Fetch overrides when modal opens
useEffect(() => { useEffect(() => {
if (!isOpen || !channelId || !currentSpaceId) { if (!isOpen || !channelId || !currentSpaceId) {
@@ -88,7 +108,25 @@ 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 ( return (
<>
<Modal isOpen={isOpen} onClose={closeModal} title="Channel Settings" maxWidth="max-w-md"> <Modal isOpen={isOpen} onClose={closeModal} title="Channel Settings" maxWidth="max-w-md">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
@@ -148,7 +186,36 @@ export function ChannelSettingsModal() {
</span> </span>
</div> </div>
)} )}
{canManageChannels && (
<div className="pt-4 border-t border-border-soft">
<label className="block text-xs font-bold text-accent-rose uppercase mb-2">Danger Zone</label>
<button
onClick={() => setShowDeleteConfirm(true)}
disabled={isDeleting}
className="w-full px-3 py-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-accent-rose text-sm font-medium hover:bg-accent-rose/20 transition-colors disabled:opacity-50"
>
Delete Channel
</button>
</div>
)}
</div> </div>
</Modal> </Modal>
<ConfirmDialog
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
onConfirm={handleDeleteChannel}
title={`Delete #${channel.name}?`}
description={<>
This will permanently delete <strong>#{channel.name}</strong> 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}
/>
</>
); );
} }
+9
View File
@@ -624,6 +624,15 @@ function handleEvent(origin: string, event: ServerEvent): void {
useChatStore.setState({ unreadChannels: newUnread, readStates: newRS }); 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(); const { currentChannelId } = useChatStore.getState();
if (currentChannelId === event.channelId) { if (currentChannelId === event.channelId) {
+3 -1
View File
@@ -318,7 +318,9 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
}, },
deleteChannel: async (channelId: string) => { 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) => ({ set((state) => ({
channels: state.channels.filter(c => c.id !== channelId), channels: state.channels.filter(c => c.id !== channelId),
})); }));