feat: Implement initial Electron frontend with core UI, user and server settings, chat, and voice features, along with Convex backend schemas and functions.
Some checks failed
Build and Release / build-and-release (push) Has been cancelled

This commit is contained in:
Bryan1029384756
2026-02-13 10:29:24 -06:00
parent 56a9523e38
commit 556a561449
15 changed files with 648 additions and 57 deletions

View File

@@ -14,20 +14,20 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E
## Key Convex Files (convex/)
- `schema.ts` - Full schema: userProfiles (with avatarStorageId, aboutMe, customStatus), categories (name, position), channels (with categoryId, topic, position), messages, messageReactions, channelKeys, roles, userRoles, invites, dmParticipants, typingIndicators, voiceStates, channelReadState, serverSettings (afkChannelId, afkTimeout)
- `auth.ts` - getSalt, verifyUser, createUserWithProfile, getPublicKeys (includes avatarUrl, aboutMe, customStatus), updateProfile, updateStatus
- `schema.ts` - Full schema: userProfiles (with avatarStorageId, aboutMe, customStatus, joinSoundStorageId), categories (name, position), channels (with categoryId, topic, position), messages, messageReactions, channelKeys, roles, userRoles, invites, dmParticipants, typingIndicators, voiceStates, channelReadState, serverSettings (serverName, afkChannelId, afkTimeout, iconStorageId)
- `auth.ts` - getSalt, verifyUser, createUserWithProfile, getPublicKeys (includes avatarUrl, aboutMe, customStatus, joinSoundUrl), updateProfile (includes joinSoundStorageId, removeJoinSound), updateStatus, getMyJoinSoundUrl
- `categories.ts` - list, create, rename, remove, reorder
- `channels.ts` - list, get, create (with categoryId/topic/position), rename, remove (cascade), updateTopic, moveChannel, reorderChannels
- `members.ts` - getChannelMembers (includes isHoist on roles, avatarUrl, aboutMe, customStatus)
- `channelKeys.ts` - uploadKeys, getKeysForUser
- `messages.ts` - list (with reactions + username), send, edit, pin, listPinned, remove (with manage_messages permission check)
- `reactions.ts` - add, remove
- `serverSettings.ts` - get, update (manage_channels permission), clearAfkChannel (internal)
- `serverSettings.ts` - get (resolves iconUrl), update (manage_channels permission), updateName (manage_channels permission), updateIcon (manage_channels permission), clearAfkChannel (internal)
- `typing.ts` - startTyping, stopTyping, getTyping, cleanExpired (scheduled)
- `dms.ts` - openDM, listDMs
- `invites.ts` - create, use, revoke
- `roles.ts` - list, create, update, remove, listMembers, assign, unassign, getMyPermissions
- `voiceState.ts` - join, leave, updateState, getAll, afkMove (self-move to AFK channel)
- `voiceState.ts` - join, leave, updateState, getAll (includes joinSoundUrl per user), afkMove (self-move to AFK channel)
- `voice.ts` - getToken (Node action, livekit-server-sdk)
- `files.ts` - generateUploadUrl, getFileUrl
- `gifs.ts` - search, categories (Node actions, Tenor API)
@@ -41,7 +41,7 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E
- `pages/Chat.jsx` - useQuery for channels, categories, channelKeys, DMs
- `components/ChatArea.jsx` - Messages, typing, reactions via Convex queries/mutations
- `components/Sidebar.jsx` - Channel/category creation, key distribution, invites, drag-and-drop reordering via @dnd-kit
- `contexts/VoiceContext.jsx` - Voice state via Convex + LiveKit room management
- `contexts/VoiceContext.jsx` - Voice state via Convex + LiveKit room management + custom join sound playback
- `components/ChannelSettingsModal.jsx` - Channel rename/delete via Convex mutations
- `components/ServerSettingsModal.jsx` - Role management via Convex queries/mutations
- `components/MessageItem.jsx` - Individual message rendering with unread divider support
@@ -73,7 +73,10 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E
- Voice connected panel includes elapsed time timer
- Keyboard shortcuts: Ctrl+K (quick switcher), Ctrl+Shift+M (mute toggle)
- Unread tracking: `channelReadState` table stores last-read timestamp per user/channel. ChatArea shows red "NEW" divider, Sidebar shows white dot on unread channels
- Server name: `serverSettings` singleton stores `serverName` (default "Secure Chat"), editable from Server Settings Overview tab (requires `manage_channels`). Sidebar header, tooltip, voice panel, Chat header, and welcome text all use the dynamic name.
- AFK voice channel: `serverSettings` singleton table stores `afkChannelId` + `afkTimeout`. VoiceContext polls `idleAPI.getSystemIdleTime()` every 15s; auto-moves idle users to AFK channel via `voiceState.afkMove`. Users in AFK channel are force-muted and can't unmute. Sidebar shows "(AFK)" label. Server Settings Overview tab has AFK config UI.
- Custom join sounds: Users can upload a custom audio file (max 10MB) via User Settings > My Account. Stored as `joinSoundStorageId` on `userProfiles`. When joining voice, `VoiceContext` plays the user's custom sound (or default `join_call.mp3`). Other users in the channel hear the joiner's custom sound via reactive `voiceStates` tracking (not LiveKit events) to avoid race conditions with URL availability.
- Server icon: `serverSettings` stores `iconStorageId`. `get` query resolves it to `iconUrl`. Server Settings Overview tab has upload UI using `AvatarCropModal` with `cropShape="rect"` (square). Sidebar displays the icon image in the server strip (fallback to text initials). `AvatarCropModal` accepts a `cropShape` prop (`"round"` default for avatars, `"rect"` for server icon).
## Environment Variables

