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/)
|
## 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)
|
- `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), updateProfile, updateStatus
|
- `auth.ts` - getSalt, verifyUser, createUserWithProfile, getPublicKeys (includes avatarUrl, aboutMe, customStatus, joinSoundUrl), updateProfile (includes joinSoundStorageId, removeJoinSound), updateStatus, getMyJoinSoundUrl
|
||||||
- `categories.ts` - list, create, rename, remove, reorder
|
- `categories.ts` - list, create, rename, remove, reorder
|
||||||
- `channels.ts` - list, get, create (with categoryId/topic/position), rename, remove (cascade), updateTopic, moveChannel, reorderChannels
|
- `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)
|
- `members.ts` - getChannelMembers (includes isHoist on roles, avatarUrl, aboutMe, customStatus)
|
||||||
- `channelKeys.ts` - uploadKeys, getKeysForUser
|
- `channelKeys.ts` - uploadKeys, getKeysForUser
|
||||||
- `messages.ts` - list (with reactions + username), send, edit, pin, listPinned, remove (with manage_messages permission check)
|
- `messages.ts` - list (with reactions + username), send, edit, pin, listPinned, remove (with manage_messages permission check)
|
||||||
- `reactions.ts` - add, remove
|
- `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)
|
- `typing.ts` - startTyping, stopTyping, getTyping, cleanExpired (scheduled)
|
||||||
- `dms.ts` - openDM, listDMs
|
- `dms.ts` - openDM, listDMs
|
||||||
- `invites.ts` - create, use, revoke
|
- `invites.ts` - create, use, revoke
|
||||||
- `roles.ts` - list, create, update, remove, listMembers, assign, unassign, getMyPermissions
|
- `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)
|
- `voice.ts` - getToken (Node action, livekit-server-sdk)
|
||||||
- `files.ts` - generateUploadUrl, getFileUrl
|
- `files.ts` - generateUploadUrl, getFileUrl
|
||||||
- `gifs.ts` - search, categories (Node actions, Tenor API)
|
- `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
|
- `pages/Chat.jsx` - useQuery for channels, categories, channelKeys, DMs
|
||||||
- `components/ChatArea.jsx` - Messages, typing, reactions via Convex queries/mutations
|
- `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
|
- `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/ChannelSettingsModal.jsx` - Channel rename/delete via Convex mutations
|
||||||
- `components/ServerSettingsModal.jsx` - Role management via Convex queries/mutations
|
- `components/ServerSettingsModal.jsx` - Role management via Convex queries/mutations
|
||||||
- `components/MessageItem.jsx` - Individual message rendering with unread divider support
|
- `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
|
- Voice connected panel includes elapsed time timer
|
||||||
- Keyboard shortcuts: Ctrl+K (quick switcher), Ctrl+Shift+M (mute toggle)
|
- 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
|
- 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.
|
- 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
|
## Environment Variables
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "discord",
|
"name": "discord",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.11",
|
"version": "1.0.12",
|
||||||
"description": "A Discord clone built with Convex, React, and Electron",
|
"description": "A Discord clone built with Convex, React, and Electron",
|
||||||
"author": "Moyettes",
|
"author": "Moyettes",
|
||||||
"type": "module",
|
"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 [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState(null);
|
const [croppedAreaPixels, setCroppedAreaPixels] = useState(null);
|
||||||
@@ -77,7 +77,7 @@ const AvatarCropModal = ({ imageUrl, onApply, onCancel }) => {
|
|||||||
crop={crop}
|
crop={crop}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
aspect={1}
|
aspect={1}
|
||||||
cropShape="round"
|
cropShape={cropShape}
|
||||||
showGrid={false}
|
showGrid={false}
|
||||||
onCropChange={setCrop}
|
onCropChange={setCrop}
|
||||||
onZoomChange={setZoom}
|
onZoomChange={setZoom}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useConvex } from 'convex/react';
|
import { useConvex } from 'convex/react';
|
||||||
import { api } from '../../../../convex/_generated/api';
|
import { api } from '../../../../convex/_generated/api';
|
||||||
|
|
||||||
@@ -8,6 +8,12 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
|||||||
|
|
||||||
const convex = useConvex();
|
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 () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
await convex.mutation(api.channels.rename, { id: channel._id, name });
|
await convex.mutation(api.channels.rename, { id: channel._id, name });
|
||||||
@@ -100,29 +106,11 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div style={{ flex: 1, padding: '60px 40px', maxWidth: '740px' }}>
|
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-start', overflowY: 'auto' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '20px' }}>
|
<div style={{ flex: 1, maxWidth: '740px', padding: '60px 40px 80px', position: 'relative' }}>
|
||||||
<h2 style={{ color: 'var(--header-primary)', margin: 0 }}>
|
<h2 style={{ color: 'var(--header-primary)', margin: 0, marginBottom: '20px' }}>
|
||||||
{activeTab === 'Delete' ? 'Delete Channel' : 'Overview'}
|
{activeTab === 'Delete' ? 'Delete Channel' : 'Overview'}
|
||||||
</h2>
|
</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' && (
|
{activeTab === 'Overview' && (
|
||||||
<>
|
<>
|
||||||
@@ -198,10 +186,25 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ flex: '0 0 36px', paddingTop: '60px', marginLeft: '8px' }}>
|
||||||
<div style={{ flex: 0.5 }}>
|
<button
|
||||||
{/* Right side spacer like real Discord */}
|
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>
|
||||||
</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 { useQuery, useConvex } from 'convex/react';
|
||||||
import { api } from '../../../../convex/_generated/api';
|
import { api } from '../../../../convex/_generated/api';
|
||||||
|
import AvatarCropModal from './AvatarCropModal';
|
||||||
|
|
||||||
const TIMEOUT_OPTIONS = [
|
const TIMEOUT_OPTIONS = [
|
||||||
{ value: 60, label: '1 min' },
|
{ value: 60, label: '1 min' },
|
||||||
@@ -25,22 +26,53 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
userId ? { userId } : "skip"
|
userId ? { userId } : "skip"
|
||||||
) || {};
|
) || {};
|
||||||
|
|
||||||
// AFK settings
|
// Server settings
|
||||||
const serverSettings = useQuery(api.serverSettings.get);
|
const serverSettings = useQuery(api.serverSettings.get);
|
||||||
const channels = useQuery(api.channels.list) || [];
|
const channels = useQuery(api.channels.list) || [];
|
||||||
const voiceChannels = channels.filter(c => c.type === 'voice');
|
const voiceChannels = channels.filter(c => c.type === 'voice');
|
||||||
|
const [serverName, setServerName] = useState('Secure Chat');
|
||||||
|
const [serverNameDirty, setServerNameDirty] = useState(false);
|
||||||
const [afkChannelId, setAfkChannelId] = useState('');
|
const [afkChannelId, setAfkChannelId] = useState('');
|
||||||
const [afkTimeout, setAfkTimeout] = useState(300);
|
const [afkTimeout, setAfkTimeout] = useState(300);
|
||||||
const [afkDirty, setAfkDirty] = useState(false);
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (serverSettings) {
|
if (serverSettings) {
|
||||||
|
setServerName(serverSettings.serverName || 'Secure Chat');
|
||||||
|
setServerNameDirty(false);
|
||||||
setAfkChannelId(serverSettings.afkChannelId || '');
|
setAfkChannelId(serverSettings.afkChannelId || '');
|
||||||
setAfkTimeout(serverSettings.afkTimeout || 300);
|
setAfkTimeout(serverSettings.afkTimeout || 300);
|
||||||
setAfkDirty(false);
|
setAfkDirty(false);
|
||||||
}
|
}
|
||||||
}, [serverSettings]);
|
}, [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 () => {
|
const handleSaveAfkSettings = async () => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
try {
|
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 () => {
|
const handleCreateRole = async () => {
|
||||||
try {
|
try {
|
||||||
const newRole = await convex.mutation(api.roles.create, {
|
const newRole = await convex.mutation(api.roles.create, {
|
||||||
@@ -102,7 +217,7 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
const renderSidebar = () => (
|
const renderSidebar = () => (
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '218px',
|
width: '218px',
|
||||||
backgroundColor: 'var(--bg-tertiary)',
|
backgroundColor: 'var(--bg-secondary)',
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'flex-end', padding: '60px 6px 60px 20px'
|
display: 'flex', flexDirection: 'column', alignItems: 'flex-end', padding: '60px 6px 60px 20px'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ width: '100%', padding: '0 10px' }}>
|
<div style={{ width: '100%', padding: '0 10px' }}>
|
||||||
@@ -258,7 +373,97 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
case 'Members': return renderMembersTab();
|
case 'Members': return renderMembersTab();
|
||||||
default: return (
|
default: return (
|
||||||
<div>
|
<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>
|
<label style={labelStyle}>INACTIVE CHANNEL</label>
|
||||||
<select
|
<select
|
||||||
@@ -315,13 +520,38 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
return (
|
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)' }}>
|
<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()}
|
{renderSidebar()}
|
||||||
<div style={{ flex: 1, padding: '60px 40px' }}>
|
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-start', overflowY: 'auto' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 20 }}>
|
<div style={{ flex: 1, maxWidth: '740px', padding: '60px 40px 80px', position: 'relative' }}>
|
||||||
<h2 style={{ color: 'var(--header-primary)', margin: 0 }}>{activeTab}</h2>
|
<h2 style={{ color: 'var(--header-primary)', margin: 0, marginBottom: 20 }}>{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>
|
{renderTabContent()}
|
||||||
</div>
|
</div>
|
||||||
{renderTabContent()}
|
<div style={{ flex: '0 0 36px', paddingTop: '60px', marginLeft: '8px' }}>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
width: '36px', height: '36px', borderRadius: '50%',
|
||||||
|
border: '2px solid var(--header-secondary)', background: 'transparent',
|
||||||
|
color: 'var(--header-secondary)', cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: '18px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--header-secondary)', textAlign: 'center', marginTop: '4px' }}>
|
||||||
|
ESC
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: '0.5' }} />
|
||||||
</div>
|
</div>
|
||||||
|
{showIconCropModal && rawIconUrl && (
|
||||||
|
<AvatarCropModal
|
||||||
|
imageUrl={rawIconUrl}
|
||||||
|
onApply={handleIconCropApply}
|
||||||
|
onCancel={handleIconCropCancel}
|
||||||
|
cropShape="rect"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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 [isCreating, setIsCreating] = useState(false);
|
||||||
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
|
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
|
||||||
const [newChannelName, setNewChannelName] = useState('');
|
const [newChannelName, setNewChannelName] = useState('');
|
||||||
@@ -1313,7 +1313,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
const renderServerView = () => (
|
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 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)' }}>
|
<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">
|
<button className="server-header-invite" onClick={handleCreateInvite} title="Invite People">
|
||||||
<img src={inviteUserIcon} alt="Invite" />
|
<img src={inviteUserIcon} alt="Invite" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1548,12 +1548,18 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
|
|
||||||
<div className="server-item-wrapper">
|
<div className="server-item-wrapper">
|
||||||
<div className={`server-pill ${view === 'server' ? 'active' : ''}`} />
|
<div className={`server-pill ${view === 'server' ? 'active' : ''}`} />
|
||||||
<Tooltip text="Secure Chat" position="right">
|
<Tooltip text={serverName} position="right">
|
||||||
<div
|
<div
|
||||||
className={`server-icon ${view === 'server' ? 'active' : ''}`}
|
className={`server-icon ${view === 'server' ? 'active' : ''}`}
|
||||||
onClick={() => onViewChange('server')}
|
onClick={() => onViewChange('server')}
|
||||||
style={{ cursor: 'pointer' }}
|
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>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1583,7 +1589,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
<ColoredIcon src={disconnectIcon} color="var(--header-secondary)" size="20px" />
|
<ColoredIcon src={disconnectIcon} color="var(--header-secondary)" size="20px" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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={{ marginBottom: 8 }}><VoiceTimer /></div>
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
<button onClick={() => room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)} title="Turn On Camera" style={voicePanelButtonStyle}>
|
<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 [showCropModal, setShowCropModal] = useState(false);
|
||||||
const [rawImageUrl, setRawImageUrl] = useState(null);
|
const [rawImageUrl, setRawImageUrl] = useState(null);
|
||||||
const fileInputRef = useRef(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(() => {
|
useEffect(() => {
|
||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
@@ -170,9 +175,11 @@ const MyAccountTab = ({ userId, username }) => {
|
|||||||
displayName !== (currentUser.displayName || '') ||
|
displayName !== (currentUser.displayName || '') ||
|
||||||
aboutMe !== (currentUser.aboutMe || '') ||
|
aboutMe !== (currentUser.aboutMe || '') ||
|
||||||
customStatus !== (currentUser.customStatus || '') ||
|
customStatus !== (currentUser.customStatus || '') ||
|
||||||
avatarFile !== null;
|
avatarFile !== null ||
|
||||||
|
joinSoundFile !== null ||
|
||||||
|
removeJoinSound;
|
||||||
setHasChanges(changed);
|
setHasChanges(changed);
|
||||||
}, [displayName, aboutMe, customStatus, avatarFile, currentUser]);
|
}, [displayName, aboutMe, customStatus, avatarFile, joinSoundFile, removeJoinSound, currentUser]);
|
||||||
|
|
||||||
const handleAvatarChange = (e) => {
|
const handleAvatarChange = (e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -199,6 +206,45 @@ const MyAccountTab = ({ userId, username }) => {
|
|||||||
setShowCropModal(false);
|
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 () => {
|
const handleSave = async () => {
|
||||||
if (!userId || saving) return;
|
if (!userId || saving) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -214,10 +260,26 @@ const MyAccountTab = ({ userId, username }) => {
|
|||||||
const { storageId } = await res.json();
|
const { storageId } = await res.json();
|
||||||
avatarStorageId = storageId;
|
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 };
|
const args = { userId, displayName, aboutMe, customStatus };
|
||||||
if (avatarStorageId) args.avatarStorageId = avatarStorageId;
|
if (avatarStorageId) args.avatarStorageId = avatarStorageId;
|
||||||
|
if (joinSoundStorageId) args.joinSoundStorageId = joinSoundStorageId;
|
||||||
|
if (removeJoinSound) args.removeJoinSound = true;
|
||||||
await convex.mutation(api.auth.updateProfile, args);
|
await convex.mutation(api.auth.updateProfile, args);
|
||||||
setAvatarFile(null);
|
setAvatarFile(null);
|
||||||
|
setJoinSoundFile(null);
|
||||||
|
setJoinSoundPreviewName(null);
|
||||||
|
setRemoveJoinSound(false);
|
||||||
if (avatarPreview) {
|
if (avatarPreview) {
|
||||||
URL.revokeObjectURL(avatarPreview);
|
URL.revokeObjectURL(avatarPreview);
|
||||||
setAvatarPreview(null);
|
setAvatarPreview(null);
|
||||||
@@ -237,6 +299,9 @@ const MyAccountTab = ({ userId, username }) => {
|
|||||||
setCustomStatus(currentUser.customStatus || '');
|
setCustomStatus(currentUser.customStatus || '');
|
||||||
}
|
}
|
||||||
setAvatarFile(null);
|
setAvatarFile(null);
|
||||||
|
setJoinSoundFile(null);
|
||||||
|
setJoinSoundPreviewName(null);
|
||||||
|
setRemoveJoinSound(false);
|
||||||
if (avatarPreview) {
|
if (avatarPreview) {
|
||||||
URL.revokeObjectURL(avatarPreview);
|
URL.revokeObjectURL(avatarPreview);
|
||||||
setAvatarPreview(null);
|
setAvatarPreview(null);
|
||||||
@@ -364,6 +429,72 @@ const MyAccountTab = ({ userId, username }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ function playSound(type) {
|
|||||||
audio.play().catch(e => console.error("Sound play failed", e));
|
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 }) => {
|
export const VoiceProvider = ({ children }) => {
|
||||||
const [activeChannelId, setActiveChannelId] = useState(null);
|
const [activeChannelId, setActiveChannelId] = useState(null);
|
||||||
const [activeChannelName, setActiveChannelName] = useState(null);
|
const [activeChannelName, setActiveChannelName] = useState(null);
|
||||||
@@ -175,6 +181,17 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
|
|
||||||
const voiceStates = useQuery(api.voiceState.getAll) || {};
|
const voiceStates = useQuery(api.voiceState.getAll) || {};
|
||||||
const serverSettings = useQuery(api.serverSettings.get);
|
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);
|
const isInAfkChannel = !!(activeChannelId && serverSettings?.afkChannelId === activeChannelId);
|
||||||
|
|
||||||
async function updateVoiceState(fields) {
|
async function updateVoiceState(fields) {
|
||||||
@@ -236,7 +253,12 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
setRoom(newRoom);
|
setRoom(newRoom);
|
||||||
setConnectionState('connected');
|
setConnectionState('connected');
|
||||||
window.voiceRoom = newRoom;
|
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, {
|
await convex.mutation(api.voiceState.join, {
|
||||||
channelId,
|
channelId,
|
||||||
@@ -374,6 +396,45 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [activeChannelId, serverSettings?.afkChannelId, serverSettings?.afkTimeout, isInAfkChannel]);
|
}, [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
|
// Manage screen share subscriptions — only subscribe when actively watching
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
|
|||||||
@@ -2534,6 +2534,42 @@ body {
|
|||||||
opacity: 1;
|
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
|
AVATAR CROP MODAL
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ const Chat = () => {
|
|||||||
|
|
||||||
const channels = useQuery(api.channels.list) || [];
|
const channels = useQuery(api.channels.list) || [];
|
||||||
const categories = useQuery(api.categories.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(
|
const rawChannelKeys = useQuery(
|
||||||
api.channelKeys.getKeysForUser,
|
api.channelKeys.getKeysForUser,
|
||||||
@@ -228,7 +231,7 @@ const Chat = () => {
|
|||||||
onToggleMembers={() => setShowMembers(!showMembers)}
|
onToggleMembers={() => setShowMembers(!showMembers)}
|
||||||
showMembers={showMembers}
|
showMembers={showMembers}
|
||||||
onTogglePinned={() => setShowPinned(p => !p)}
|
onTogglePinned={() => setShowPinned(p => !p)}
|
||||||
serverName="Secure Chat"
|
serverName={serverName}
|
||||||
/>
|
/>
|
||||||
<div className="chat-content">
|
<div className="chat-content">
|
||||||
<ChatArea
|
<ChatArea
|
||||||
@@ -256,7 +259,7 @@ const Chat = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#b9bbbe', flexDirection: 'column' }}>
|
<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>No channels found.</p>
|
||||||
<p>Click the <b>+</b> in the sidebar to create your first encrypted channel.</p>
|
<p>Click the <b>+</b> in the sidebar to create your first encrypted channel.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -290,6 +293,8 @@ const Chat = () => {
|
|||||||
setActiveDMChannel={setActiveDMChannel}
|
setActiveDMChannel={setActiveDMChannel}
|
||||||
dmChannels={dmChannels}
|
dmChannels={dmChannels}
|
||||||
userId={userId}
|
userId={userId}
|
||||||
|
serverName={serverName}
|
||||||
|
serverIconUrl={serverIconUrl}
|
||||||
/>
|
/>
|
||||||
{renderMainContent()}
|
{renderMainContent()}
|
||||||
{showPiP && <FloatingStreamPiP onGoBackToStream={handleGoBackToStream} />}
|
{showPiP && <FloatingStreamPiP onGoBackToStream={handleGoBackToStream} />}
|
||||||
|
|||||||
18
TODO.md
18
TODO.md
@@ -28,10 +28,22 @@
|
|||||||
|
|
||||||
# Future
|
# 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. -->
|
<!-- 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())),
|
avatarUrl: v.optional(v.union(v.string(), v.null())),
|
||||||
aboutMe: v.optional(v.string()),
|
aboutMe: v.optional(v.string()),
|
||||||
customStatus: v.optional(v.string()),
|
customStatus: v.optional(v.string()),
|
||||||
|
joinSoundUrl: v.optional(v.union(v.string(), v.null())),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
@@ -214,6 +215,10 @@ export const getPublicKeys = query({
|
|||||||
if (u.avatarStorageId) {
|
if (u.avatarStorageId) {
|
||||||
avatarUrl = await getPublicStorageUrl(ctx, u.avatarStorageId);
|
avatarUrl = await getPublicStorageUrl(ctx, u.avatarStorageId);
|
||||||
}
|
}
|
||||||
|
let joinSoundUrl: string | null = null;
|
||||||
|
if (u.joinSoundStorageId) {
|
||||||
|
joinSoundUrl = await getPublicStorageUrl(ctx, u.joinSoundStorageId);
|
||||||
|
}
|
||||||
results.push({
|
results.push({
|
||||||
id: u._id,
|
id: u._id,
|
||||||
username: u.username,
|
username: u.username,
|
||||||
@@ -223,6 +228,7 @@ export const getPublicKeys = query({
|
|||||||
avatarUrl,
|
avatarUrl,
|
||||||
aboutMe: u.aboutMe,
|
aboutMe: u.aboutMe,
|
||||||
customStatus: u.customStatus,
|
customStatus: u.customStatus,
|
||||||
|
joinSoundUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
@@ -237,6 +243,8 @@ export const updateProfile = mutation({
|
|||||||
aboutMe: v.optional(v.string()),
|
aboutMe: v.optional(v.string()),
|
||||||
avatarStorageId: v.optional(v.id("_storage")),
|
avatarStorageId: v.optional(v.id("_storage")),
|
||||||
customStatus: v.optional(v.string()),
|
customStatus: v.optional(v.string()),
|
||||||
|
joinSoundStorageId: v.optional(v.id("_storage")),
|
||||||
|
removeJoinSound: v.optional(v.boolean()),
|
||||||
},
|
},
|
||||||
returns: v.null(),
|
returns: v.null(),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
@@ -245,11 +253,24 @@ export const updateProfile = mutation({
|
|||||||
if (args.aboutMe !== undefined) patch.aboutMe = args.aboutMe;
|
if (args.aboutMe !== undefined) patch.aboutMe = args.aboutMe;
|
||||||
if (args.avatarStorageId !== undefined) patch.avatarStorageId = args.avatarStorageId;
|
if (args.avatarStorageId !== undefined) patch.avatarStorageId = args.avatarStorageId;
|
||||||
if (args.customStatus !== undefined) patch.customStatus = args.customStatus;
|
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);
|
await ctx.db.patch(args.userId, patch);
|
||||||
return null;
|
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
|
// Update user status
|
||||||
export const updateStatus = mutation({
|
export const updateStatus = mutation({
|
||||||
args: {
|
args: {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default defineSchema({
|
|||||||
avatarStorageId: v.optional(v.id("_storage")),
|
avatarStorageId: v.optional(v.id("_storage")),
|
||||||
aboutMe: v.optional(v.string()),
|
aboutMe: v.optional(v.string()),
|
||||||
customStatus: v.optional(v.string()),
|
customStatus: v.optional(v.string()),
|
||||||
|
joinSoundStorageId: v.optional(v.id("_storage")),
|
||||||
}).index("by_username", ["username"]),
|
}).index("by_username", ["username"]),
|
||||||
|
|
||||||
categories: defineTable({
|
categories: defineTable({
|
||||||
@@ -126,7 +127,9 @@ export default defineSchema({
|
|||||||
.index("by_user_and_channel", ["userId", "channelId"]),
|
.index("by_user_and_channel", ["userId", "channelId"]),
|
||||||
|
|
||||||
serverSettings: defineTable({
|
serverSettings: defineTable({
|
||||||
|
serverName: v.optional(v.string()),
|
||||||
afkChannelId: v.optional(v.id("channels")),
|
afkChannelId: v.optional(v.id("channels")),
|
||||||
afkTimeout: v.number(), // seconds (default 300 = 5 min)
|
afkTimeout: v.number(), // seconds (default 300 = 5 min)
|
||||||
|
iconStorageId: v.optional(v.id("_storage")),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ export const get = query({
|
|||||||
args: {},
|
args: {},
|
||||||
returns: v.any(),
|
returns: v.any(),
|
||||||
handler: async (ctx) => {
|
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({
|
export const clearAfkChannel = internalMutation({
|
||||||
args: { channelId: v.id("channels") },
|
args: { channelId: v.id("channels") },
|
||||||
returns: v.null(),
|
returns: v.null(),
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export const getAll = query({
|
|||||||
isScreenSharing: boolean;
|
isScreenSharing: boolean;
|
||||||
isServerMuted: boolean;
|
isServerMuted: boolean;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
|
joinSoundUrl: string | null;
|
||||||
watchingStream: string | null;
|
watchingStream: string | null;
|
||||||
}>> = {};
|
}>> = {};
|
||||||
|
|
||||||
@@ -160,6 +161,10 @@ export const getAll = query({
|
|||||||
if (user?.avatarStorageId) {
|
if (user?.avatarStorageId) {
|
||||||
avatarUrl = await getPublicStorageUrl(ctx, 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({
|
(grouped[s.channelId] ??= []).push({
|
||||||
userId: s.userId,
|
userId: s.userId,
|
||||||
@@ -169,6 +174,7 @@ export const getAll = query({
|
|||||||
isScreenSharing: s.isScreenSharing,
|
isScreenSharing: s.isScreenSharing,
|
||||||
isServerMuted: s.isServerMuted,
|
isServerMuted: s.isServerMuted,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
|
joinSoundUrl,
|
||||||
watchingStream: s.watchingStream ?? null,
|
watchingStream: s.watchingStream ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user