diff --git a/Frontend/Electron/main.cjs b/Frontend/Electron/main.cjs index 775a9b4..c45fb2b 100644 --- a/Frontend/Electron/main.cjs +++ b/Frontend/Electron/main.cjs @@ -79,6 +79,11 @@ function createWindow() { // Save window state 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 if (!mainWindow.isMaximized()) { const bounds = mainWindow.getBounds(); diff --git a/Frontend/Electron/package.json b/Frontend/Electron/package.json index 2d467ac..145ef3a 100644 --- a/Frontend/Electron/package.json +++ b/Frontend/Electron/package.json @@ -1,7 +1,7 @@ { "name": "discord", "private": true, - "version": "1.0.10", + "version": "1.0.11", "description": "A Discord clone built with Convex, React, and Electron", "author": "Moyettes", "type": "module", diff --git a/Frontend/Electron/src/App.jsx b/Frontend/Electron/src/App.jsx index 2f21cf0..e7d0486 100644 --- a/Frontend/Electron/src/App.jsx +++ b/Frontend/Electron/src/App.jsx @@ -32,6 +32,15 @@ function AuthGuard({ children }) { if (session.publicKey) localStorage.setItem('publicKey', session.publicKey); sessionStorage.setItem('signingKey', session.signingKey); 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'); return; } diff --git a/Frontend/Electron/src/components/ChatArea.jsx b/Frontend/Electron/src/components/ChatArea.jsx index 0bb6235..7e7f142 100644 --- a/Frontend/Electron/src/components/ChatArea.jsx +++ b/Frontend/Electron/src/components/ChatArea.jsx @@ -1236,6 +1236,15 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u onClose={onTogglePinned} channelKey={channelKey} onJumpToMessage={scrollToMessage} + userId={currentUserId} + username={username} + roles={roles} + Attachment={Attachment} + LinkPreview={LinkPreview} + DirectVideo={DirectVideo} + onReactionClick={handleReactionClick} + onProfilePopup={handleProfilePopup} + onImageClick={setZoomedImage} />
diff --git a/Frontend/Electron/src/components/PinnedMessagesPanel.jsx b/Frontend/Electron/src/components/PinnedMessagesPanel.jsx index 61b603a..091b512 100644 --- a/Frontend/Electron/src/components/PinnedMessagesPanel.jsx +++ b/Frontend/Electron/src/components/PinnedMessagesPanel.jsx @@ -1,13 +1,31 @@ import React, { useState, useEffect } from 'react'; import { useQuery, useMutation } from 'convex/react'; 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 pinnedMessages = useQuery( api.messages.listPinned, - channelId ? { channelId } : "skip" + channelId ? { channelId, userId: userId || undefined } : "skip" ) || []; const unpinMutation = useMutation(api.messages.pin); @@ -19,28 +37,90 @@ const PinnedMessagesPanel = ({ channelId, visible, onClose, channelKey, onJumpTo } let cancelled = false; - const decrypt = async () => { - const results = await Promise.all( - pinnedMessages.map(async (msg) => { - try { - const TAG_LENGTH = 32; - const tag = msg.ciphertext.slice(-TAG_LENGTH); - const content = msg.ciphertext.slice(0, -TAG_LENGTH); - const decrypted = await window.cryptoAPI.decryptData(content, channelKey, msg.nonce, tag); - return { ...msg, content: decrypted }; - } catch { - return { ...msg, content: '[Encrypted Message]' }; - } - }) - ); + + const decryptAll = async () => { + // Build batch arrays for message decryption + const decryptItems = []; + const decryptMsgMap = []; + const replyDecryptItems = []; + const replyMsgMap = []; + const verifyItems = []; + const verifyMsgMap = []; + + 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); }; - decrypt(); + + decryptAll(); return () => { cancelled = true; }; }, [pinnedMessages, channelKey]); if (!visible) return null; + const noop = () => {}; + return (
@@ -53,33 +133,60 @@ const PinnedMessagesPanel = ({ channelId, visible, onClose, channelKey, onJumpTo No pinned messages in this channel yet.
) : ( - decryptedPins.map(msg => ( -
-
- {msg.username} - - {new Date(msg.created_at).toLocaleDateString()} - + decryptedPins.map(msg => { + const isOwner = msg.username === username; + return ( +
+ 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} + /> +
+ + +
-
- {msg.content?.startsWith('{') ? '[Attachment]' : msg.content} -
-
- - -
-
- )) + ); + }) )}
diff --git a/Frontend/Electron/src/components/Sidebar.jsx b/Frontend/Electron/src/components/Sidebar.jsx index 285997d..15e9741 100644 --- a/Frontend/Electron/src/components/Sidebar.jsx +++ b/Frontend/Electron/src/components/Sidebar.jsx @@ -152,10 +152,20 @@ const UserControlPanel = ({ username, userId }) => { if (window.sessionPersistence) { try { await window.sessionPersistence.clear(); } catch {} } - // Clear storage (preserve theme) + // Clear storage (preserve theme and user preferences) 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(); if (theme) localStorage.setItem('theme', theme); + for (const [key, value] of Object.entries(savedPrefs)) { + localStorage.setItem(key, value); + } sessionStorage.clear(); navigate('/'); }; @@ -749,7 +759,15 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam const [newChannelType, setNewChannelType] = useState('text'); const [editingChannel, setEditingChannel] = useState(null); 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 [voiceUserMenu, setVoiceUserMenu] = useState(null); const [showCreateChannelModal, setShowCreateChannelModal] = useState(false); @@ -1107,11 +1125,9 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam }; const toggleCategory = (cat) => { - setCollapsedCategories(prev => { - const next = { ...prev, [cat]: !prev[cat] }; - setUserPref(userId, 'collapsedCategories', next); - return next; - }); + const next = { ...collapsedCategories, [cat]: !collapsedCategories[cat] }; + setCollapsedCategories(next); + setUserPref(userId, 'collapsedCategories', next); }; // Group channels by categoryId diff --git a/Frontend/Electron/src/contexts/VoiceContext.jsx b/Frontend/Electron/src/contexts/VoiceContext.jsx index 2aad4e4..ecdc5a0 100644 --- a/Frontend/Electron/src/contexts/VoiceContext.jsx +++ b/Frontend/Electron/src/contexts/VoiceContext.jsx @@ -12,6 +12,8 @@ import muteSound from '../assets/sounds/mute.mp3'; import unmuteSound from '../assets/sounds/unmute.mp3'; import deafenSound from '../assets/sounds/deafen.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 = { join: joinSound, @@ -19,7 +21,9 @@ const soundMap = { mute: muteSound, unmute: unmuteSound, deafen: deafenSound, - undeafen: undeafenSound + undeafen: undeafenSound, + viewer_join: viewerJoinSound, + viewer_leave: viewerLeaveSound, }; const VoiceContext = createContext(); @@ -46,12 +50,36 @@ export const VoiceProvider = ({ children }) => { const [isScreenSharing, setIsScreenSharingLocal] = useState(false); const isMovingRef = useRef(false); + const convex = useConvex(); + // Stream watching state (lifted from VoiceStage so PiP can persist across navigation) const [watchingStreamOf, setWatchingStreamOfRaw] = useState(null); const setWatchingStreamOf = useCallback((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) const [personallyMutedUsers, setPersonallyMutedUsers] = useState(() => { @@ -127,8 +155,6 @@ export const VoiceProvider = ({ children }) => { const isPersonallyMuted = (userId) => personallyMutedUsers.has(userId); - const convex = useConvex(); - const serverMute = async (targetUserId, isServerMuted) => { const actorUserId = localStorage.getItem('userId'); if (!actorUserId) return; @@ -389,14 +415,14 @@ export const VoiceProvider = ({ children }) => { || (room.localParticipant.identity === watchingStreamOf ? room.localParticipant : null); if (!participant) { - setWatchingStreamOfRaw(null); + clearWatchingStream(); return; } // Check if they're still screen sharing const { screenSharePub } = findTrackPubs(participant); if (!screenSharePub) { - setWatchingStreamOfRaw(null); + clearWatchingStream(); } }; @@ -413,10 +439,59 @@ export const VoiceProvider = ({ children }) => { // Reset watching state when room disconnects useEffect(() => { if (!room) { - setWatchingStreamOfRaw(null); + clearWatchingStream(); } }, [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 = () => { console.log('User manually disconnected voice'); if (room) room.disconnect(); diff --git a/Frontend/Electron/src/index.css b/Frontend/Electron/src/index.css index 4b02e2d..e4fae66 100644 --- a/Frontend/Electron/src/index.css +++ b/Frontend/Electron/src/index.css @@ -1596,42 +1596,30 @@ body { padding: 32px 16px; } -.pinned-message-item { +.pinned-message-card { background-color: var(--bg-primary); - border-radius: 4px; - padding: 12px; + border-radius: 8px; margin-bottom: 8px; + overflow: hidden; } -.pinned-message-header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 4px; +.pinned-message-card .message-item { + padding: 8px 12px 4px; } -.pinned-message-author { - color: var(--header-primary); - font-weight: 600; - font-size: 14px; +.pinned-message-card .message-toolbar { + display: none !important; } -.pinned-message-date { - color: var(--text-muted); - font-size: 12px; -} - -.pinned-message-content { - color: var(--text-normal); - font-size: 14px; - line-height: 1.4; - margin-bottom: 8px; - word-break: break-word; +.pinned-message-card .message-content img, +.pinned-message-card .message-content video { + max-width: 100%; } .pinned-message-actions { display: flex; gap: 8px; + padding: 4px 12px 10px 68px; } .pinned-action-btn { diff --git a/Frontend/Electron/src/pages/Chat.jsx b/Frontend/Electron/src/pages/Chat.jsx index 4e86d4d..661674a 100644 --- a/Frontend/Electron/src/pages/Chat.jsx +++ b/Frontend/Electron/src/pages/Chat.jsx @@ -14,10 +14,13 @@ import { PresenceProvider } from '../contexts/PresenceContext'; import { getUserPref, setUserPref } from '../utils/userPreferences'; 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 [username, setUsername] = useState(''); - const [userId, setUserId] = useState(null); const [channelKeys, setChannelKeys] = useState({}); const [activeDMChannel, setActiveDMChannel] = useState(null); const [showMembers, setShowMembers] = useState(true); @@ -57,17 +60,6 @@ const Chat = () => { 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(() => { if (!rawChannelKeys || rawChannelKeys.length === 0) return; const privateKey = sessionStorage.getItem('privateKey'); diff --git a/Frontend/Electron/src/pages/Register.jsx b/Frontend/Electron/src/pages/Register.jsx index b10ecc2..0f827e8 100644 --- a/Frontend/Electron/src/pages/Register.jsx +++ b/Frontend/Electron/src/pages/Register.jsx @@ -13,6 +13,7 @@ function parseInviteParams(input) { const Register = () => { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const [inviteKeys, setInviteKeys] = useState(null); @@ -70,6 +71,12 @@ const Register = () => { const handleRegister = async (e) => { e.preventDefault(); setError(''); + + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + setLoading(true); try { @@ -176,6 +183,16 @@ const Register = () => { disabled={loading} />
+
+ + setConfirmPassword(e.target.value)} + required + disabled={loading} + /> +
diff --git a/Frontend/Electron/src/utils/userPreferences.js b/Frontend/Electron/src/utils/userPreferences.js index d65f712..d62b52b 100644 --- a/Frontend/Electron/src/utils/userPreferences.js +++ b/Frontend/Electron/src/utils/userPreferences.js @@ -17,6 +17,10 @@ export function setUserPref(userId, key, value) { const prefs = raw ? JSON.parse(raw) : {}; prefs[key] = value; 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 { // Silently fail on corrupt data or full storage } diff --git a/TODO.md b/TODO.md index 1dac0a4..093bff5 100644 --- a/TODO.md +++ b/TODO.md @@ -22,15 +22,16 @@ -- Independient voice volumes per user. + # Future -- Allow users to add custom join sounds. + - -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. \ No newline at end of file + \ No newline at end of file diff --git a/convex/messages.ts b/convex/messages.ts index e2a3370..45ef767 100644 --- a/convex/messages.ts +++ b/convex/messages.ts @@ -4,6 +4,68 @@ import { v } from "convex/values"; import { getPublicStorageUrl } from "./storageUrl"; 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 = {}; + 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({ args: { paginationOpts: paginationOptsValidator, @@ -19,67 +81,7 @@ export const list = query({ .paginate(args.paginationOpts); const enrichedPage = await Promise.all( - result.page.map(async (msg) => { - 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 = {}; - 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, - }; - }) + result.page.map((msg) => enrichMessage(ctx, msg, args.userId)) ); return { ...result, page: enrichedPage }; @@ -145,30 +147,19 @@ export const pin = mutation({ export const listPinned = query({ args: { channelId: v.id("channels"), + userId: v.optional(v.id("userProfiles")), }, returns: v.any(), handler: async (ctx, args) => { - const allMessages = await ctx.db + const pinned = await ctx.db .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(); - const pinned = allMessages.filter((m) => m.pinned === true); - return Promise.all( - pinned.map(async (msg) => { - 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 || "", - }; - }) + pinned.map((msg) => enrichMessage(ctx, msg, args.userId)) ); }, }); diff --git a/convex/schema.ts b/convex/schema.ts index 7677fc3..fc91de3 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -42,7 +42,8 @@ export default defineSchema({ replyTo: v.optional(v.id("messages")), editedAt: v.optional(v.number()), pinned: v.optional(v.boolean()), - }).index("by_channel", ["channelId"]), + }).index("by_channel", ["channelId"]) + .index("by_channel_pinned", ["channelId", "pinned"]), messageReactions: defineTable({ messageId: v.id("messages"), @@ -110,6 +111,7 @@ export default defineSchema({ isDeafened: v.boolean(), isScreenSharing: v.boolean(), isServerMuted: v.boolean(), + watchingStream: v.optional(v.id("userProfiles")), }) .index("by_channel", ["channelId"]) .index("by_user", ["userId"]), diff --git a/convex/voiceState.ts b/convex/voiceState.ts index 2042f14..ee64954 100644 --- a/convex/voiceState.ts +++ b/convex/voiceState.ts @@ -70,6 +70,16 @@ export const updateState = mutation({ Object.entries(updates).filter(([, val]) => val !== undefined) ); 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; @@ -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({ args: {}, returns: v.any(), @@ -119,6 +151,7 @@ export const getAll = query({ isScreenSharing: boolean; isServerMuted: boolean; avatarUrl: string | null; + watchingStream: string | null; }>> = {}; for (const s of states) { @@ -136,6 +169,7 @@ export const getAll = query({ isScreenSharing: s.isScreenSharing, isServerMuted: s.isServerMuted, avatarUrl, + watchingStream: s.watchingStream ?? null, }); } @@ -177,6 +211,14 @@ export const afkMove = mutation({ 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; }, });