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), }));