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:
@@ -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<void> {
|
||||
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<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
|
||||
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) {
|
||||
|
||||
@@ -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,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 (
|
||||
<>
|
||||
<Modal isOpen={isOpen} onClose={closeModal} title="Channel Settings" maxWidth="max-w-md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
@@ -148,7 +186,36 @@ export function ChannelSettingsModal() {
|
||||
</span>
|
||||
</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>
|
||||
</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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -318,7 +318,9 @@ export const useSpaceStore = create<SpaceState>((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),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user