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

This commit is contained in:
Bryan1029384756
2026-02-13 07:18:19 -06:00
parent 2201c56cb2
commit 56a9523e38
15 changed files with 436 additions and 178 deletions

View File

@@ -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();

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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}
/>
<div className="messages-list" ref={messagesContainerRef}>

View File

@@ -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 (
<div className="pinned-panel">
<div className="pinned-panel-header">
@@ -53,33 +133,60 @@ const PinnedMessagesPanel = ({ channelId, visible, onClose, channelKey, onJumpTo
No pinned messages in this channel yet.
</div>
) : (
decryptedPins.map(msg => (
<div key={msg.id} className="pinned-message-item">
<div className="pinned-message-header">
<span className="pinned-message-author">{msg.username}</span>
<span className="pinned-message-date">
{new Date(msg.created_at).toLocaleDateString()}
</span>
decryptedPins.map(msg => {
const isOwner = msg.username === username;
return (
<div key={msg.id} className="pinned-message-card">
<MessageItem
msg={msg}
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 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>

View File

@@ -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

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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');

View File

@@ -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}
/>
</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}>
{loading ? 'Generating Keys...' : 'Continue'}
</button>

View File

@@ -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
}

View File

@@ -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. -->
- 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. -->
# 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. -->

View File

@@ -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<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({
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<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,
};
})
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))
);
},
});

View File

@@ -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"]),

View File

@@ -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;
},
});