From b6d44f1568c6dd8042b37c8a416119c9d66c2b58 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 12 Mar 2026 00:46:15 +0100 Subject: [PATCH] refactor: remove video channel type, fix invisible CreateChannel inputs Voice channels already support video/screen share, so the separate video type was redundant. Adds migration to convert existing video channels. Also adds border-border-soft to CreateChannel input fields for visibility. --- CLAUDE.md | 4 ++-- packages/server/src/db/migrate.ts | 11 +++++++++++ packages/server/src/routes/channels.ts | 4 ++-- packages/server/src/ws/events.ts | 2 +- packages/server/src/ws/handler.ts | 2 +- packages/shared/src/types.ts | 2 +- .../web/src/components/layout/ChannelSidebar.tsx | 2 +- packages/web/src/components/layout/MainContent.tsx | 2 +- packages/web/src/components/modals/CreateChannel.tsx | 12 ++++-------- .../src/components/voice/VoiceUserContextMenu.tsx | 2 +- packages/web/src/stores/spaceStore.ts | 4 ++-- 11 files changed, 27 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bf3525be..7e033c12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -352,7 +352,7 @@ CREATE TABLE channels ( id TEXT PRIMARY KEY, space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, name TEXT NOT NULL, - type TEXT NOT NULL, -- 'text' | 'voice' | 'video' + type TEXT NOT NULL, -- 'text' | 'voice' topic TEXT, position INTEGER DEFAULT 0, created_at INTEGER NOT NULL @@ -846,7 +846,7 @@ All core features are implemented and live: - **Auth:** Registration (first user = admin), login, JWT sessions, username availability check - **Spaces:** Create, join by invite, space settings, delete, ownership transfer -- **Channels:** Text, voice, video types with position ordering +- **Channels:** Text and voice types with position ordering (voice channels support video/screen share) - **Messaging:** Send, edit, delete, replies, attachments, reactions, typing indicators, read states - **Permissions:** Full RBAC with roles, per-channel overrides, computed permissions - **Voice/Video:** LiveKit integration, mute/deafen, camera, screen share with VP9 diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index f4d3b29f..03c1ebf4 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -213,6 +213,9 @@ export function runMigrations(db: Database.Database): void { // ─── Lowercase all existing usernames ──────────────────────────────────────── migrateLowercaseUsernames(db); + // ─── Convert video channels to voice (video type removed) ───────────────── + migrateVideoChannels(db); + console.log('Migrations complete.'); } @@ -583,6 +586,14 @@ function migrateLowercaseUsernames(db: Database.Database): void { } } +/** Convert any existing video channels to voice (video type removed — voice channels have full video capability) */ +function migrateVideoChannels(db: Database.Database): void { + const result = db.prepare("UPDATE channels SET type = 'voice' WHERE type = 'video'").run(); + if (result.changes > 0) { + console.log(`Migrating: Converted ${result.changes} video channel(s) to voice`); + } +} + function migrateReplicatedUsernames(db: Database.Database): void { const rows = db.prepare( "SELECT id, username, home_instance FROM users WHERE home_instance IS NOT NULL AND username NOT LIKE '%@%'" diff --git a/packages/server/src/routes/channels.ts b/packages/server/src/routes/channels.ts index 920b5aa0..e9668af5 100644 --- a/packages/server/src/routes/channels.ts +++ b/packages/server/src/routes/channels.ts @@ -117,8 +117,8 @@ export async function channelRoutes(app: FastifyInstance): Promise { return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 }); } - if (!type || !['text', 'voice', 'video'].includes(type)) { - return reply.code(400).send({ error: 'Channel type must be "text", "voice", or "video"', statusCode: 400 }); + if (!type || !['text', 'voice'].includes(type)) { + return reply.code(400).send({ error: 'Channel type must be "text" or "voice"', statusCode: 400 }); } // Get max position for ordering diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index ac67ef5b..1310f8bf 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -1348,7 +1348,7 @@ function handleVoiceMove(event: Record, userId: string): void { connectionManager.sendToUser(userId, { type: 'error', message: 'Target channel not found in this space' }); return; } - if (targetChannel.type !== 'voice' && targetChannel.type !== 'video') { + if (targetChannel.type !== 'voice') { connectionManager.sendToUser(userId, { type: 'error', message: 'Target channel is not a voice channel' }); return; } diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 45d6bd0e..4f7ca227 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -923,7 +923,7 @@ function buildReadyPayload(userId: string): { const voiceStates: Record = {}; for (const space of spaces) { for (const ch of space.channels) { - if (ch.type === 'voice' || ch.type === 'video') { + if (ch.type === 'voice') { const participants = connectionManager.getRoomParticipants(ch.id); if (participants.size > 0) { voiceStates[ch.id] = Array.from(participants); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 535e5b1c..c12a2211 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -123,7 +123,7 @@ export interface SpaceFolder { // ─── Channel Types ────────────────────────────────────────────────────────── -export type ChannelType = 'text' | 'voice' | 'video'; +export type ChannelType = 'text' | 'voice'; export interface Channel { id: string; diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index b0e513cf..e8ad03c7 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -98,7 +98,7 @@ export function ChannelSidebar() { const canCreateInvite = hasPermissionBit(mySpacePerms, PermissionBits.CREATE_INVITE); const textChannels = channels.filter(c => c.type === 'text'); - const voiceChannels = channels.filter(c => c.type === 'voice' || c.type === 'video'); + const voiceChannels = channels.filter(c => c.type === 'voice'); const handleChannelClick = (channelId: string) => { setCurrentChannel(channelId); diff --git a/packages/web/src/components/layout/MainContent.tsx b/packages/web/src/components/layout/MainContent.tsx index 3fc95f14..edf0f1ff 100644 --- a/packages/web/src/components/layout/MainContent.tsx +++ b/packages/web/src/components/layout/MainContent.tsx @@ -72,7 +72,7 @@ export function MainContent() { // 2. LOGIC AND EARLY RETURNS const channel = channels.find(c => c.id === currentChannelId); - const isVoiceChannel = channel?.type === 'voice' || channel?.type === 'video'; + const isVoiceChannel = channel?.type === 'voice'; if (showDms || isExplorePage || !currentSpaceId) { if (!currentChannelId) { diff --git a/packages/web/src/components/modals/CreateChannel.tsx b/packages/web/src/components/modals/CreateChannel.tsx index 13141638..efaea66c 100644 --- a/packages/web/src/components/modals/CreateChannel.tsx +++ b/packages/web/src/components/modals/CreateChannel.tsx @@ -5,7 +5,7 @@ import { useSpaceStore } from '../../stores/spaceStore'; export function CreateChannelModal() { const [name, setName] = useState(''); - const [type, setType] = useState<'text' | 'voice' | 'video'>('text'); + const [type, setType] = useState<'text' | 'voice'>('text'); const [topic, setTopic] = useState(''); const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -58,7 +58,7 @@ export function CreateChannelModal() { Channel Type
- {(['text', 'voice', 'video'] as const).map((t) => ( + {(['text', 'voice'] as const).map((t) => (
{t}
{t === 'text' && 'Send messages, images, and files'} {t === 'voice' && 'Hang out with voice and video'} - {t === 'video' && 'Share your screen and camera'}
@@ -107,7 +103,7 @@ export function CreateChannelModal() { type="text" value={name} onChange={(e) => setName(e.target.value)} - className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary" + className="w-full px-3 py-2 bg-surface-input border border-border-soft rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-colors" placeholder="new-channel" autoFocus /> @@ -122,7 +118,7 @@ export function CreateChannelModal() { type="text" value={topic} onChange={(e) => setTopic(e.target.value)} - className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary" + className="w-full px-3 py-2 bg-surface-input border border-border-soft rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-colors" placeholder="What's this channel about?" /> diff --git a/packages/web/src/components/voice/VoiceUserContextMenu.tsx b/packages/web/src/components/voice/VoiceUserContextMenu.tsx index 233cc22f..712b22d3 100644 --- a/packages/web/src/components/voice/VoiceUserContextMenu.tsx +++ b/packages/web/src/components/voice/VoiceUserContextMenu.tsx @@ -32,7 +32,7 @@ export function VoiceModMenuItems({ targetUserId, channelId, onAction }: VoiceMo const canDisconnectMembers = hasPermissionBit(myPerms, PermissionBits.DISCONNECT_MEMBERS); const otherVoiceChannels = channels.filter( - (c) => (c.type === 'voice' || c.type === 'video') && c.id !== channelId, + (c) => c.type === 'voice' && c.id !== channelId, ); const voiceOrigin = getChannelOrigin(channelId); diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index b5f872c2..41be460c 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -57,7 +57,7 @@ interface SpaceState { leaveSpace: (spaceId: string) => Promise; joinByCode: (inviteCode: string, origin?: string) => Promise; generateInvite: (spaceId: string) => Promise; - createChannel: (spaceId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => Promise; + createChannel: (spaceId: string, name: string, type: 'text' | 'voice', topic?: string) => Promise; deleteChannel: (channelId: string) => Promise; addSpace: (space: Space) => void; removeSpace: (spaceId: string) => void; @@ -294,7 +294,7 @@ export const useSpaceStore = create((set, get) => ({ return result.inviteCode; }, - createChannel: async (spaceId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => { + createChannel: async (spaceId: string, name: string, type: 'text' | 'voice', topic?: string) => { const channel = await api.channels.create(spaceId, { name, type, topic }); set((state) => { if (state.channels.some(c => c.id === channel.id)) return state;