View File

@@ -1,7 +1,7 @@
{
"name": "discord",
"private": true,
"version": "1.0.11",
"version": "1.0.12",
"description": "A Discord clone built with Convex, React, and Electron",
"author": "Moyettes",
"type": "module",

View File

@@ -25,7 +25,7 @@ function getCroppedImg(imageSrc, pixelCrop) {
});
}
const AvatarCropModal = ({ imageUrl, onApply, onCancel }) => {
const AvatarCropModal = ({ imageUrl, onApply, onCancel, cropShape = 'round' }) => {
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState(null);
@@ -77,7 +77,7 @@ const AvatarCropModal = ({ imageUrl, onApply, onCancel }) => {
crop={crop}
zoom={zoom}
aspect={1}
cropShape="round"
cropShape={cropShape}
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
@@ -8,6 +8,12 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
const convex = useConvex();
useEffect(() => {
const handleKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [onClose]);
const handleSave = async () => {
try {
await convex.mutation(api.channels.rename, { id: channel._id, name });
@@ -100,29 +106,11 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
</div>
{/* Content */}
<div style={{ flex: 1, padding: '60px 40px', maxWidth: '740px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '20px' }}>
<h2 style={{ color: 'var(--header-primary)', margin: 0 }}>
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-start', overflowY: 'auto' }}>
<div style={{ flex: 1, maxWidth: '740px', padding: '60px 40px 80px', position: 'relative' }}>
<h2 style={{ color: 'var(--header-primary)', margin: 0, marginBottom: '20px' }}>
{activeTab === 'Delete' ? 'Delete Channel' : 'Overview'}
</h2>
<button
onClick={onClose}
style={{
background: 'transparent',
border: '1px solid var(--header-secondary)',
borderRadius: '50%',
width: '36px',
height: '36px',
color: 'var(--header-secondary)',
cursor: 'pointer',
display: 'flex', allignItems: 'center', justifyContent: 'center'
}}
>
<svg width="12" height="12" viewBox="0 0 12 12">
<polygon fill="currentColor" points="11,1.576 10.424,1 6,5.424 1.576,1 1,1.576 5.424,6 1,10.424 1.576,11 6,6.576 10.424,11 11,10.424 6.576,6" />
</svg>
</button>
</div>
{activeTab === 'Overview' && (
<>
@@ -198,10 +186,25 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
</div>
)}
</div>
<div style={{ flex: 0.5 }}>
{/* Right side spacer like real Discord */}
</div>
<div style={{ flex: '0 0 36px', paddingTop: '60px', marginLeft: '8px' }}>
<button
onClick={onClose}
style={{
width: '36px', height: '36px', borderRadius: '50%',
border: '2px solid var(--header-secondary)', background: 'transparent',
color: 'var(--header-secondary)', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '18px',
}}
>
</button>
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--header-secondary)', textAlign: 'center', marginTop: '4px' }}>
ESC
</div>
</div>
<div style={{ flex: '0.5' }} />
</div>
</div>
);

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useQuery, useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import AvatarCropModal from './AvatarCropModal';
const TIMEOUT_OPTIONS = [
{ value: 60, label: '1 min' },
@@ -25,22 +26,53 @@ const ServerSettingsModal = ({ onClose }) => {
userId ? { userId } : "skip"
) || {};
// AFK settings
// Server settings
const serverSettings = useQuery(api.serverSettings.get);
const channels = useQuery(api.channels.list) || [];
const voiceChannels = channels.filter(c => c.type === 'voice');
const [serverName, setServerName] = useState('Secure Chat');
const [serverNameDirty, setServerNameDirty] = useState(false);
const [afkChannelId, setAfkChannelId] = useState('');
const [afkTimeout, setAfkTimeout] = useState(300);
const [afkDirty, setAfkDirty] = useState(false);
const [iconFile, setIconFile] = useState(null);
const [iconPreview, setIconPreview] = useState(null);
const [rawIconUrl, setRawIconUrl] = useState(null);
const [showIconCropModal, setShowIconCropModal] = useState(false);
const [iconDirty, setIconDirty] = useState(false);
const [savingIcon, setSavingIcon] = useState(false);
const iconInputRef = useRef(null);
useEffect(() => {
const handleKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [onClose]);
React.useEffect(() => {
if (serverSettings) {
setServerName(serverSettings.serverName || 'Secure Chat');
setServerNameDirty(false);
setAfkChannelId(serverSettings.afkChannelId || '');
setAfkTimeout(serverSettings.afkTimeout || 300);
setAfkDirty(false);
}
}, [serverSettings]);
const handleSaveServerName = async () => {
if (!userId) return;
try {
await convex.mutation(api.serverSettings.updateName, {
userId,
serverName,
});
setServerNameDirty(false);
} catch (e) {
console.error('Failed to update server name:', e);
alert('Failed to save server name: ' + e.message);
}
};
const handleSaveAfkSettings = async () => {
if (!userId) return;
try {
@@ -56,6 +88,89 @@ const ServerSettingsModal = ({ onClose }) => {
}
};
const handleIconFileChange = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const url = URL.createObjectURL(file);
setRawIconUrl(url);
setShowIconCropModal(true);
e.target.value = '';
};
const handleIconCropApply = (blob) => {
const file = new File([blob], 'server-icon.png', { type: 'image/png' });
setIconFile(file);
const previewUrl = URL.createObjectURL(blob);
setIconPreview(previewUrl);
if (rawIconUrl) URL.revokeObjectURL(rawIconUrl);
setRawIconUrl(null);
setShowIconCropModal(false);
setIconDirty(true);
};
const handleIconCropCancel = () => {
if (rawIconUrl) URL.revokeObjectURL(rawIconUrl);
setRawIconUrl(null);
setShowIconCropModal(false);
};
const handleSaveIcon = async () => {
if (!userId || savingIcon) return;
setSavingIcon(true);
try {
let iconStorageId;
if (iconFile) {
const uploadUrl = await convex.mutation(api.files.generateUploadUrl);
const res = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': iconFile.type },
body: iconFile,
});
const { storageId } = await res.json();
iconStorageId = storageId;
}
await convex.mutation(api.serverSettings.updateIcon, {
userId,
iconStorageId,
});
setIconFile(null);
setIconDirty(false);
if (iconPreview) {
URL.revokeObjectURL(iconPreview);
setIconPreview(null);
}
} catch (e) {
console.error('Failed to update server icon:', e);
alert('Failed to save server icon: ' + e.message);
} finally {
setSavingIcon(false);
}
};
const handleRemoveIcon = async () => {
if (!userId) return;
setSavingIcon(true);
try {
await convex.mutation(api.serverSettings.updateIcon, {
userId,
iconStorageId: undefined,
});
setIconFile(null);
setIconDirty(false);
if (iconPreview) {
URL.revokeObjectURL(iconPreview);
setIconPreview(null);
}
} catch (e) {
console.error('Failed to remove server icon:', e);
alert('Failed to remove server icon: ' + e.message);
} finally {
setSavingIcon(false);
}
};
const currentIconUrl = iconPreview || serverSettings?.iconUrl;
const handleCreateRole = async () => {
try {
const newRole = await convex.mutation(api.roles.create, {
@@ -102,7 +217,7 @@ const ServerSettingsModal = ({ onClose }) => {
const renderSidebar = () => (
<div style={{
width: '218px',
backgroundColor: 'var(--bg-tertiary)',
backgroundColor: 'var(--bg-secondary)',
display: 'flex', flexDirection: 'column', alignItems: 'flex-end', padding: '60px 6px 60px 20px'
}}>
<div style={{ width: '100%', padding: '0 10px' }}>
@@ -258,7 +373,97 @@ const ServerSettingsModal = ({ onClose }) => {
case 'Members': return renderMembersTab();
default: return (
<div>
<div style={{ color: 'var(--header-secondary)', marginBottom: 30 }}>Server Name: Secure Chat<br/>Region: US-East</div>
<label style={labelStyle}>SERVER ICON</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: 20 }}>
<div
className="server-icon-upload-wrapper"
onClick={() => myPermissions.manage_channels && iconInputRef.current?.click()}
style={{ cursor: myPermissions.manage_channels ? 'pointer' : 'default', opacity: myPermissions.manage_channels ? 1 : 0.5 }}
>
{currentIconUrl ? (
<img src={currentIconUrl} alt="Server Icon" style={{ width: 80, height: 80, objectFit: 'cover', borderRadius: '16px' }} />
) : (
<div style={{
width: 80, height: 80, borderRadius: '16px',
backgroundColor: 'var(--bg-tertiary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--text-normal)', fontWeight: 600, fontSize: '24px',
}}>
{serverName.substring(0, 2)}
</div>
)}
{myPermissions.manage_channels && (
<div className="server-icon-upload-overlay">
CHANGE<br/>ICON
</div>
)}
<input
ref={iconInputRef}
type="file"
accept="image/*"
onChange={handleIconFileChange}
style={{ display: 'none' }}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{iconDirty && myPermissions.manage_channels && (
<button
onClick={handleSaveIcon}
disabled={savingIcon}
style={{
backgroundColor: '#5865F2', color: '#fff', border: 'none',
borderRadius: 4, padding: '8px 16px', cursor: savingIcon ? 'not-allowed' : 'pointer',
fontWeight: 600, fontSize: 14, opacity: savingIcon ? 0.5 : 1,
}}
>
{savingIcon ? 'Saving...' : 'Upload Icon'}
</button>
)}
{currentIconUrl && !iconDirty && myPermissions.manage_channels && (
<button
onClick={handleRemoveIcon}
disabled={savingIcon}
style={{
backgroundColor: 'transparent', color: '#ed4245', border: '1px solid #ed4245',
borderRadius: 4, padding: '6px 12px', cursor: 'pointer',
fontWeight: 600, fontSize: 13,
}}
>
Remove Icon
</button>
)}
</div>
</div>
<label style={labelStyle}>SERVER NAME</label>
<input
value={serverName}
onChange={(e) => { setServerName(e.target.value); setServerNameDirty(true); }}
disabled={!myPermissions.manage_channels}
maxLength={100}
style={{
width: '100%', padding: 10, background: 'var(--bg-tertiary)', border: 'none',
borderRadius: 4, color: 'var(--header-primary)', marginBottom: 8,
opacity: myPermissions.manage_channels ? 1 : 0.5,
}}
/>
{serverNameDirty && myPermissions.manage_channels && (
<button
onClick={handleSaveServerName}
disabled={!serverName.trim()}
style={{
backgroundColor: '#5865F2', color: '#fff', border: 'none',
borderRadius: 4, padding: '8px 16px', cursor: 'pointer',
fontWeight: 600, fontSize: 14, marginBottom: 20,
opacity: serverName.trim() ? 1 : 0.5,
}}
>
Save Changes
</button>
)}
{!serverNameDirty && <div style={{ marginBottom: 12 }} />}
<div style={{ color: 'var(--header-secondary)', marginBottom: 20, fontSize: '14px' }}>Region: US-East</div>
<label style={labelStyle}>INACTIVE CHANNEL</label>
<select
@@ -315,13 +520,38 @@ const ServerSettingsModal = ({ onClose }) => {
return (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'var(--bg-primary)', zIndex: 1000, display: 'flex', color: 'var(--text-normal)' }}>
{renderSidebar()}
<div style={{ flex: 1, padding: '60px 40px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 20 }}>
<h2 style={{ color: 'var(--header-primary)', margin: 0 }}>{activeTab}</h2>
<button onClick={onClose} style={{ background: 'transparent', border: '1px solid #b9bbbe', borderRadius: '50%', width: 36, height: 36, color: 'var(--header-secondary)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}></button>
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-start', overflowY: 'auto' }}>
<div style={{ flex: 1, maxWidth: '740px', padding: '60px 40px 80px', position: 'relative' }}>
<h2 style={{ color: 'var(--header-primary)', margin: 0, marginBottom: 20 }}>{activeTab}</h2>
{renderTabContent()}
</div>
{renderTabContent()}
<div style={{ flex: '0 0 36px', paddingTop: '60px', marginLeft: '8px' }}>
<button
onClick={onClose}
style={{
width: '36px', height: '36px', borderRadius: '50%',
border: '2px solid var(--header-secondary)', background: 'transparent',
color: 'var(--header-secondary)', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '18px',
}}
>
</button>
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--header-secondary)', textAlign: 'center', marginTop: '4px' }}>
ESC
</div>
</div>
<div style={{ flex: '0.5' }} />
</div>
{showIconCropModal && rawIconUrl && (
<AvatarCropModal
imageUrl={rawIconUrl}
onApply={handleIconCropApply}
onCancel={handleIconCropCancel}
cropShape="rect"
/>
)}
</div>
);
};

View File

@@ -752,7 +752,7 @@ const DraggableVoiceUser = ({ userId, channelId, disabled, children }) => {
);
};
const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels, userId }) => {
const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels, userId, serverName = 'Secure Chat', serverIconUrl }) => {
const [isCreating, setIsCreating] = useState(false);
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
const [newChannelName, setNewChannelName] = useState('');
@@ -1313,7 +1313,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
const renderServerView = () => (
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', backgroundColor: 'var(--bg-tertiary)', borderLeft: '1px solid var(--app-frame-border)', borderTop: '1px solid var(--app-frame-border)', borderRadius: '8px 0 0 0' }}>
<div className="server-header" style={{ borderBottom: '1px solid var(--app-frame-border)' }}>
<span className="server-header-name" onClick={() => setIsServerSettingsOpen(true)}>Secure Chat</span>
<span className="server-header-name" onClick={() => setIsServerSettingsOpen(true)}>{serverName}</span>
<button className="server-header-invite" onClick={handleCreateInvite} title="Invite People">
<img src={inviteUserIcon} alt="Invite" />
</button>
@@ -1548,12 +1548,18 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
<div className="server-item-wrapper">
<div className={`server-pill ${view === 'server' ? 'active' : ''}`} />
<Tooltip text="Secure Chat" position="right">
<Tooltip text={serverName} position="right">
<div
className={`server-icon ${view === 'server' ? 'active' : ''}`}
onClick={() => onViewChange('server')}
style={{ cursor: 'pointer' }}
>Sc</div>
>
{serverIconUrl ? (
<img src={serverIconUrl} alt={serverName} style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 'inherit' }} />
) : (
serverName.substring(0, 2)
)}
</div>
</Tooltip>
</div>
</div>
@@ -1583,7 +1589,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
<ColoredIcon src={disconnectIcon} color="var(--header-secondary)" size="20px" />
</button>
</div>
<div style={{ color: 'var(--text-normal)', fontSize: 12, marginBottom: 4 }}>{voiceChannelName} / Secure Chat</div>
<div style={{ color: 'var(--text-normal)', fontSize: 12, marginBottom: 4 }}>{voiceChannelName} / {serverName}</div>
<div style={{ marginBottom: 8 }}><VoiceTimer /></div>
<div style={{ display: 'flex', gap: 4 }}>
<button onClick={() => room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)} title="Turn On Camera" style={voicePanelButtonStyle}>

View File

@@ -155,6 +155,11 @@ const MyAccountTab = ({ userId, username }) => {
const [showCropModal, setShowCropModal] = useState(false);
const [rawImageUrl, setRawImageUrl] = useState(null);
const fileInputRef = useRef(null);
const [joinSoundFile, setJoinSoundFile] = useState(null);
const [joinSoundPreviewName, setJoinSoundPreviewName] = useState(null);
const [removeJoinSound, setRemoveJoinSound] = useState(false);
const joinSoundInputRef = useRef(null);
const joinSoundAudioRef = useRef(null);
useEffect(() => {
if (currentUser) {
@@ -170,9 +175,11 @@ const MyAccountTab = ({ userId, username }) => {
displayName !== (currentUser.displayName || '') ||
aboutMe !== (currentUser.aboutMe || '') ||
customStatus !== (currentUser.customStatus || '') ||
avatarFile !== null;
avatarFile !== null ||
joinSoundFile !== null ||
removeJoinSound;
setHasChanges(changed);
}, [displayName, aboutMe, customStatus, avatarFile, currentUser]);
}, [displayName, aboutMe, customStatus, avatarFile, joinSoundFile, removeJoinSound, currentUser]);
const handleAvatarChange = (e) => {
const file = e.target.files?.[0];
@@ -199,6 +206,45 @@ const MyAccountTab = ({ userId, username }) => {
setShowCropModal(false);
};
const handleJoinSoundChange = (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 10 * 1024 * 1024) {
alert('Join sound must be under 10MB');
e.target.value = '';
return;
}
setJoinSoundFile(file);
setJoinSoundPreviewName(file.name);
setRemoveJoinSound(false);
e.target.value = '';
};
const handleJoinSoundPreview = () => {
if (joinSoundAudioRef.current) {
joinSoundAudioRef.current.pause();
joinSoundAudioRef.current = null;
}
let src;
if (joinSoundFile) {
src = URL.createObjectURL(joinSoundFile);
} else if (currentUser?.joinSoundUrl) {
src = currentUser.joinSoundUrl;
}
if (src) {
const audio = new Audio(src);
audio.volume = 0.5;
audio.play().catch(e => console.error('Preview failed', e));
joinSoundAudioRef.current = audio;
}
};
const handleRemoveJoinSound = () => {
setJoinSoundFile(null);
setJoinSoundPreviewName(null);
setRemoveJoinSound(true);
};
const handleSave = async () => {
if (!userId || saving) return;
setSaving(true);
@@ -214,10 +260,26 @@ const MyAccountTab = ({ userId, username }) => {
const { storageId } = await res.json();
avatarStorageId = storageId;
}
let joinSoundStorageId;
if (joinSoundFile) {
const jsUploadUrl = await convex.mutation(api.files.generateUploadUrl);
const jsRes = await fetch(jsUploadUrl, {
method: 'POST',
headers: { 'Content-Type': joinSoundFile.type },
body: joinSoundFile,
});
const jsData = await jsRes.json();
joinSoundStorageId = jsData.storageId;
}
const args = { userId, displayName, aboutMe, customStatus };
if (avatarStorageId) args.avatarStorageId = avatarStorageId;
if (joinSoundStorageId) args.joinSoundStorageId = joinSoundStorageId;
if (removeJoinSound) args.removeJoinSound = true;
await convex.mutation(api.auth.updateProfile, args);
setAvatarFile(null);
setJoinSoundFile(null);
setJoinSoundPreviewName(null);
setRemoveJoinSound(false);
if (avatarPreview) {
URL.revokeObjectURL(avatarPreview);
setAvatarPreview(null);
@@ -237,6 +299,9 @@ const MyAccountTab = ({ userId, username }) => {
setCustomStatus(currentUser.customStatus || '');
}
setAvatarFile(null);
setJoinSoundFile(null);
setJoinSoundPreviewName(null);
setRemoveJoinSound(false);
if (avatarPreview) {
URL.revokeObjectURL(avatarPreview);
setAvatarPreview(null);
@@ -364,6 +429,72 @@ const MyAccountTab = ({ userId, username }) => {
}}
/>
</div>
{/* Voice Channel Join Sound */}
<div>
<label style={{
display: 'block', color: 'var(--header-secondary)', fontSize: '12px',
fontWeight: '700', textTransform: 'uppercase', marginBottom: '8px',
}}>
Voice Channel Join Sound
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
<button
onClick={() => joinSoundInputRef.current?.click()}
style={{
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
borderRadius: '4px', padding: '8px 16px', cursor: 'pointer',
fontSize: '14px', fontWeight: '500',
}}
>
Upload Sound
</button>
{(joinSoundPreviewName || (!removeJoinSound && currentUser?.joinSoundUrl)) && (
<button
onClick={handleJoinSoundPreview}
title="Preview join sound"
style={{
backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-normal)', border: 'none',
borderRadius: '4px', padding: '8px 12px', cursor: 'pointer', fontSize: '14px',
display: 'flex', alignItems: 'center', gap: '4px',
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5,3 19,12 5,21" />
</svg>
Preview
</button>
)}
{(joinSoundPreviewName || (!removeJoinSound && currentUser?.joinSoundUrl)) && (
<button
onClick={handleRemoveJoinSound}
style={{
backgroundColor: 'transparent', color: '#ed4245', border: '1px solid #ed4245',
borderRadius: '4px', padding: '7px 12px', cursor: 'pointer', fontSize: '14px',
}}
>
Remove
</button>
)}
<input
ref={joinSoundInputRef}
type="file"
accept="audio/mpeg,audio/wav,audio/ogg,audio/webm"
onChange={handleJoinSoundChange}
style={{ display: 'none' }}
/>
</div>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '6px' }}>
{joinSoundPreviewName
? `Selected: ${joinSoundPreviewName}`
: removeJoinSound
? 'Join sound will be removed on save'
: currentUser?.joinSoundUrl
? 'Custom sound set — plays when you join a voice channel'
: 'Upload a short audio file (max 10MB) — plays for everyone when you join a voice channel'
}
</div>
</div>
</div>
</div>
</div>

View File

@@ -38,6 +38,12 @@ function playSound(type) {
audio.play().catch(e => console.error("Sound play failed", e));
}
function playSoundUrl(url) {
const audio = new Audio(url);
audio.volume = 0.5;
audio.play().catch(e => console.error("Sound play failed", e));
}
export const VoiceProvider = ({ children }) => {
const [activeChannelId, setActiveChannelId] = useState(null);
const [activeChannelName, setActiveChannelName] = useState(null);
@@ -175,6 +181,17 @@ export const VoiceProvider = ({ children }) => {
const voiceStates = useQuery(api.voiceState.getAll) || {};
const serverSettings = useQuery(api.serverSettings.get);
// Subscribe to own join sound URL for self-join playback
const myUserId = localStorage.getItem('userId');
const myJoinSoundUrl = useQuery(
api.auth.getMyJoinSoundUrl,
myUserId ? { userId: myUserId } : "skip"
);
// Refs for detecting other-user joins via voiceStates changes
const prevChannelUsersRef = useRef(new Map());
const otherJoinInitRef = useRef(false);
const isInAfkChannel = !!(activeChannelId && serverSettings?.afkChannelId === activeChannelId);
async function updateVoiceState(fields) {
@@ -236,7 +253,12 @@ export const VoiceProvider = ({ children }) => {
setRoom(newRoom);
setConnectionState('connected');
window.voiceRoom = newRoom;
playSound('join');
// Play custom join sound if set, otherwise default
if (myJoinSoundUrl) {
playSoundUrl(myJoinSoundUrl);
} else {
playSound('join');
}
await convex.mutation(api.voiceState.join, {
channelId,
@@ -374,6 +396,45 @@ export const VoiceProvider = ({ children }) => {
return () => clearInterval(interval);
}, [activeChannelId, serverSettings?.afkChannelId, serverSettings?.afkTimeout, isInAfkChannel]);
// Detect other users joining the same voice channel and play their join sound
useEffect(() => {
if (!activeChannelId) {
prevChannelUsersRef.current = new Map();
otherJoinInitRef.current = false;
return;
}
const selfId = localStorage.getItem('userId');
const channelUsers = voiceStates[activeChannelId] || [];
const currentUsers = new Map();
for (const u of channelUsers) {
currentUsers.set(u.userId, u);
}
// Skip the first render after joining to avoid playing sounds for users already in the channel
if (!otherJoinInitRef.current) {
otherJoinInitRef.current = true;
prevChannelUsersRef.current = currentUsers;
return;
}
const prev = prevChannelUsersRef.current;
// Detect new users (not self)
for (const [uid, userData] of currentUsers) {
if (uid !== selfId && !prev.has(uid)) {
if (userData.joinSoundUrl) {
playSoundUrl(userData.joinSoundUrl);
} else {
playSound('join');
}
break; // one sound per update batch
}
}
prevChannelUsersRef.current = currentUsers;
}, [voiceStates, activeChannelId]);
// Manage screen share subscriptions — only subscribe when actively watching
useEffect(() => {
if (!room) return;

View File

@@ -2534,6 +2534,42 @@ body {
opacity: 1;
}
/* Server icon upload (settings) */
.server-icon-upload-wrapper {
position: relative;
width: 80px;
height: 80px;
border-radius: 16px;
overflow: hidden;
flex-shrink: 0;
}
.server-icon-upload-overlay {
position: absolute;
top: 0;
left: 0;
width: 80px;
height: 80px;
border-radius: 16px;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
text-align: center;
line-height: 1.3;
opacity: 0;
transition: opacity 0.15s;
pointer-events: none;
}
.server-icon-upload-wrapper:hover .server-icon-upload-overlay {
opacity: 1;
}
/* ============================================
AVATAR CROP MODAL
============================================ */

View File

@@ -49,6 +49,9 @@ const Chat = () => {
const channels = useQuery(api.channels.list) || [];
const categories = useQuery(api.categories.list) || [];
const serverSettings = useQuery(api.serverSettings.get);
const serverName = serverSettings?.serverName || 'Secure Chat';
const serverIconUrl = serverSettings?.iconUrl || null;
const rawChannelKeys = useQuery(
api.channelKeys.getKeysForUser,
@@ -228,7 +231,7 @@ const Chat = () => {
onToggleMembers={() => setShowMembers(!showMembers)}
showMembers={showMembers}
onTogglePinned={() => setShowPinned(p => !p)}
serverName="Secure Chat"
serverName={serverName}
/>
<div className="chat-content">
<ChatArea
@@ -256,7 +259,7 @@ const Chat = () => {
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#b9bbbe', flexDirection: 'column' }}>
<h2>Welcome to Secure Chat</h2>
<h2>Welcome to {serverName}</h2>
<p>No channels found.</p>
<p>Click the <b>+</b> in the sidebar to create your first encrypted channel.</p>
</div>
@@ -290,6 +293,8 @@ const Chat = () => {
setActiveDMChannel={setActiveDMChannel}
dmChannels={dmChannels}
userId={userId}
serverName={serverName}
serverIconUrl={serverIconUrl}
/>
{renderMainContent()}
{showPiP && <FloatingStreamPiP onGoBackToStream={handleGoBackToStream} />}

18
TODO.md
View File

@@ -28,10 +28,22 @@
# Future
<!-- - Allow users to add custom join sounds.
<!-- - Can we allow users to add custom join sounds. Right now we have a default join sound. Can we make it so users can upload their own join sound? In their user settings. They can upload a audio file and it will be used as their join sound instead of the default join sound. -->
- Make people type passwords twice to make sure they dont mess up typing their password for registration. -->
<!-- - Make people type passwords twice to make sure they dont mess up typing their password for registration. -->
<!-- How can we save user preferences for the app like individual user volumes, the position and size they have the floating stream popout, if they have categories collaped, the last channel they were in so we can open that channel when they open the app, etc. -->
<!-- How can we save user preferences for the app like individual user volumes, the position and size they have the floating stream popout, if they have categories collaped, the last channel they were in so we can open that channel when they open the app, etc. -->
Can we make sure Voice and Video work. We have the users input and output devices but if i select any it dosent show it changed. I want to make sure that the users can select their input and output devices and that it works for livekit.
- Lets make it so we can upload a custom image for the server that will show on the sidebar. Make the image editing like how we do it for avatars but instead of a circle that we have to show users cut off its a square with a border radius, match it to the boarder radius of the server-item-wrapper
Why dont all my chats have a
Welcome to #chatName
This is the start of the #chatName channel.
Its only the first one that has this.

View File

@@ -204,6 +204,7 @@ export const getPublicKeys = query({
avatarUrl: v.optional(v.union(v.string(), v.null())),
aboutMe: v.optional(v.string()),
customStatus: v.optional(v.string()),
joinSoundUrl: v.optional(v.union(v.string(), v.null())),
})
),
handler: async (ctx) => {
@@ -214,6 +215,10 @@ export const getPublicKeys = query({
if (u.avatarStorageId) {
avatarUrl = await getPublicStorageUrl(ctx, u.avatarStorageId);
}
let joinSoundUrl: string | null = null;
if (u.joinSoundStorageId) {
joinSoundUrl = await getPublicStorageUrl(ctx, u.joinSoundStorageId);
}
results.push({
id: u._id,
username: u.username,
@@ -223,6 +228,7 @@ export const getPublicKeys = query({
avatarUrl,
aboutMe: u.aboutMe,
customStatus: u.customStatus,
joinSoundUrl,
});
}
return results;
@@ -237,6 +243,8 @@ export const updateProfile = mutation({
aboutMe: v.optional(v.string()),
avatarStorageId: v.optional(v.id("_storage")),
customStatus: v.optional(v.string()),
joinSoundStorageId: v.optional(v.id("_storage")),
removeJoinSound: v.optional(v.boolean()),
},
returns: v.null(),
handler: async (ctx, args) => {
@@ -245,11 +253,24 @@ export const updateProfile = mutation({
if (args.aboutMe !== undefined) patch.aboutMe = args.aboutMe;
if (args.avatarStorageId !== undefined) patch.avatarStorageId = args.avatarStorageId;
if (args.customStatus !== undefined) patch.customStatus = args.customStatus;
if (args.joinSoundStorageId !== undefined) patch.joinSoundStorageId = args.joinSoundStorageId;
if (args.removeJoinSound) patch.joinSoundStorageId = undefined;
await ctx.db.patch(args.userId, patch);
return null;
},
});
// Get the current user's join sound URL
export const getMyJoinSoundUrl = query({
args: { userId: v.id("userProfiles") },
returns: v.union(v.string(), v.null()),
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
if (!user?.joinSoundStorageId) return null;
return await getPublicStorageUrl(ctx, user.joinSoundStorageId);
},
});
// Update user status
export const updateStatus = mutation({
args: {

View File

@@ -16,6 +16,7 @@ export default defineSchema({
avatarStorageId: v.optional(v.id("_storage")),
aboutMe: v.optional(v.string()),
customStatus: v.optional(v.string()),
joinSoundStorageId: v.optional(v.id("_storage")),
}).index("by_username", ["username"]),
categories: defineTable({
@@ -126,7 +127,9 @@ export default defineSchema({
.index("by_user_and_channel", ["userId", "channelId"]),
serverSettings: defineTable({
serverName: v.optional(v.string()),
afkChannelId: v.optional(v.id("channels")),
afkTimeout: v.number(), // seconds (default 300 = 5 min)
iconStorageId: v.optional(v.id("_storage")),
}),
});

View File

@@ -6,7 +6,13 @@ export const get = query({
args: {},
returns: v.any(),
handler: async (ctx) => {
return await ctx.db.query("serverSettings").first();
const settings = await ctx.db.query("serverSettings").first();
if (!settings) return null;
let iconUrl = null;
if (settings.iconStorageId) {
iconUrl = await ctx.storage.getUrl(settings.iconStorageId);
}
return { ...settings, iconUrl };
},
});
@@ -56,6 +62,74 @@ export const update = mutation({
},
});
export const updateName = mutation({
args: {
userId: v.id("userProfiles"),
serverName: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
// Permission check
const roles = await getRolesForUser(ctx, args.userId);
const canManage = roles.some(
(role) => (role.permissions as Record<string, boolean>)?.["manage_channels"]
);
if (!canManage) {
throw new Error("You don't have permission to manage server settings");
}
// Validate name
const name = args.serverName.trim();
if (name.length === 0 || name.length > 100) {
throw new Error("Server name must be between 1 and 100 characters");
}
const existing = await ctx.db.query("serverSettings").first();
if (existing) {
await ctx.db.patch(existing._id, { serverName: name });
} else {
await ctx.db.insert("serverSettings", {
serverName: name,
afkTimeout: 300,
});
}
return null;
},
});
export const updateIcon = mutation({
args: {
userId: v.id("userProfiles"),
iconStorageId: v.optional(v.id("_storage")),
},
returns: v.null(),
handler: async (ctx, args) => {
// Permission check
const roles = await getRolesForUser(ctx, args.userId);
const canManage = roles.some(
(role) => (role.permissions as Record<string, boolean>)?.["manage_channels"]
);
if (!canManage) {
throw new Error("You don't have permission to manage server settings");
}
const existing = await ctx.db.query("serverSettings").first();
if (existing) {
await ctx.db.patch(existing._id, {
iconStorageId: args.iconStorageId,
});
} else {
await ctx.db.insert("serverSettings", {
iconStorageId: args.iconStorageId,
afkTimeout: 300,
});
}
return null;
},
});
export const clearAfkChannel = internalMutation({
args: { channelId: v.id("channels") },
returns: v.null(),

View File

@@ -151,6 +151,7 @@ export const getAll = query({
isScreenSharing: boolean;
isServerMuted: boolean;
avatarUrl: string | null;
joinSoundUrl: string | null;
watchingStream: string | null;
}>> = {};
@@ -160,6 +161,10 @@ export const getAll = query({
if (user?.avatarStorageId) {
avatarUrl = await getPublicStorageUrl(ctx, user.avatarStorageId);
}
let joinSoundUrl: string | null = null;
if (user?.joinSoundStorageId) {
joinSoundUrl = await getPublicStorageUrl(ctx, user.joinSoundStorageId);
}
(grouped[s.channelId] ??= []).push({
userId: s.userId,
@@ -169,6 +174,7 @@ export const getAll = query({
isScreenSharing: s.isScreenSharing,
isServerMuted: s.isServerMuted,
avatarUrl,
joinSoundUrl,
watchingStream: s.watchingStream ?? null,
});
}