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.
This commit is contained in:
Jannis Braun
2026-03-12 00:46:15 +01:00
parent fc72e424d6
commit b6d44f1568
11 changed files with 27 additions and 20 deletions
+2 -2
View File
@@ -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
+11
View File
@@ -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 '%@%'"
+2 -2
View File
@@ -117,8 +117,8 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
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
+1 -1
View File
@@ -1348,7 +1348,7 @@ function handleVoiceMove(event: Record<string, unknown>, 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;
}
+1 -1
View File
@@ -923,7 +923,7 @@ function buildReadyPayload(userId: string): {
const voiceStates: Record<string, string[]> = {};
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);
+1 -1
View File
@@ -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;
@@ -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);
@@ -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) {
@@ -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
</label>
<div className="space-y-2">
{(['text', 'voice', 'video'] as const).map((t) => (
{(['text', 'voice'] as const).map((t) => (
<label
key={t}
className={`flex items-center gap-3 p-3 rounded cursor-pointer border ${
@@ -82,16 +82,12 @@ export function CreateChannelModal() {
{t === 'voice' && (
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" /></svg>
)}
{t === 'video' && (
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" /></svg>
)}
</div>
<div>
<div className="text-sm font-medium text-txt-primary capitalize">{t}</div>
<div className="text-xs text-txt-tertiary">
{t === 'text' && 'Send messages, images, and files'}
{t === 'voice' && 'Hang out with voice and video'}
{t === 'video' && 'Share your screen and camera'}
</div>
</div>
</label>
@@ -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?"
/>
</div>
@@ -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);
+2 -2
View File
@@ -57,7 +57,7 @@ interface SpaceState {
leaveSpace: (spaceId: string) => Promise<void>;
joinByCode: (inviteCode: string, origin?: string) => Promise<Space>;
generateInvite: (spaceId: string) => Promise<string>;
createChannel: (spaceId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => Promise<Channel>;
createChannel: (spaceId: string, name: string, type: 'text' | 'voice', topic?: string) => Promise<Channel>;
deleteChannel: (channelId: string) => Promise<void>;
addSpace: (space: Space) => void;
removeSpace: (spaceId: string) => void;
@@ -294,7 +294,7 @@ export const useSpaceStore = create<SpaceState>((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;