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
Some checks failed
Build and Release / build-and-release (push) Has been cancelled
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
============================================ */
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
Reference in New Issue
Block a user