From 556a561449466dd3c342c235aa56fb217cfed95a Mon Sep 17 00:00:00 2001
From: Bryan1029384756 <23323626+Bryan1029384756@users.noreply.github.com>
Date: Fri, 13 Feb 2026 10:29:24 -0600
Subject: [PATCH] feat: Implement initial Electron frontend with core UI, user
and server settings, chat, and voice features, along with Convex backend
schemas and functions.
---
CLAUDE.md | 13 +-
Frontend/Electron/package.json | 2 +-
.../src/components/AvatarCropModal.jsx | 4 +-
.../src/components/ChannelSettingsModal.jsx | 55 ++--
.../src/components/ServerSettingsModal.jsx | 248 +++++++++++++++++-
Frontend/Electron/src/components/Sidebar.jsx | 16 +-
.../Electron/src/components/UserSettings.jsx | 135 +++++++++-
.../Electron/src/contexts/VoiceContext.jsx | 63 ++++-
Frontend/Electron/src/index.css | 36 +++
Frontend/Electron/src/pages/Chat.jsx | 9 +-
TODO.md | 18 +-
convex/auth.ts | 21 ++
convex/schema.ts | 3 +
convex/serverSettings.ts | 76 +++++-
convex/voiceState.ts | 6 +
15 files changed, 648 insertions(+), 57 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 9a05213..432da7a 100644
--- a/CLAUDE.md
+++ b/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
diff --git a/Frontend/Electron/package.json b/Frontend/Electron/package.json
index 145ef3a..a93fc3d 100644
--- a/Frontend/Electron/package.json
+++ b/Frontend/Electron/package.json
@@ -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",
diff --git a/Frontend/Electron/src/components/AvatarCropModal.jsx b/Frontend/Electron/src/components/AvatarCropModal.jsx
index 7b34195..4061c48 100644
--- a/Frontend/Electron/src/components/AvatarCropModal.jsx
+++ b/Frontend/Electron/src/components/AvatarCropModal.jsx
@@ -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}
diff --git a/Frontend/Electron/src/components/ChannelSettingsModal.jsx b/Frontend/Electron/src/components/ChannelSettingsModal.jsx
index 90991d7..9473cac 100644
--- a/Frontend/Electron/src/components/ChannelSettingsModal.jsx
+++ b/Frontend/Electron/src/components/ChannelSettingsModal.jsx
@@ -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 }) => {
{/* Content */}
-
-
-
+
+
+
{activeTab === 'Delete' ? 'Delete Channel' : 'Overview'}
-
-
{activeTab === 'Overview' && (
<>
@@ -198,10 +186,25 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
)}
-
-
-
- {/* Right side spacer like real Discord */}
+
+
+
);
diff --git a/Frontend/Electron/src/components/ServerSettingsModal.jsx b/Frontend/Electron/src/components/ServerSettingsModal.jsx
index 57a5686..96f1c24 100644
--- a/Frontend/Electron/src/components/ServerSettingsModal.jsx
+++ b/Frontend/Electron/src/components/ServerSettingsModal.jsx
@@ -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 = () => (
@@ -258,7 +373,97 @@ const ServerSettingsModal = ({ onClose }) => {
case 'Members': return renderMembersTab();
default: return (
-
Server Name: Secure Chat
Region: US-East
+
+
+
myPermissions.manage_channels && iconInputRef.current?.click()}
+ style={{ cursor: myPermissions.manage_channels ? 'pointer' : 'default', opacity: myPermissions.manage_channels ? 1 : 0.5 }}
+ >
+ {currentIconUrl ? (
+

+ ) : (
+
+ {serverName.substring(0, 2)}
+
+ )}
+ {myPermissions.manage_channels && (
+
+ CHANGE
ICON
+
+ )}
+
+
+
+ {iconDirty && myPermissions.manage_channels && (
+
+ )}
+ {currentIconUrl && !iconDirty && myPermissions.manage_channels && (
+
+ )}
+
+
+
+
+
{ 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 && (
+
+ )}
+ {!serverNameDirty &&
}
+
+
Region: US-East
diff --git a/Frontend/Electron/src/contexts/VoiceContext.jsx b/Frontend/Electron/src/contexts/VoiceContext.jsx
index ecdc5a0..7ee8057 100644
--- a/Frontend/Electron/src/contexts/VoiceContext.jsx
+++ b/Frontend/Electron/src/contexts/VoiceContext.jsx
@@ -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;
diff --git a/Frontend/Electron/src/index.css b/Frontend/Electron/src/index.css
index e4fae66..f20438d 100644
--- a/Frontend/Electron/src/index.css
+++ b/Frontend/Electron/src/index.css
@@ -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
============================================ */
diff --git a/Frontend/Electron/src/pages/Chat.jsx b/Frontend/Electron/src/pages/Chat.jsx
index 661674a..0e3df10 100644
--- a/Frontend/Electron/src/pages/Chat.jsx
+++ b/Frontend/Electron/src/pages/Chat.jsx
@@ -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}
/>
{
return (
-
Welcome to Secure Chat
+
Welcome to {serverName}
No channels found.
Click the + in the sidebar to create your first encrypted channel.
@@ -290,6 +293,8 @@ const Chat = () => {
setActiveDMChannel={setActiveDMChannel}
dmChannels={dmChannels}
userId={userId}
+ serverName={serverName}
+ serverIconUrl={serverIconUrl}
/>
{renderMainContent()}
{showPiP && }
diff --git a/TODO.md b/TODO.md
index 093bff5..8935b5d 100644
--- a/TODO.md
+++ b/TODO.md
@@ -28,10 +28,22 @@
# Future
-
-- Make people type passwords twice to make sure they dont mess up typing their password for registration. -->
+
-
\ No newline at end of file
+
+
+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.
\ No newline at end of file
diff --git a/convex/auth.ts b/convex/auth.ts
index 68479f8..a89016c 100644
--- a/convex/auth.ts
+++ b/convex/auth.ts
@@ -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: {
diff --git a/convex/schema.ts b/convex/schema.ts
index fc91de3..270e36f 100644
--- a/convex/schema.ts
+++ b/convex/schema.ts
@@ -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")),
}),
});
diff --git a/convex/serverSettings.ts b/convex/serverSettings.ts
index ec64e5d..6c753bf 100644
--- a/convex/serverSettings.ts
+++ b/convex/serverSettings.ts
@@ -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)?.["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)?.["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(),
diff --git a/convex/voiceState.ts b/convex/voiceState.ts
index ee64954..49fbc99 100644
--- a/convex/voiceState.ts
+++ b/convex/voiceState.ts
@@ -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,
});
}