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:
13
CLAUDE.md
13
CLAUDE.md
@@ -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
|
||||
|
||||
|
||||
@@ -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' && (
|
||||
<>
|
||||
@@ -199,9 +187,24 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 0.5 }}>
|
||||
{/* Right side spacer like real Discord */}
|
||||
<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>
|
||||
<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>
|
||||
<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;
|
||||
// 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} />}
|
||||
|
||||
16
TODO.md
16
TODO.md
@@ -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. -->
|
||||
|
||||
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.
|
||||
@@ -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: {
|
||||
|
||||
@@ -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")),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user