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 */} +
+
+ +
+ ESC +
+
+
); 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 ? ( + Server Icon + ) : ( +
+ {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
+
+
+ {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' + } +
+
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, }); }