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,
|
id TEXT PRIMARY KEY,
|
||||||
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
type TEXT NOT NULL, -- 'text' | 'voice' | 'video'
|
type TEXT NOT NULL, -- 'text' | 'voice'
|
||||||
topic TEXT,
|
topic TEXT,
|
||||||
position INTEGER DEFAULT 0,
|
position INTEGER DEFAULT 0,
|
||||||
created_at INTEGER NOT NULL
|
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
|
- **Auth:** Registration (first user = admin), login, JWT sessions, username availability check
|
||||||
- **Spaces:** Create, join by invite, space settings, delete, ownership transfer
|
- **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
|
- **Messaging:** Send, edit, delete, replies, attachments, reactions, typing indicators, read states
|
||||||
- **Permissions:** Full RBAC with roles, per-channel overrides, computed permissions
|
- **Permissions:** Full RBAC with roles, per-channel overrides, computed permissions
|
||||||
- **Voice/Video:** LiveKit integration, mute/deafen, camera, screen share with VP9
|
- **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 ────────────────────────────────────────
|
// ─── Lowercase all existing usernames ────────────────────────────────────────
|
||||||
migrateLowercaseUsernames(db);
|
migrateLowercaseUsernames(db);
|
||||||
|
|
||||||
|
// ─── Convert video channels to voice (video type removed) ─────────────────
|
||||||
|
migrateVideoChannels(db);
|
||||||
|
|
||||||
console.log('Migrations complete.');
|
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 {
|
function migrateReplicatedUsernames(db: Database.Database): void {
|
||||||
const rows = db.prepare(
|
const rows = db.prepare(
|
||||||
"SELECT id, username, home_instance FROM users WHERE home_instance IS NOT NULL AND username NOT LIKE '%@%'"
|
"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 });
|
return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!type || !['text', 'voice', 'video'].includes(type)) {
|
if (!type || !['text', 'voice'].includes(type)) {
|
||||||
return reply.code(400).send({ error: 'Channel type must be "text", "voice", or "video"', statusCode: 400 });
|
return reply.code(400).send({ error: 'Channel type must be "text" or "voice"', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get max position for ordering
|
// 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' });
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Target channel not found in this space' });
|
||||||
return;
|
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' });
|
connectionManager.sendToUser(userId, { type: 'error', message: 'Target channel is not a voice channel' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -923,7 +923,7 @@ function buildReadyPayload(userId: string): {
|
|||||||
const voiceStates: Record<string, string[]> = {};
|
const voiceStates: Record<string, string[]> = {};
|
||||||
for (const space of spaces) {
|
for (const space of spaces) {
|
||||||
for (const ch of space.channels) {
|
for (const ch of space.channels) {
|
||||||
if (ch.type === 'voice' || ch.type === 'video') {
|
if (ch.type === 'voice') {
|
||||||
const participants = connectionManager.getRoomParticipants(ch.id);
|
const participants = connectionManager.getRoomParticipants(ch.id);
|
||||||
if (participants.size > 0) {
|
if (participants.size > 0) {
|
||||||
voiceStates[ch.id] = Array.from(participants);
|
voiceStates[ch.id] = Array.from(participants);
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export interface SpaceFolder {
|
|||||||
|
|
||||||
// ─── Channel Types ──────────────────────────────────────────────────────────
|
// ─── Channel Types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type ChannelType = 'text' | 'voice' | 'video';
|
export type ChannelType = 'text' | 'voice';
|
||||||
|
|
||||||
export interface Channel {
|
export interface Channel {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export function ChannelSidebar() {
|
|||||||
const canCreateInvite = hasPermissionBit(mySpacePerms, PermissionBits.CREATE_INVITE);
|
const canCreateInvite = hasPermissionBit(mySpacePerms, PermissionBits.CREATE_INVITE);
|
||||||
|
|
||||||
const textChannels = channels.filter(c => c.type === 'text');
|
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) => {
|
const handleChannelClick = (channelId: string) => {
|
||||||
setCurrentChannel(channelId);
|
setCurrentChannel(channelId);
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function MainContent() {
|
|||||||
|
|
||||||
// 2. LOGIC AND EARLY RETURNS
|
// 2. LOGIC AND EARLY RETURNS
|
||||||
const channel = channels.find(c => c.id === currentChannelId);
|
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 (showDms || isExplorePage || !currentSpaceId) {
|
||||||
if (!currentChannelId) {
|
if (!currentChannelId) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useSpaceStore } from '../../stores/spaceStore';
|
|||||||
|
|
||||||
export function CreateChannelModal() {
|
export function CreateChannelModal() {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [type, setType] = useState<'text' | 'voice' | 'video'>('text');
|
const [type, setType] = useState<'text' | 'voice'>('text');
|
||||||
const [topic, setTopic] = useState('');
|
const [topic, setTopic] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -58,7 +58,7 @@ export function CreateChannelModal() {
|
|||||||
Channel Type
|
Channel Type
|
||||||
</label>
|
</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(['text', 'voice', 'video'] as const).map((t) => (
|
{(['text', 'voice'] as const).map((t) => (
|
||||||
<label
|
<label
|
||||||
key={t}
|
key={t}
|
||||||
className={`flex items-center gap-3 p-3 rounded cursor-pointer border ${
|
className={`flex items-center gap-3 p-3 rounded cursor-pointer border ${
|
||||||
@@ -82,16 +82,12 @@ export function CreateChannelModal() {
|
|||||||
{t === 'voice' && (
|
{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>
|
<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>
|
<div>
|
||||||
<div className="text-sm font-medium text-txt-primary capitalize">{t}</div>
|
<div className="text-sm font-medium text-txt-primary capitalize">{t}</div>
|
||||||
<div className="text-xs text-txt-tertiary">
|
<div className="text-xs text-txt-tertiary">
|
||||||
{t === 'text' && 'Send messages, images, and files'}
|
{t === 'text' && 'Send messages, images, and files'}
|
||||||
{t === 'voice' && 'Hang out with voice and video'}
|
{t === 'voice' && 'Hang out with voice and video'}
|
||||||
{t === 'video' && 'Share your screen and camera'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -107,7 +103,7 @@ export function CreateChannelModal() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
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"
|
placeholder="new-channel"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -122,7 +118,7 @@ export function CreateChannelModal() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={topic}
|
value={topic}
|
||||||
onChange={(e) => setTopic(e.target.value)}
|
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?"
|
placeholder="What's this channel about?"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function VoiceModMenuItems({ targetUserId, channelId, onAction }: VoiceMo
|
|||||||
const canDisconnectMembers = hasPermissionBit(myPerms, PermissionBits.DISCONNECT_MEMBERS);
|
const canDisconnectMembers = hasPermissionBit(myPerms, PermissionBits.DISCONNECT_MEMBERS);
|
||||||
|
|
||||||
const otherVoiceChannels = channels.filter(
|
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);
|
const voiceOrigin = getChannelOrigin(channelId);
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ interface SpaceState {
|
|||||||
leaveSpace: (spaceId: string) => Promise<void>;
|
leaveSpace: (spaceId: string) => Promise<void>;
|
||||||
joinByCode: (inviteCode: string, origin?: string) => Promise<Space>;
|
joinByCode: (inviteCode: string, origin?: string) => Promise<Space>;
|
||||||
generateInvite: (spaceId: string) => Promise<string>;
|
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>;
|
deleteChannel: (channelId: string) => Promise<void>;
|
||||||
addSpace: (space: Space) => void;
|
addSpace: (space: Space) => void;
|
||||||
removeSpace: (spaceId: string) => void;
|
removeSpace: (spaceId: string) => void;
|
||||||
@@ -294,7 +294,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
return result.inviteCode;
|
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 });
|
const channel = await api.channels.create(spaceId, { name, type, topic });
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (state.channels.some(c => c.id === channel.id)) return state;
|
if (state.channels.some(c => c.id === channel.id)) return state;
|
||||||
|
|||||||
Reference in New Issue
Block a user