feat: implement initial Electron chat application with core UI components and Convex backend integration.
All checks were successful
Build and Release / build-and-release (push) Successful in 11m1s
All checks were successful
Build and Release / build-and-release (push) Successful in 11m1s
This commit is contained in:
@@ -79,6 +79,11 @@ function createWindow() {
|
|||||||
|
|
||||||
// Save window state on close
|
// Save window state on close
|
||||||
mainWindow.on('close', () => {
|
mainWindow.on('close', () => {
|
||||||
|
// Flush localStorage/sessionStorage to disk before renderer is destroyed
|
||||||
|
// (Chromium writes to an in-memory cache and flushes asynchronously;
|
||||||
|
// without this, data written just before close can be lost)
|
||||||
|
mainWindow.webContents.session.flushStorageData();
|
||||||
|
|
||||||
const current = loadSettings(); // re-read to preserve theme changes
|
const current = loadSettings(); // re-read to preserve theme changes
|
||||||
if (!mainWindow.isMaximized()) {
|
if (!mainWindow.isMaximized()) {
|
||||||
const bounds = mainWindow.getBounds();
|
const bounds = mainWindow.getBounds();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "discord",
|
"name": "discord",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.10",
|
"version": "1.0.11",
|
||||||
"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",
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ function AuthGuard({ children }) {
|
|||||||
if (session.publicKey) localStorage.setItem('publicKey', session.publicKey);
|
if (session.publicKey) localStorage.setItem('publicKey', session.publicKey);
|
||||||
sessionStorage.setItem('signingKey', session.signingKey);
|
sessionStorage.setItem('signingKey', session.signingKey);
|
||||||
sessionStorage.setItem('privateKey', session.privateKey);
|
sessionStorage.setItem('privateKey', session.privateKey);
|
||||||
|
// Restore user preferences from file-based backup into localStorage
|
||||||
|
if (window.appSettings) {
|
||||||
|
try {
|
||||||
|
const savedPrefs = await window.appSettings.get(`userPrefs_${session.userId}`);
|
||||||
|
if (savedPrefs && typeof savedPrefs === 'object') {
|
||||||
|
localStorage.setItem(`userPrefs_${session.userId}`, JSON.stringify(savedPrefs));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
if (!cancelled) setAuthState('authenticated');
|
if (!cancelled) setAuthState('authenticated');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1236,6 +1236,15 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
|||||||
onClose={onTogglePinned}
|
onClose={onTogglePinned}
|
||||||
channelKey={channelKey}
|
channelKey={channelKey}
|
||||||
onJumpToMessage={scrollToMessage}
|
onJumpToMessage={scrollToMessage}
|
||||||
|
userId={currentUserId}
|
||||||
|
username={username}
|
||||||
|
roles={roles}
|
||||||
|
Attachment={Attachment}
|
||||||
|
LinkPreview={LinkPreview}
|
||||||
|
DirectVideo={DirectVideo}
|
||||||
|
onReactionClick={handleReactionClick}
|
||||||
|
onProfilePopup={handleProfilePopup}
|
||||||
|
onImageClick={setZoomedImage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="messages-list" ref={messagesContainerRef}>
|
<div className="messages-list" ref={messagesContainerRef}>
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useQuery, useMutation } from 'convex/react';
|
import { useQuery, useMutation } from 'convex/react';
|
||||||
import { api } from '../../../../convex/_generated/api';
|
import { api } from '../../../../convex/_generated/api';
|
||||||
|
import MessageItem from './MessageItem';
|
||||||
|
|
||||||
const PinnedMessagesPanel = ({ channelId, visible, onClose, channelKey, onJumpToMessage }) => {
|
const TAG_LENGTH = 32;
|
||||||
|
|
||||||
|
const PinnedMessagesPanel = ({
|
||||||
|
channelId,
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
channelKey,
|
||||||
|
onJumpToMessage,
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
roles,
|
||||||
|
Attachment,
|
||||||
|
LinkPreview,
|
||||||
|
DirectVideo,
|
||||||
|
onReactionClick,
|
||||||
|
onProfilePopup,
|
||||||
|
onImageClick,
|
||||||
|
}) => {
|
||||||
const [decryptedPins, setDecryptedPins] = useState([]);
|
const [decryptedPins, setDecryptedPins] = useState([]);
|
||||||
|
|
||||||
const pinnedMessages = useQuery(
|
const pinnedMessages = useQuery(
|
||||||
api.messages.listPinned,
|
api.messages.listPinned,
|
||||||
channelId ? { channelId } : "skip"
|
channelId ? { channelId, userId: userId || undefined } : "skip"
|
||||||
) || [];
|
) || [];
|
||||||
|
|
||||||
const unpinMutation = useMutation(api.messages.pin);
|
const unpinMutation = useMutation(api.messages.pin);
|
||||||
@@ -19,28 +37,90 @@ const PinnedMessagesPanel = ({ channelId, visible, onClose, channelKey, onJumpTo
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const decrypt = async () => {
|
|
||||||
const results = await Promise.all(
|
const decryptAll = async () => {
|
||||||
pinnedMessages.map(async (msg) => {
|
// Build batch arrays for message decryption
|
||||||
try {
|
const decryptItems = [];
|
||||||
const TAG_LENGTH = 32;
|
const decryptMsgMap = [];
|
||||||
const tag = msg.ciphertext.slice(-TAG_LENGTH);
|
const replyDecryptItems = [];
|
||||||
const content = msg.ciphertext.slice(0, -TAG_LENGTH);
|
const replyMsgMap = [];
|
||||||
const decrypted = await window.cryptoAPI.decryptData(content, channelKey, msg.nonce, tag);
|
const verifyItems = [];
|
||||||
return { ...msg, content: decrypted };
|
const verifyMsgMap = [];
|
||||||
} catch {
|
|
||||||
return { ...msg, content: '[Encrypted Message]' };
|
for (const msg of pinnedMessages) {
|
||||||
}
|
if (msg.ciphertext && msg.ciphertext.length >= TAG_LENGTH) {
|
||||||
})
|
const tag = msg.ciphertext.slice(-TAG_LENGTH);
|
||||||
);
|
const content = msg.ciphertext.slice(0, -TAG_LENGTH);
|
||||||
|
decryptItems.push({ ciphertext: content, key: channelKey, iv: msg.nonce, tag });
|
||||||
|
decryptMsgMap.push(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.replyToContent && msg.replyToNonce) {
|
||||||
|
const rTag = msg.replyToContent.slice(-TAG_LENGTH);
|
||||||
|
const rContent = msg.replyToContent.slice(0, -TAG_LENGTH);
|
||||||
|
replyDecryptItems.push({ ciphertext: rContent, key: channelKey, iv: msg.replyToNonce, tag: rTag });
|
||||||
|
replyMsgMap.push(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.signature && msg.public_signing_key) {
|
||||||
|
verifyItems.push({ publicKey: msg.public_signing_key, message: msg.ciphertext, signature: msg.signature });
|
||||||
|
verifyMsgMap.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [decryptResults, replyResults, verifyResults] = await Promise.all([
|
||||||
|
decryptItems.length > 0 ? window.cryptoAPI.decryptBatch(decryptItems) : [],
|
||||||
|
replyDecryptItems.length > 0 ? window.cryptoAPI.decryptBatch(replyDecryptItems) : [],
|
||||||
|
verifyItems.length > 0 ? window.cryptoAPI.verifyBatch(verifyItems) : [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const decryptedMap = new Map();
|
||||||
|
for (let i = 0; i < decryptResults.length; i++) {
|
||||||
|
const msg = decryptMsgMap[i];
|
||||||
|
const result = decryptResults[i];
|
||||||
|
decryptedMap.set(msg.id, result.success ? result.data : '[Decryption Error]');
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyMap = new Map();
|
||||||
|
for (let i = 0; i < replyResults.length; i++) {
|
||||||
|
const msg = replyMsgMap[i];
|
||||||
|
const result = replyResults[i];
|
||||||
|
if (result.success) {
|
||||||
|
let text = result.data;
|
||||||
|
if (text.startsWith('{')) text = '[Attachment]';
|
||||||
|
else if (text.length > 100) text = text.substring(0, 100) + '...';
|
||||||
|
replyMap.set(msg.id, text);
|
||||||
|
} else {
|
||||||
|
replyMap.set(msg.id, '[Encrypted]');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifyMap = new Map();
|
||||||
|
for (let i = 0; i < verifyResults.length; i++) {
|
||||||
|
const msg = verifyMsgMap[i];
|
||||||
|
verifyMap.set(msg.id, verifyResults[i].success && verifyResults[i].verified);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = pinnedMessages.map(msg => ({
|
||||||
|
...msg,
|
||||||
|
content: decryptedMap.get(msg.id) ?? '[Encrypted Message]',
|
||||||
|
isVerified: verifyMap.get(msg.id) ?? false,
|
||||||
|
decryptedReply: replyMap.get(msg.id) ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
if (!cancelled) setDecryptedPins(results);
|
if (!cancelled) setDecryptedPins(results);
|
||||||
};
|
};
|
||||||
decrypt();
|
|
||||||
|
decryptAll();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [pinnedMessages, channelKey]);
|
}, [pinnedMessages, channelKey]);
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pinned-panel">
|
<div className="pinned-panel">
|
||||||
<div className="pinned-panel-header">
|
<div className="pinned-panel-header">
|
||||||
@@ -53,33 +133,60 @@ const PinnedMessagesPanel = ({ channelId, visible, onClose, channelKey, onJumpTo
|
|||||||
No pinned messages in this channel yet.
|
No pinned messages in this channel yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
decryptedPins.map(msg => (
|
decryptedPins.map(msg => {
|
||||||
<div key={msg.id} className="pinned-message-item">
|
const isOwner = msg.username === username;
|
||||||
<div className="pinned-message-header">
|
return (
|
||||||
<span className="pinned-message-author">{msg.username}</span>
|
<div key={msg.id} className="pinned-message-card">
|
||||||
<span className="pinned-message-date">
|
<MessageItem
|
||||||
{new Date(msg.created_at).toLocaleDateString()}
|
msg={msg}
|
||||||
</span>
|
isGrouped={false}
|
||||||
|
showDateDivider={false}
|
||||||
|
showUnreadDivider={false}
|
||||||
|
dateLabel=""
|
||||||
|
isMentioned={false}
|
||||||
|
isOwner={isOwner}
|
||||||
|
isEditing={false}
|
||||||
|
isHovered={false}
|
||||||
|
editInput=""
|
||||||
|
username={username}
|
||||||
|
roles={roles}
|
||||||
|
onHover={noop}
|
||||||
|
onLeave={noop}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
onAddReaction={noop}
|
||||||
|
onEdit={noop}
|
||||||
|
onReply={noop}
|
||||||
|
onMore={noop}
|
||||||
|
onEditInputChange={noop}
|
||||||
|
onEditKeyDown={noop}
|
||||||
|
onEditSave={noop}
|
||||||
|
onEditCancel={noop}
|
||||||
|
onReactionClick={onReactionClick}
|
||||||
|
onScrollToMessage={onJumpToMessage}
|
||||||
|
onProfilePopup={onProfilePopup}
|
||||||
|
onImageClick={onImageClick}
|
||||||
|
scrollToBottom={noop}
|
||||||
|
Attachment={Attachment}
|
||||||
|
LinkPreview={LinkPreview}
|
||||||
|
DirectVideo={DirectVideo}
|
||||||
|
/>
|
||||||
|
<div className="pinned-message-actions">
|
||||||
|
<button
|
||||||
|
className="pinned-action-btn"
|
||||||
|
onClick={() => onJumpToMessage && onJumpToMessage(msg.id)}
|
||||||
|
>
|
||||||
|
Jump
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pinned-action-btn pinned-action-danger"
|
||||||
|
onClick={() => unpinMutation({ id: msg.id, pinned: false })}
|
||||||
|
>
|
||||||
|
Unpin
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pinned-message-content">
|
);
|
||||||
{msg.content?.startsWith('{') ? '[Attachment]' : msg.content}
|
})
|
||||||
</div>
|
|
||||||
<div className="pinned-message-actions">
|
|
||||||
<button
|
|
||||||
className="pinned-action-btn"
|
|
||||||
onClick={() => onJumpToMessage && onJumpToMessage(msg.id)}
|
|
||||||
>
|
|
||||||
Jump
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="pinned-action-btn pinned-action-danger"
|
|
||||||
onClick={() => unpinMutation({ id: msg.id, pinned: false })}
|
|
||||||
>
|
|
||||||
Unpin
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -152,10 +152,20 @@ const UserControlPanel = ({ username, userId }) => {
|
|||||||
if (window.sessionPersistence) {
|
if (window.sessionPersistence) {
|
||||||
try { await window.sessionPersistence.clear(); } catch {}
|
try { await window.sessionPersistence.clear(); } catch {}
|
||||||
}
|
}
|
||||||
// Clear storage (preserve theme)
|
// Clear storage (preserve theme and user preferences)
|
||||||
const theme = localStorage.getItem('theme');
|
const theme = localStorage.getItem('theme');
|
||||||
|
const savedPrefs = {};
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key.startsWith('userPrefs_')) {
|
||||||
|
savedPrefs[key] = localStorage.getItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
if (theme) localStorage.setItem('theme', theme);
|
if (theme) localStorage.setItem('theme', theme);
|
||||||
|
for (const [key, value] of Object.entries(savedPrefs)) {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
}
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
navigate('/');
|
navigate('/');
|
||||||
};
|
};
|
||||||
@@ -749,7 +759,15 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
const [newChannelType, setNewChannelType] = useState('text');
|
const [newChannelType, setNewChannelType] = useState('text');
|
||||||
const [editingChannel, setEditingChannel] = useState(null);
|
const [editingChannel, setEditingChannel] = useState(null);
|
||||||
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
|
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
|
||||||
const [collapsedCategories, setCollapsedCategories] = useState(() => getUserPref(userId, 'collapsedCategories', {}));
|
const [collapsedCategories, setCollapsedCategories] = useState(() => {
|
||||||
|
const effectiveUserId = userId || localStorage.getItem('userId');
|
||||||
|
return getUserPref(effectiveUserId, 'collapsedCategories', {});
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (userId) {
|
||||||
|
setCollapsedCategories(getUserPref(userId, 'collapsedCategories', {}));
|
||||||
|
}
|
||||||
|
}, [userId]);
|
||||||
const [channelListContextMenu, setChannelListContextMenu] = useState(null);
|
const [channelListContextMenu, setChannelListContextMenu] = useState(null);
|
||||||
const [voiceUserMenu, setVoiceUserMenu] = useState(null);
|
const [voiceUserMenu, setVoiceUserMenu] = useState(null);
|
||||||
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false);
|
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false);
|
||||||
@@ -1107,11 +1125,9 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleCategory = (cat) => {
|
const toggleCategory = (cat) => {
|
||||||
setCollapsedCategories(prev => {
|
const next = { ...collapsedCategories, [cat]: !collapsedCategories[cat] };
|
||||||
const next = { ...prev, [cat]: !prev[cat] };
|
setCollapsedCategories(next);
|
||||||
setUserPref(userId, 'collapsedCategories', next);
|
setUserPref(userId, 'collapsedCategories', next);
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Group channels by categoryId
|
// Group channels by categoryId
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import muteSound from '../assets/sounds/mute.mp3';
|
|||||||
import unmuteSound from '../assets/sounds/unmute.mp3';
|
import unmuteSound from '../assets/sounds/unmute.mp3';
|
||||||
import deafenSound from '../assets/sounds/deafen.mp3';
|
import deafenSound from '../assets/sounds/deafen.mp3';
|
||||||
import undeafenSound from '../assets/sounds/undeafen.mp3';
|
import undeafenSound from '../assets/sounds/undeafen.mp3';
|
||||||
|
import viewerJoinSound from '../assets/sounds/screenshare_viewer_join.mp3';
|
||||||
|
import viewerLeaveSound from '../assets/sounds/screenshare_viewer_leave.mp3';
|
||||||
|
|
||||||
const soundMap = {
|
const soundMap = {
|
||||||
join: joinSound,
|
join: joinSound,
|
||||||
@@ -19,7 +21,9 @@ const soundMap = {
|
|||||||
mute: muteSound,
|
mute: muteSound,
|
||||||
unmute: unmuteSound,
|
unmute: unmuteSound,
|
||||||
deafen: deafenSound,
|
deafen: deafenSound,
|
||||||
undeafen: undeafenSound
|
undeafen: undeafenSound,
|
||||||
|
viewer_join: viewerJoinSound,
|
||||||
|
viewer_leave: viewerLeaveSound,
|
||||||
};
|
};
|
||||||
|
|
||||||
const VoiceContext = createContext();
|
const VoiceContext = createContext();
|
||||||
@@ -46,12 +50,36 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
const [isScreenSharing, setIsScreenSharingLocal] = useState(false);
|
const [isScreenSharing, setIsScreenSharingLocal] = useState(false);
|
||||||
const isMovingRef = useRef(false);
|
const isMovingRef = useRef(false);
|
||||||
|
|
||||||
|
const convex = useConvex();
|
||||||
|
|
||||||
// Stream watching state (lifted from VoiceStage so PiP can persist across navigation)
|
// Stream watching state (lifted from VoiceStage so PiP can persist across navigation)
|
||||||
const [watchingStreamOf, setWatchingStreamOfRaw] = useState(null);
|
const [watchingStreamOf, setWatchingStreamOfRaw] = useState(null);
|
||||||
|
|
||||||
const setWatchingStreamOf = useCallback((identity) => {
|
const setWatchingStreamOf = useCallback((identity) => {
|
||||||
setWatchingStreamOfRaw(identity);
|
setWatchingStreamOfRaw(identity);
|
||||||
}, []);
|
// Sync to backend
|
||||||
|
const userId = localStorage.getItem('userId');
|
||||||
|
if (userId) {
|
||||||
|
convex.mutation(api.voiceState.setWatchingStream, {
|
||||||
|
userId,
|
||||||
|
...(identity ? { watchingStream: identity } : {}),
|
||||||
|
}).catch(e => console.error('Failed to set watching stream:', e));
|
||||||
|
}
|
||||||
|
// Play join sound for the viewer starting to watch
|
||||||
|
if (identity) {
|
||||||
|
playSound('viewer_join');
|
||||||
|
}
|
||||||
|
}, [convex]);
|
||||||
|
|
||||||
|
const clearWatchingStream = useCallback(() => {
|
||||||
|
setWatchingStreamOfRaw(null);
|
||||||
|
const userId = localStorage.getItem('userId');
|
||||||
|
if (userId) {
|
||||||
|
convex.mutation(api.voiceState.setWatchingStream, { userId }).catch(
|
||||||
|
e => console.error('Failed to clear watching stream:', e)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [convex]);
|
||||||
|
|
||||||
// Personal mute state (persisted to localStorage)
|
// Personal mute state (persisted to localStorage)
|
||||||
const [personallyMutedUsers, setPersonallyMutedUsers] = useState(() => {
|
const [personallyMutedUsers, setPersonallyMutedUsers] = useState(() => {
|
||||||
@@ -127,8 +155,6 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
|
|
||||||
const isPersonallyMuted = (userId) => personallyMutedUsers.has(userId);
|
const isPersonallyMuted = (userId) => personallyMutedUsers.has(userId);
|
||||||
|
|
||||||
const convex = useConvex();
|
|
||||||
|
|
||||||
const serverMute = async (targetUserId, isServerMuted) => {
|
const serverMute = async (targetUserId, isServerMuted) => {
|
||||||
const actorUserId = localStorage.getItem('userId');
|
const actorUserId = localStorage.getItem('userId');
|
||||||
if (!actorUserId) return;
|
if (!actorUserId) return;
|
||||||
@@ -389,14 +415,14 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
|| (room.localParticipant.identity === watchingStreamOf ? room.localParticipant : null);
|
|| (room.localParticipant.identity === watchingStreamOf ? room.localParticipant : null);
|
||||||
|
|
||||||
if (!participant) {
|
if (!participant) {
|
||||||
setWatchingStreamOfRaw(null);
|
clearWatchingStream();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if they're still screen sharing
|
// Check if they're still screen sharing
|
||||||
const { screenSharePub } = findTrackPubs(participant);
|
const { screenSharePub } = findTrackPubs(participant);
|
||||||
if (!screenSharePub) {
|
if (!screenSharePub) {
|
||||||
setWatchingStreamOfRaw(null);
|
clearWatchingStream();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -413,10 +439,59 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
// Reset watching state when room disconnects
|
// Reset watching state when room disconnects
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!room) {
|
if (!room) {
|
||||||
setWatchingStreamOfRaw(null);
|
clearWatchingStream();
|
||||||
}
|
}
|
||||||
}, [room]);
|
}, [room]);
|
||||||
|
|
||||||
|
// Detect viewer join/leave for the stream we're watching and play sounds
|
||||||
|
const prevViewersRef = useRef(new Set());
|
||||||
|
const viewerDetectionInitRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!watchingStreamOf) {
|
||||||
|
prevViewersRef.current = new Set();
|
||||||
|
viewerDetectionInitRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const myUserId = localStorage.getItem('userId');
|
||||||
|
// Collect all users currently watching the same stream
|
||||||
|
const currentViewers = new Set();
|
||||||
|
for (const users of Object.values(voiceStates)) {
|
||||||
|
for (const u of users) {
|
||||||
|
if (u.watchingStream === watchingStreamOf && u.userId !== myUserId) {
|
||||||
|
currentViewers.add(u.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip first render to avoid spurious sounds on load
|
||||||
|
if (!viewerDetectionInitRef.current) {
|
||||||
|
viewerDetectionInitRef.current = true;
|
||||||
|
prevViewersRef.current = currentViewers;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = prevViewersRef.current;
|
||||||
|
|
||||||
|
// New viewers joined
|
||||||
|
for (const uid of currentViewers) {
|
||||||
|
if (!prev.has(uid)) {
|
||||||
|
playSound('viewer_join');
|
||||||
|
break; // one sound per update batch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Viewers left (excluding self)
|
||||||
|
for (const uid of prev) {
|
||||||
|
if (!currentViewers.has(uid)) {
|
||||||
|
playSound('viewer_leave');
|
||||||
|
break; // one sound per update batch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prevViewersRef.current = currentViewers;
|
||||||
|
}, [voiceStates, watchingStreamOf]);
|
||||||
|
|
||||||
const disconnectVoice = () => {
|
const disconnectVoice = () => {
|
||||||
console.log('User manually disconnected voice');
|
console.log('User manually disconnected voice');
|
||||||
if (room) room.disconnect();
|
if (room) room.disconnect();
|
||||||
|
|||||||
@@ -1596,42 +1596,30 @@ body {
|
|||||||
padding: 32px 16px;
|
padding: 32px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pinned-message-item {
|
.pinned-message-card {
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
padding: 12px;
|
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pinned-message-header {
|
.pinned-message-card .message-item {
|
||||||
display: flex;
|
padding: 8px 12px 4px;
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pinned-message-author {
|
.pinned-message-card .message-toolbar {
|
||||||
color: var(--header-primary);
|
display: none !important;
|
||||||
font-weight: 600;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pinned-message-date {
|
.pinned-message-card .message-content img,
|
||||||
color: var(--text-muted);
|
.pinned-message-card .message-content video {
|
||||||
font-size: 12px;
|
max-width: 100%;
|
||||||
}
|
|
||||||
|
|
||||||
.pinned-message-content {
|
|
||||||
color: var(--text-normal);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.4;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pinned-message-actions {
|
.pinned-message-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
padding: 4px 12px 10px 68px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pinned-action-btn {
|
.pinned-action-btn {
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ import { PresenceProvider } from '../contexts/PresenceContext';
|
|||||||
import { getUserPref, setUserPref } from '../utils/userPreferences';
|
import { getUserPref, setUserPref } from '../utils/userPreferences';
|
||||||
|
|
||||||
const Chat = () => {
|
const Chat = () => {
|
||||||
const [view, setView] = useState('server');
|
const [userId, setUserId] = useState(() => localStorage.getItem('userId'));
|
||||||
|
const [username, setUsername] = useState(() => localStorage.getItem('username') || '');
|
||||||
|
const [view, setView] = useState(() => {
|
||||||
|
const id = localStorage.getItem('userId');
|
||||||
|
return id ? getUserPref(id, 'lastView', 'server') : 'server';
|
||||||
|
});
|
||||||
const [activeChannel, setActiveChannel] = useState(null);
|
const [activeChannel, setActiveChannel] = useState(null);
|
||||||
const [username, setUsername] = useState('');
|
|
||||||
const [userId, setUserId] = useState(null);
|
|
||||||
const [channelKeys, setChannelKeys] = useState({});
|
const [channelKeys, setChannelKeys] = useState({});
|
||||||
const [activeDMChannel, setActiveDMChannel] = useState(null);
|
const [activeDMChannel, setActiveDMChannel] = useState(null);
|
||||||
const [showMembers, setShowMembers] = useState(true);
|
const [showMembers, setShowMembers] = useState(true);
|
||||||
@@ -57,17 +60,6 @@ const Chat = () => {
|
|||||||
userId ? { userId } : "skip"
|
userId ? { userId } : "skip"
|
||||||
) || [];
|
) || [];
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const storedUsername = localStorage.getItem('username');
|
|
||||||
const storedUserId = localStorage.getItem('userId');
|
|
||||||
if (storedUsername) setUsername(storedUsername);
|
|
||||||
if (storedUserId) {
|
|
||||||
setUserId(storedUserId);
|
|
||||||
const savedView = getUserPref(storedUserId, 'lastView', 'server');
|
|
||||||
setView(savedView);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!rawChannelKeys || rawChannelKeys.length === 0) return;
|
if (!rawChannelKeys || rawChannelKeys.length === 0) return;
|
||||||
const privateKey = sessionStorage.getItem('privateKey');
|
const privateKey = sessionStorage.getItem('privateKey');
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ function parseInviteParams(input) {
|
|||||||
const Register = () => {
|
const Register = () => {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [inviteKeys, setInviteKeys] = useState(null);
|
const [inviteKeys, setInviteKeys] = useState(null);
|
||||||
@@ -70,6 +71,12 @@ const Register = () => {
|
|||||||
const handleRegister = async (e) => {
|
const handleRegister = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -176,6 +183,16 @@ const Register = () => {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Confirm Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button type="submit" className="auth-button" disabled={loading}>
|
<button type="submit" className="auth-button" disabled={loading}>
|
||||||
{loading ? 'Generating Keys...' : 'Continue'}
|
{loading ? 'Generating Keys...' : 'Continue'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export function setUserPref(userId, key, value) {
|
|||||||
const prefs = raw ? JSON.parse(raw) : {};
|
const prefs = raw ? JSON.parse(raw) : {};
|
||||||
prefs[key] = value;
|
prefs[key] = value;
|
||||||
localStorage.setItem(`userPrefs_${userId}`, JSON.stringify(prefs));
|
localStorage.setItem(`userPrefs_${userId}`, JSON.stringify(prefs));
|
||||||
|
// Also persist to disk via Electron IPC (fire-and-forget)
|
||||||
|
if (window.appSettings) {
|
||||||
|
window.appSettings.set(`userPrefs_${userId}`, prefs);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Silently fail on corrupt data or full storage
|
// Silently fail on corrupt data or full storage
|
||||||
}
|
}
|
||||||
|
|||||||
9
TODO.md
9
TODO.md
@@ -22,15 +22,16 @@
|
|||||||
|
|
||||||
<!-- - Allow users to mute other users for themself only. I want to be able to allow users to mute other users for themself only and no one else. So if we click the button button in the popup that we get for when we right click on a user and click mute we will mute their voice audio. Can we also update that menu i have a snippit server mute setting snippit.txt inside the discord-html-copy folder. Where they have a checkbox that shows when that mute is on or off. Also when we mute someone we put the personal_mute.svg icon on them. If they are muted themself we show this icon rather than the mute.svg icon. -->
|
<!-- - Allow users to mute other users for themself only. I want to be able to allow users to mute other users for themself only and no one else. So if we click the button button in the popup that we get for when we right click on a user and click mute we will mute their voice audio. Can we also update that menu i have a snippit server mute setting snippit.txt inside the discord-html-copy folder. Where they have a checkbox that shows when that mute is on or off. Also when we mute someone we put the personal_mute.svg icon on them. If they are muted themself we show this icon rather than the mute.svg icon. -->
|
||||||
|
|
||||||
- Independient voice volumes per user.
|
<!-- - Independient voice volumes per user. -->
|
||||||
|
|
||||||
<!-- - We have it so if a user is in a voice channel on the memebers list it shows a status as "In voice" with a icon. Can we do the same when they are streaming. Where its the streaming icon and says "Sharing their screen" We will use the sharing.svg icon. -->
|
<!-- - We have it so if a user is in a voice channel on the memebers list it shows a status as "In voice" with a icon. Can we do the same when they are streaming. Where its the streaming icon and says "Sharing their screen" We will use the sharing.svg icon. -->
|
||||||
|
|
||||||
# Future
|
# Future
|
||||||
|
|
||||||
- Allow users to add custom join sounds.
|
<!-- - Allow users to add custom join sounds.
|
||||||
|
|
||||||
|
- 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.
|
|
||||||
@@ -4,6 +4,68 @@ import { v } from "convex/values";
|
|||||||
import { getPublicStorageUrl } from "./storageUrl";
|
import { getPublicStorageUrl } from "./storageUrl";
|
||||||
import { getRolesForUser } from "./roles";
|
import { getRolesForUser } from "./roles";
|
||||||
|
|
||||||
|
async function enrichMessage(ctx: any, msg: any, userId?: any) {
|
||||||
|
const sender = await ctx.db.get(msg.senderId);
|
||||||
|
|
||||||
|
let avatarUrl: string | null = null;
|
||||||
|
if (sender?.avatarStorageId) {
|
||||||
|
avatarUrl = await getPublicStorageUrl(ctx, sender.avatarStorageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reactionDocs = await ctx.db
|
||||||
|
.query("messageReactions")
|
||||||
|
.withIndex("by_message", (q: any) => q.eq("messageId", msg._id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const reactions: Record<string, { count: number; me: boolean }> = {};
|
||||||
|
for (const r of reactionDocs) {
|
||||||
|
const entry = (reactions[r.emoji] ??= { count: 0, me: false });
|
||||||
|
entry.count++;
|
||||||
|
if (userId && r.userId === userId) {
|
||||||
|
entry.me = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let replyToUsername: string | null = null;
|
||||||
|
let replyToContent: string | null = null;
|
||||||
|
let replyToNonce: string | null = null;
|
||||||
|
let replyToAvatarUrl: string | null = null;
|
||||||
|
if (msg.replyTo) {
|
||||||
|
const repliedMsg = await ctx.db.get(msg.replyTo);
|
||||||
|
if (repliedMsg) {
|
||||||
|
const repliedSender = await ctx.db.get(repliedMsg.senderId);
|
||||||
|
replyToUsername = repliedSender?.username || "Unknown";
|
||||||
|
replyToContent = repliedMsg.ciphertext;
|
||||||
|
replyToNonce = repliedMsg.nonce;
|
||||||
|
if (repliedSender?.avatarStorageId) {
|
||||||
|
replyToAvatarUrl = await getPublicStorageUrl(ctx, repliedSender.avatarStorageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: msg._id,
|
||||||
|
channel_id: msg.channelId,
|
||||||
|
sender_id: msg.senderId,
|
||||||
|
ciphertext: msg.ciphertext,
|
||||||
|
nonce: msg.nonce,
|
||||||
|
signature: msg.signature,
|
||||||
|
key_version: msg.keyVersion,
|
||||||
|
created_at: new Date(msg._creationTime).toISOString(),
|
||||||
|
username: sender?.username || "Unknown",
|
||||||
|
public_signing_key: sender?.publicSigningKey || "",
|
||||||
|
avatarUrl,
|
||||||
|
reactions: Object.keys(reactions).length > 0 ? reactions : null,
|
||||||
|
replyToId: msg.replyTo || null,
|
||||||
|
replyToUsername,
|
||||||
|
replyToContent,
|
||||||
|
replyToNonce,
|
||||||
|
replyToAvatarUrl,
|
||||||
|
editedAt: msg.editedAt || null,
|
||||||
|
pinned: msg.pinned || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const list = query({
|
export const list = query({
|
||||||
args: {
|
args: {
|
||||||
paginationOpts: paginationOptsValidator,
|
paginationOpts: paginationOptsValidator,
|
||||||
@@ -19,67 +81,7 @@ export const list = query({
|
|||||||
.paginate(args.paginationOpts);
|
.paginate(args.paginationOpts);
|
||||||
|
|
||||||
const enrichedPage = await Promise.all(
|
const enrichedPage = await Promise.all(
|
||||||
result.page.map(async (msg) => {
|
result.page.map((msg) => enrichMessage(ctx, msg, args.userId))
|
||||||
const sender = await ctx.db.get(msg.senderId);
|
|
||||||
|
|
||||||
let avatarUrl: string | null = null;
|
|
||||||
if (sender?.avatarStorageId) {
|
|
||||||
avatarUrl = await getPublicStorageUrl(ctx, sender.avatarStorageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const reactionDocs = await ctx.db
|
|
||||||
.query("messageReactions")
|
|
||||||
.withIndex("by_message", (q) => q.eq("messageId", msg._id))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
const reactions: Record<string, { count: number; me: boolean }> = {};
|
|
||||||
for (const r of reactionDocs) {
|
|
||||||
const entry = (reactions[r.emoji] ??= { count: 0, me: false });
|
|
||||||
entry.count++;
|
|
||||||
if (args.userId && r.userId === args.userId) {
|
|
||||||
entry.me = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let replyToUsername: string | null = null;
|
|
||||||
let replyToContent: string | null = null;
|
|
||||||
let replyToNonce: string | null = null;
|
|
||||||
let replyToAvatarUrl: string | null = null;
|
|
||||||
if (msg.replyTo) {
|
|
||||||
const repliedMsg = await ctx.db.get(msg.replyTo);
|
|
||||||
if (repliedMsg) {
|
|
||||||
const repliedSender = await ctx.db.get(repliedMsg.senderId);
|
|
||||||
replyToUsername = repliedSender?.username || "Unknown";
|
|
||||||
replyToContent = repliedMsg.ciphertext;
|
|
||||||
replyToNonce = repliedMsg.nonce;
|
|
||||||
if (repliedSender?.avatarStorageId) {
|
|
||||||
replyToAvatarUrl = await getPublicStorageUrl(ctx, repliedSender.avatarStorageId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: msg._id,
|
|
||||||
channel_id: msg.channelId,
|
|
||||||
sender_id: msg.senderId,
|
|
||||||
ciphertext: msg.ciphertext,
|
|
||||||
nonce: msg.nonce,
|
|
||||||
signature: msg.signature,
|
|
||||||
key_version: msg.keyVersion,
|
|
||||||
created_at: new Date(msg._creationTime).toISOString(),
|
|
||||||
username: sender?.username || "Unknown",
|
|
||||||
public_signing_key: sender?.publicSigningKey || "",
|
|
||||||
avatarUrl,
|
|
||||||
reactions: Object.keys(reactions).length > 0 ? reactions : null,
|
|
||||||
replyToId: msg.replyTo || null,
|
|
||||||
replyToUsername,
|
|
||||||
replyToContent,
|
|
||||||
replyToNonce,
|
|
||||||
replyToAvatarUrl,
|
|
||||||
editedAt: msg.editedAt || null,
|
|
||||||
pinned: msg.pinned || false,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return { ...result, page: enrichedPage };
|
return { ...result, page: enrichedPage };
|
||||||
@@ -145,30 +147,19 @@ export const pin = mutation({
|
|||||||
export const listPinned = query({
|
export const listPinned = query({
|
||||||
args: {
|
args: {
|
||||||
channelId: v.id("channels"),
|
channelId: v.id("channels"),
|
||||||
|
userId: v.optional(v.id("userProfiles")),
|
||||||
},
|
},
|
||||||
returns: v.any(),
|
returns: v.any(),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const allMessages = await ctx.db
|
const pinned = await ctx.db
|
||||||
.query("messages")
|
.query("messages")
|
||||||
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
|
.withIndex("by_channel_pinned", (q) =>
|
||||||
|
q.eq("channelId", args.channelId).eq("pinned", true)
|
||||||
|
)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
const pinned = allMessages.filter((m) => m.pinned === true);
|
|
||||||
|
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
pinned.map(async (msg) => {
|
pinned.map((msg) => enrichMessage(ctx, msg, args.userId))
|
||||||
const sender = await ctx.db.get(msg.senderId);
|
|
||||||
return {
|
|
||||||
id: msg._id,
|
|
||||||
ciphertext: msg.ciphertext,
|
|
||||||
nonce: msg.nonce,
|
|
||||||
signature: msg.signature,
|
|
||||||
key_version: msg.keyVersion,
|
|
||||||
created_at: new Date(msg._creationTime).toISOString(),
|
|
||||||
username: sender?.username || "Unknown",
|
|
||||||
public_signing_key: sender?.publicSigningKey || "",
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ export default defineSchema({
|
|||||||
replyTo: v.optional(v.id("messages")),
|
replyTo: v.optional(v.id("messages")),
|
||||||
editedAt: v.optional(v.number()),
|
editedAt: v.optional(v.number()),
|
||||||
pinned: v.optional(v.boolean()),
|
pinned: v.optional(v.boolean()),
|
||||||
}).index("by_channel", ["channelId"]),
|
}).index("by_channel", ["channelId"])
|
||||||
|
.index("by_channel_pinned", ["channelId", "pinned"]),
|
||||||
|
|
||||||
messageReactions: defineTable({
|
messageReactions: defineTable({
|
||||||
messageId: v.id("messages"),
|
messageId: v.id("messages"),
|
||||||
@@ -110,6 +111,7 @@ export default defineSchema({
|
|||||||
isDeafened: v.boolean(),
|
isDeafened: v.boolean(),
|
||||||
isScreenSharing: v.boolean(),
|
isScreenSharing: v.boolean(),
|
||||||
isServerMuted: v.boolean(),
|
isServerMuted: v.boolean(),
|
||||||
|
watchingStream: v.optional(v.id("userProfiles")),
|
||||||
})
|
})
|
||||||
.index("by_channel", ["channelId"])
|
.index("by_channel", ["channelId"])
|
||||||
.index("by_user", ["userId"]),
|
.index("by_user", ["userId"]),
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ export const updateState = mutation({
|
|||||||
Object.entries(updates).filter(([, val]) => val !== undefined)
|
Object.entries(updates).filter(([, val]) => val !== undefined)
|
||||||
);
|
);
|
||||||
await ctx.db.patch(existing._id, filtered);
|
await ctx.db.patch(existing._id, filtered);
|
||||||
|
|
||||||
|
// When a user stops screen sharing, clear all viewers watching their stream
|
||||||
|
if (args.isScreenSharing === false) {
|
||||||
|
const allStates = await ctx.db.query("voiceStates").collect();
|
||||||
|
for (const s of allStates) {
|
||||||
|
if (s.watchingStream === args.userId) {
|
||||||
|
await ctx.db.patch(s._id, { watchingStream: undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -105,6 +115,28 @@ export const serverMute = mutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const setWatchingStream = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.id("userProfiles"),
|
||||||
|
watchingStream: v.optional(v.id("userProfiles")),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("voiceStates")
|
||||||
|
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, {
|
||||||
|
watchingStream: args.watchingStream ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const getAll = query({
|
export const getAll = query({
|
||||||
args: {},
|
args: {},
|
||||||
returns: v.any(),
|
returns: v.any(),
|
||||||
@@ -119,6 +151,7 @@ export const getAll = query({
|
|||||||
isScreenSharing: boolean;
|
isScreenSharing: boolean;
|
||||||
isServerMuted: boolean;
|
isServerMuted: boolean;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
|
watchingStream: string | null;
|
||||||
}>> = {};
|
}>> = {};
|
||||||
|
|
||||||
for (const s of states) {
|
for (const s of states) {
|
||||||
@@ -136,6 +169,7 @@ export const getAll = query({
|
|||||||
isScreenSharing: s.isScreenSharing,
|
isScreenSharing: s.isScreenSharing,
|
||||||
isServerMuted: s.isServerMuted,
|
isServerMuted: s.isServerMuted,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
|
watchingStream: s.watchingStream ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +211,14 @@ export const afkMove = mutation({
|
|||||||
isServerMuted: currentState.isServerMuted,
|
isServerMuted: currentState.isServerMuted,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clear viewers watching the moved user's stream (screen sharing stops on AFK move)
|
||||||
|
const allStates = await ctx.db.query("voiceStates").collect();
|
||||||
|
for (const s of allStates) {
|
||||||
|
if (s.watchingStream === args.userId) {
|
||||||
|
await ctx.db.patch(s._id, { watchingStream: undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user