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

@@ -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} />}