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:
@@ -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
|
||||
|
||||
@@ -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 '%@%'"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user