feat: Implement core chat application UI, including chat, voice, members, DMs, and shared components.
Some checks failed
Build and Release / build-and-release (push) Failing after 0s

This commit is contained in:
Bryan1029384756
2026-02-14 01:57:15 -06:00
parent 6f12f98d30
commit 958cf56b23
51 changed files with 4761 additions and 1858 deletions

View File

@@ -174,9 +174,27 @@ async function decryptBatch(items) {
}));
}
// --- Ed25519 Support Detection ---
let ed25519Supported = null;
async function checkEd25519Support() {
if (ed25519Supported !== null) return ed25519Supported;
try {
await crypto.subtle.generateKey('Ed25519', false, ['sign', 'verify']);
ed25519Supported = true;
} catch {
ed25519Supported = false;
}
return ed25519Supported;
}
// --- Batch Verify ---
async function verifyBatch(items) {
const supported = await checkEd25519Support();
if (!supported) {
return items.map(() => ({ success: true, verified: null }));
}
return Promise.all(items.map(async ({ publicKey, message, signature }) => {
try {
const verified = await verifySignature(publicKey, message, signature);

View File

@@ -1,7 +1,7 @@
{
"name": "@discord-clone/shared",
"private": true,
"version": "1.0.14",
"version": "1.0.16",
"type": "module",
"main": "src/App.jsx",
"dependencies": {

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect } from 'react';
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import Login from './pages/Login';
import Register from './pages/Register';
@@ -62,21 +62,18 @@ function AuthGuard({ children }) {
return () => { cancelled = true; };
}, []);
// Redirect once after auth state is determined (not on every route change)
const hasRedirected = useRef(false);
useEffect(() => {
if (authState === 'loading' || hasRedirected.current) return;
hasRedirected.current = true;
if (authState === 'loading') return;
const isAuthPage = location.pathname === '/' || location.pathname === '/register';
const hasSession = sessionStorage.getItem('privateKey') && sessionStorage.getItem('signingKey');
if (authState === 'authenticated' && isAuthPage) {
if (hasSession && isAuthPage) {
navigate('/chat', { replace: true });
} else if (authState === 'unauthenticated' && !isAuthPage) {
} else if (!hasSession && !isAuthPage) {
navigate('/', { replace: true });
}
}, [authState]);
}, [authState, location.pathname]);
if (authState === 'loading') {
return (

View File

@@ -24,7 +24,9 @@ import UserProfilePopup from './UserProfilePopup';
import Avatar from './Avatar';
import MentionMenu from './MentionMenu';
import MessageItem, { getUserColor } from './MessageItem';
import ColoredIcon from './ColoredIcon';
import { usePlatform } from '../platform';
import { useVoice } from '../contexts/VoiceContext';
const metadataCache = new Map();
const attachmentCache = new Map();
@@ -59,8 +61,8 @@ export function clearMessageDecryptionCache() {
messageDecryptionCache.clear();
}
const ICON_COLOR_DEFAULT = 'color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)';
const ICON_COLOR_DANGER = 'color-mix(in oklab, hsl(1.353 calc(1*82.609%) 68.431% /1) 100%, #000 0%)';
const ICON_COLOR_DEFAULT = 'hsl(240, 4.294%, 68.039%)';
const ICON_COLOR_DANGER = 'hsl(1.353, 82.609%, 68.431%)';
const fromHexString = (hexString) =>
new Uint8Array(hexString.match(/.{1,2}/g).map((byte) => parseInt(byte, 16)));
@@ -463,14 +465,33 @@ const MessageContextMenu = ({ x, y, onInteract, onClose, isOwner, canDelete }) =
);
};
const ColoredIcon = ({ src, color, size = '24px', style = {} }) => (
<div style={{ width: size, height: size, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', ...style }}>
<img src={src} alt="" style={color ? { width: size, height: size, transform: 'translateX(-1000px)', filter: `drop-shadow(1000px 0 0 ${color})` } : { width: size, height: size, objectFit: 'contain' }} />
</div>
);
const InputContextMenu = ({ x, y, onClose, onPaste }) => {
const menuRef = useRef(null);
const [pos, setPos] = useState({ top: y, left: x });
useEffect(() => { const h = () => onClose(); window.addEventListener('click', h); return () => window.removeEventListener('click', h); }, [onClose]);
React.useLayoutEffect(() => {
if (!menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
let newTop = y, newLeft = x;
if (x + rect.width > window.innerWidth) newLeft = x - rect.width;
if (y + rect.height > window.innerHeight) newTop = y - rect.height;
if (newLeft < 0) newLeft = 10;
if (newTop < 0) newTop = 10;
setPos({ top: newTop, left: newLeft });
}, [x, y]);
return (
<div ref={menuRef} className="context-menu" style={{ top: pos.top, left: pos.left }} onClick={(e) => e.stopPropagation()}>
<div className="context-menu-item" onClick={(e) => { e.stopPropagation(); onPaste(); onClose(); }}>
<span>Paste</span>
</div>
</div>
);
};
const ChatArea = ({ channelId, channelName, channelType, username, channelKey, userId: currentUserId, showMembers, onToggleMembers, onOpenDM, showPinned, onTogglePinned }) => {
const { crypto } = usePlatform();
const { isReceivingScreenShareAudio } = useVoice();
const [decryptedMessages, setDecryptedMessages] = useState([]);
const [input, setInput] = useState('');
const [zoomedImage, setZoomedImage] = useState(null);
@@ -481,6 +502,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const [isMultiline, setIsMultiline] = useState(false);
const [hoveredMessageId, setHoveredMessageId] = useState(null);
const [contextMenu, setContextMenu] = useState(null);
const [inputContextMenu, setInputContextMenu] = useState(null);
const [uploading, setUploading] = useState(false);
const [replyingTo, setReplyingTo] = useState(null);
const [editingMessage, setEditingMessage] = useState(null);
@@ -563,7 +585,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
return {
...msg,
content: cached?.content ?? '[Decrypting...]',
isVerified: cached?.isVerified ?? false,
isVerified: cached?.isVerified ?? null,
decryptedReply: cached?.decryptedReply ?? null,
};
});
@@ -667,14 +689,15 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
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 verified = verifyResults[i].verified;
verifyMap.set(msg.id, verified === null ? null : (verifyResults[i].success && verified));
}
// Populate cache
for (const msg of needsDecryption) {
const content = decryptedMap.get(msg.id) ??
(msg.ciphertext && msg.ciphertext.length < TAG_LENGTH ? '[Invalid Encrypted Message]' : '[Encrypted Message - Key Missing]');
const isVerified = verifyMap.get(msg.id) ?? false;
const isVerified = verifyMap.has(msg.id) ? verifyMap.get(msg.id) : null;
const decryptedReply = replyMap.get(msg.id) ?? null;
messageDecryptionCache.set(msg.id, { content, isVerified, decryptedReply });
}
@@ -715,13 +738,14 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
}, [username, myRoleNames]);
const playPingSound = useCallback(() => {
if (isReceivingScreenShareAudio) return;
const now = Date.now();
if (now - lastPingTimeRef.current < 1000) return;
lastPingTimeRef.current = now;
const audio = new Audio(PingSound);
audio.volume = 0.5;
audio.play().catch(() => {});
}, []);
}, [isReceivingScreenShareAudio]);
// Play ping sound when a new message mentions us (by username or role)
useEffect(() => {
@@ -1341,6 +1365,38 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
</div>
</div>
{contextMenu && <MessageContextMenu x={contextMenu.x} y={contextMenu.y} isOwner={contextMenu.isOwner} canDelete={contextMenu.canDelete} onClose={() => setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />}
{inputContextMenu && <InputContextMenu x={inputContextMenu.x} y={inputContextMenu.y} onClose={() => setInputContextMenu(null)} onPaste={async () => {
try {
if (inputDivRef.current) inputDivRef.current.focus();
// Try reading clipboard items for images first
if (navigator.clipboard.read) {
try {
const items = await navigator.clipboard.read();
for (const item of items) {
const imageType = item.types.find(t => t.startsWith('image/'));
if (imageType) {
const blob = await item.getType(imageType);
const file = new File([blob], `pasted-image.${imageType.split('/')[1] || 'png'}`, { type: imageType });
processFile(file);
return;
}
}
} catch {}
}
// Fall back to plain text
const text = await navigator.clipboard.readText();
if (text) {
document.execCommand('insertText', false, text);
// Sync state — onInput may not fire from async execCommand
const el = inputDivRef.current;
if (el) {
setInput(el.textContent);
const inner = el.innerText;
setIsMultiline(inner.includes('\n') || el.scrollHeight > 50);
}
}
} catch {}
}} />}
<form className="chat-input-form" onSubmit={handleSend} style={{ position: 'relative' }}>
{mentionQuery !== null && mentionItems.length > 0 && (
@@ -1382,6 +1438,10 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
onBlur={saveSelection}
onMouseUp={saveSelection}
onKeyUp={saveSelection}
onContextMenu={(e) => {
e.preventDefault();
setInputContextMenu({ x: e.clientX, y: e.clientY });
}}
onPaste={(e) => {
const items = e.clipboardData?.items;
if (items) {

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import Tooltip from './Tooltip';
const ChatHeader = ({ channelName, channelType, channelTopic, onToggleMembers, showMembers, onTogglePinned, serverName }) => {
const ChatHeader = ({ channelName, channelType, channelTopic, onToggleMembers, showMembers, onTogglePinned, serverName, isMobile, onMobileBack }) => {
const [searchFocused, setSearchFocused] = useState(false);
const isDM = channelType === 'dm';
@@ -10,9 +10,14 @@ const ChatHeader = ({ channelName, channelType, channelTopic, onToggleMembers, s
return (
<div className="chat-header">
<div className="chat-header-left">
{isMobile && onMobileBack && (
<button className="mobile-back-btn" onClick={onMobileBack}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
</button>
)}
<span className="chat-header-icon">{isDM ? '@' : '#'}</span>
<span className="chat-header-name">{channelName}</span>
{channelTopic && !isDM && (
{channelTopic && !isDM && !isMobile && (
<>
<div className="chat-header-divider" />
<span className="chat-header-topic" title={channelTopic}>{channelTopic}</span>
@@ -21,7 +26,7 @@ const ChatHeader = ({ channelName, channelType, channelTopic, onToggleMembers, s
{isDM && <span className="chat-header-status-text"></span>}
</div>
<div className="chat-header-right">
{!isDM && (
{!isDM && !isMobile && (
<Tooltip text="Threads" position="bottom">
<button className="chat-header-btn">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
@@ -37,7 +42,7 @@ const ChatHeader = ({ channelName, channelType, channelTopic, onToggleMembers, s
</svg>
</button>
</Tooltip>
{!isDM && (
{!isDM && !isMobile && (
<Tooltip text={showMembers ? "Hide Members" : "Show Members"} position="bottom">
<button
className={`chat-header-btn ${showMembers ? 'active' : ''}`}
@@ -50,22 +55,26 @@ const ChatHeader = ({ channelName, channelType, channelTopic, onToggleMembers, s
</button>
</Tooltip>
)}
<Tooltip text="Notification Settings" position="bottom">
<button className="chat-header-btn">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M18 9V14C18 15.657 19.344 17 21 17V18H3V17C4.656 17 6 15.657 6 14V9C6 5.686 8.686 3 12 3C15.314 3 18 5.686 18 9ZM11.9999 22C10.5239 22 9.24993 20.955 8.99993 19.5H14.9999C14.7499 20.955 13.4759 22 11.9999 22Z" />
</svg>
</button>
</Tooltip>
<div className="chat-header-search-wrapper">
<input
type="text"
placeholder={searchPlaceholder}
className={`chat-header-search ${searchFocused ? 'focused' : ''}`}
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
/>
</div>
{!isMobile && (
<Tooltip text="Notification Settings" position="bottom">
<button className="chat-header-btn">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M18 9V14C18 15.657 19.344 17 21 17V18H3V17C4.656 17 6 15.657 6 14V9C6 5.686 8.686 3 12 3C15.314 3 18 5.686 18 9ZM11.9999 22C10.5239 22 9.24993 20.955 8.99993 19.5H14.9999C14.7499 20.955 13.4759 22 11.9999 22Z" />
</svg>
</button>
</Tooltip>
)}
{!isMobile && (
<div className="chat-header-search-wrapper">
<input
type="text"
placeholder={searchPlaceholder}
className={`chat-header-search ${searchFocused ? 'focused' : ''}`}
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
/>
</div>
)}
</div>
</div>
);

View File

@@ -0,0 +1,31 @@
import React from 'react';
const ColoredIcon = React.memo(({ src, color, size = '20px', style = {} }) => {
if (!color) {
return (
<div style={{ width: size, height: size, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, ...style }}>
<img src={src} alt="" style={{ width: size, height: size, objectFit: 'contain' }} />
</div>
);
}
return (
<div style={{
width: size, height: size, flexShrink: 0,
overflow: 'hidden',
...style,
}}>
<img
src={src}
alt=""
style={{
width: size, height: size,
objectFit: 'contain',
filter: `drop-shadow(${size} 0 0 ${color})`,
transform: `translateX(-${size})`,
}}
/>
</div>
);
});
export default ColoredIcon;

View File

@@ -3,6 +3,7 @@ import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import Tooltip from './Tooltip';
import Avatar from './Avatar';
import ColoredIcon from './ColoredIcon';
import { useOnlineUsers } from '../contexts/PresenceContext';
import friendsIcon from '../assets/icons/friends.svg';
@@ -181,26 +182,7 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
onClick={() => onSelectDM('friends')}
>
<div style={{ marginRight: '12px' }}>
<div style={{
width: 24,
height: 24,
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}>
<img
src={friendsIcon}
alt=""
style={{
width: 24,
height: 24,
transform: 'translateX(-1000px)',
filter: `drop-shadow(1000px 0 0 var(--interactive-normal))`
}}
/>
</div>
<ColoredIcon src={friendsIcon} color="var(--interactive-normal)" size="24px" />
</div>
<span style={{ fontWeight: 500 }}>Friends</span>
</div>

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import Avatar from './Avatar';
import ColoredIcon from './ColoredIcon';
import { useOnlineUsers } from '../contexts/PresenceContext';
import friendsIcon from '../assets/icons/friends.svg';
@@ -54,26 +55,7 @@ const FriendsView = ({ onOpenDM }) => {
}}>
<div style={{ display: 'flex', alignItems: 'center', marginRight: '16px', paddingRight: '16px', borderRight: '1px solid var(--border-subtle)' }}>
<div style={{ marginRight: '12px' }}>
<div style={{
width: 24,
height: 24,
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}>
<img
src={friendsIcon}
alt=""
style={{
width: 24,
height: 24,
transform: 'translateX(-1000px)',
filter: `drop-shadow(1000px 0 0 var(--interactive-normal))`
}}
/>
</div>
<ColoredIcon src={friendsIcon} color="var(--interactive-normal)" size="24px" />
</div>
Friends
</div>

View File

@@ -4,6 +4,7 @@ import { api } from '../../../../convex/_generated/api';
import { useOnlineUsers } from '../contexts/PresenceContext';
import { useVoice } from '../contexts/VoiceContext';
import { CrownIcon, SharingIcon } from '../assets/icons';
import ColoredIcon from './ColoredIcon';
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
@@ -15,12 +16,6 @@ function getUserColor(name) {
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
}
const ColoredIcon = ({ src, color, size = '24px', style = {} }) => (
<div style={{ width: size, height: size, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', ...style }}>
<img src={src} alt="" style={color ? { width: size, height: size, transform: 'translateX(-1000px)', filter: `drop-shadow(1000px 0 0 ${color})` } : { width: size, height: size, objectFit: 'contain' }} />
</div>
);
const STATUS_COLORS = {
online: '#3ba55c',
idle: '#faa61a',

View File

@@ -14,13 +14,14 @@ import {
import { getEmojiUrl, AllEmojis } from '../assets/emojis';
import Tooltip from './Tooltip';
import Avatar from './Avatar';
import ColoredIcon from './ColoredIcon';
import { usePlatform } from '../platform';
const fireIcon = getEmojiUrl('nature', 'fire');
const heartIcon = getEmojiUrl('symbols', 'heart');
const thumbsupIcon = getEmojiUrl('people', 'thumbsup');
const ICON_COLOR_DEFAULT = 'color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)';
const ICON_COLOR_DEFAULT = 'hsl(240, 4.294%, 68.039%)';
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
export const getUserColor = (name) => {
@@ -99,12 +100,6 @@ const getReactionIcon = (name) => {
}
};
const ColoredIcon = ({ src, color, size = '24px', style = {} }) => (
<div style={{ width: size, height: size, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', ...style }}>
<img src={src} alt="" style={color ? { width: size, height: size, transform: 'translateX(-1000px)', filter: `drop-shadow(1000px 0 0 ${color})` } : { width: size, height: size, objectFit: 'contain' }} />
</div>
);
const isNewDay = (current, previous) => {
if (!previous) return true;
return current.getDate() !== previous.getDate()
@@ -123,7 +118,7 @@ const createMarkdownComponents = (openExternal) => ({
return <span>{props.children}</span>;
}
}
if (props.href && props.href.startsWith('mention://')) return <span style={{ background: 'color-mix(in oklab,hsl(234.935 calc(1*85.556%) 64.706% /0.23921568627450981) 100%,hsl(0 0% 0% /0.23921568627450981) 0%)', borderRadius: '3px', color: 'color-mix(in oklab, hsl(228.14 calc(1*100%) 83.137% /1) 100%, #000 0%)', fontWeight: 500, padding: '0 2px', cursor: 'default' }} title={props.children}>{props.children}</span>;
if (props.href && props.href.startsWith('mention://')) return <span style={{ background: 'hsla(234.935, 85.556%, 64.706%, 0.239)', borderRadius: '3px', color: 'hsl(228.14, 100%, 83.137%)', fontWeight: 500, padding: '0 2px', cursor: 'default' }} title={props.children}>{props.children}</span>;
return <a {...props} onClick={(e) => { e.preventDefault(); openExternal(props.href); }} style={{ color: '#00b0f4', cursor: 'pointer', textDecoration: 'none' }} onMouseOver={(e) => e.target.style.textDecoration = 'underline'} onMouseOut={(e) => e.target.style.textDecoration = 'none'} />;
},
code({ node, inline, className, children, ...props }) {
@@ -163,7 +158,7 @@ const MessageToolbar = ({ onAddReaction, onEdit, onReply, onMore, isOwner }) =>
<Tooltip text="Fire" position="top">
<IconButton onClick={() => onAddReaction('fire')} emoji={<ColoredIcon src={fireIcon} size="20px" />} />
</Tooltip>
<div style={{ width: '1px', height: '24px', margin: '2px 4px', backgroundColor: 'color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%)' }}></div>
<div style={{ width: '1px', height: '24px', margin: '2px 4px', backgroundColor: 'hsla(240, 4%, 60.784%, 0.122)' }}></div>
<Tooltip text="Add Reaction" position="top">
<IconButton onClick={() => onAddReaction(null)} emoji={<ColoredIcon src={EmojieIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
</Tooltip>
@@ -262,7 +257,7 @@ const MessageItem = React.memo(({
return (
<div style={{ display: 'flex', gap: '4px', marginTop: '4px', flexWrap: 'wrap' }}>
{Object.entries(msg.reactions).map(([emojiName, data]) => (
<div key={emojiName} onClick={() => onReactionClick(msg.id, emojiName, data.me)} style={{ display: 'flex', alignItems: 'center', backgroundColor: data.me ? 'rgba(88, 101, 242, 0.15)' : 'hsl(240 calc(1*4%) 60.784% / 0.0784313725490196)', border: data.me ? '1px solid var(--brand-experiment)' : '1px solid transparent', borderRadius: '8px', padding: '2px 6px', cursor: 'pointer', gap: '4px' }}>
<div key={emojiName} onClick={() => onReactionClick(msg.id, emojiName, data.me)} style={{ display: 'flex', alignItems: 'center', backgroundColor: data.me ? 'rgba(88, 101, 242, 0.15)' : 'hsla(240, 4%, 60.784%, 0.078)', border: data.me ? '1px solid var(--brand-experiment)' : '1px solid transparent', borderRadius: '8px', padding: '2px 6px', cursor: 'pointer', gap: '4px' }}>
<ColoredIcon src={getReactionIcon(emojiName)} size="16px" color={null} />
<span style={{ fontSize: '12px', color: data.me ? '#dee0fc' : 'var(--header-secondary)', fontWeight: 600 }}>{data.count}</span>
</div>
@@ -287,12 +282,12 @@ const MessageItem = React.memo(({
<div
id={`msg-${msg.id}`}
className={`message-item${isGrouped ? ' message-grouped' : ''}`}
style={isMentioned ? { background: 'color-mix(in oklab,hsl(36.894 calc(1*100%) 31.569% /0.0784313725490196) 100%,hsl(0 0% 0% /0.0784313725490196) 0%)', position: 'relative' } : { position: 'relative' }}
style={isMentioned ? { background: 'hsla(36.894, 100%, 31.569%, 0.078)', position: 'relative' } : { position: 'relative' }}
onMouseEnter={onHover}
onMouseLeave={onLeave}
onContextMenu={onContextMenu}
>
{isMentioned && <div style={{ background: 'color-mix(in oklab, hsl(34 calc(1*50.847%) 53.725% /1) 100%, #000 0%)', bottom: 0, display: 'block', left: 0, pointerEvents: 'none', position: 'absolute', top: 0, width: '2px' }} />}
{isMentioned && <div style={{ background: 'hsl(34, 50.847%, 53.725%)', bottom: 0, display: 'block', left: 0, pointerEvents: 'none', position: 'absolute', top: 0, width: '2px' }} />}
{msg.replyToId && msg.replyToUsername && (
<div className="message-reply-context" onClick={() => onScrollToMessage(msg.replyToId)}>
@@ -330,7 +325,7 @@ const MessageItem = React.memo(({
>
{msg.username || 'Unknown'}
</span>
{!msg.isVerified && <span className="verification-failed" title="Signature Verification Failed!"><svg aria-hidden="true" role="img" width="16" height="16" viewBox="0 0 24 24" fill="var(--danger)"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path></svg></span>}
{msg.isVerified === false && <span className="verification-failed" title="Signature Verification Failed!"><svg aria-hidden="true" role="img" width="16" height="16" viewBox="0 0 24 24" fill="var(--danger)"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path></svg></span>}
<span className="timestamp">{currentDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</span>
</div>
)}

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect, useLayoutEffect } from 'react';
import React, { useState, useRef, useEffect, useLayoutEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useConvex, useMutation, useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
@@ -32,12 +32,13 @@ import screenShareStartSound from '../assets/sounds/screenshare_start.mp3';
import screenShareStopSound from '../assets/sounds/screenshare_stop.mp3';
import { getUserPref, setUserPref } from '../utils/userPreferences';
import { usePlatform } from '../platform';
import ColoredIcon from './ColoredIcon';
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
const ICON_COLOR_DEFAULT = 'color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)';
const ICON_COLOR_ACTIVE = 'hsl(357.692 67.826% 54.902% / 1)';
const SERVER_MUTE_RED = 'color-mix(in oklab, hsl(1.343 calc(1*84.81%) 69.02% /1) 100%, #000 0%)';
const ICON_COLOR_DEFAULT = 'hsl(240, 4.294%, 68.039%)';
const ICON_COLOR_ACTIVE = 'hsl(357.692, 67.826%, 54.902%)';
const SERVER_MUTE_RED = 'hsl(1.343, 84.81%, 69.02%)';
const controlButtonStyle = {
background: 'transparent',
@@ -68,29 +69,6 @@ function randomHex(length) {
return bytesToHex(bytes);
}
const ColoredIcon = ({ src, color, size = '20px' }) => (
<div style={{
width: size,
height: size,
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}>
<img
src={src}
alt=""
style={{
width: size,
height: size,
transform: 'translateX(-1000px)',
filter: `drop-shadow(1000px 0 0 ${color})`
}}
/>
</div>
);
const VoiceTimer = () => {
const [elapsed, setElapsed] = React.useState(0);
React.useEffect(() => {
@@ -114,7 +92,7 @@ const STATUS_OPTIONS = [
{ value: 'invisible', label: 'Invisible', color: '#747f8d' },
];
const UserControlPanel = ({ username, userId }) => {
const UserControlPanel = React.memo(({ username, userId }) => {
const { session, idle } = usePlatform();
const { isMuted, isDeafened, toggleMute, toggleDeafen, connectionState, disconnectVoice } = useVoice();
const [showStatusMenu, setShowStatusMenu] = useState(false);
@@ -124,23 +102,32 @@ const UserControlPanel = ({ username, userId }) => {
const navigate = useNavigate();
const manualStatusRef = useRef(false);
const preIdleStatusRef = useRef('online');
const hasInitializedRef = useRef(false);
const currentStatusRef = useRef(currentStatus);
currentStatusRef.current = currentStatus;
// Fetch stored status preference from server and sync local state
const allUsers = useQuery(api.auth.getPublicKeys) || [];
const myUser = allUsers.find(u => u.id === userId);
React.useEffect(() => {
if (myUser) {
if (myUser.status && myUser.status !== 'offline') {
setCurrentStatus(myUser.status);
// dnd/invisible are manual overrides; idle is auto-set so don't count it
manualStatusRef.current = (myUser.status === 'dnd' || myUser.status === 'invisible');
} else if (!myUser.status || myUser.status === 'offline') {
// First login or no preference set yet — default to "online"
const isInitial = !hasInitializedRef.current;
if (isInitial) hasInitializedRef.current = true;
// 'idle' is auto-set by the idle detector, not a user preference —
// on a fresh app launch, reset it to 'online' just like 'offline'
const shouldReset = !myUser.status || myUser.status === 'offline'
|| (isInitial && myUser.status === 'idle');
if (shouldReset) {
setCurrentStatus('online');
manualStatusRef.current = false;
if (userId) {
updateStatusMutation({ userId, status: 'online' }).catch(() => {});
}
} else if (myUser.status) {
setCurrentStatus(myUser.status);
manualStatusRef.current = (myUser.status === 'dnd' || myUser.status === 'invisible');
}
}
}, [myUser?.status]);
@@ -188,13 +175,13 @@ const UserControlPanel = ({ username, userId }) => {
}
};
// Auto-idle detection via Electron powerMonitor
// Auto-idle detection via platform idle API
useEffect(() => {
if (!idle || !userId) return;
const handleIdleChange = (data) => {
if (manualStatusRef.current) return;
if (data.isIdle) {
preIdleStatusRef.current = currentStatus;
preIdleStatusRef.current = currentStatusRef.current;
setCurrentStatus('idle');
updateStatusMutation({ userId, status: 'idle' }).catch(() => {});
} else {
@@ -294,7 +281,7 @@ const UserControlPanel = ({ username, userId }) => {
)}
</div>
);
};
});
@@ -311,9 +298,9 @@ const voicePanelButtonStyle = {
flex: 1,
alignItems: 'center',
minHeight: '32px',
background: 'hsl(240 calc(1*4%) 60.784% /0.0784313725490196)',
border: 'hsl(0 calc(1*0%) 100% /0.0784313725490196)',
borderColor: 'color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.0392156862745098) 100%,hsl(0 0% 0% /0.0392156862745098) 0%)',
background: 'hsla(240, 4%, 60.784%, 0.078)',
border: 'hsla(0, 0%, 100%, 0.078)',
borderColor: 'hsla(240, 4%, 60.784%, 0.039)',
borderRadius: '8px',
cursor: 'pointer',
padding: '4px',
@@ -332,7 +319,7 @@ const liveBadgeStyle = {
height: '16px',
minHeight: '16px',
minWidth: '16px',
color: 'hsl(0 calc(1*0%) 100% /1)',
color: 'hsl(0, 0%, 100%)',
fontSize: '12px',
fontWeight: '700',
letterSpacing: '.02em',
@@ -343,8 +330,8 @@ const liveBadgeStyle = {
marginRight: '4px'
};
const ACTIVE_SPEAKER_SHADOW = '0 0 0 0px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 2px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 3px color-mix(in oklab, hsl(240 calc(1*7.143%) 10.98% /1) 100%, #000 0%)';
const VOICE_ACTIVE_COLOR = "color-mix(in oklab, hsl(132.809 calc(1*34.902%) 50% /1) 100%, #000 0%)";
const ACTIVE_SPEAKER_SHADOW = '0 0 0 0px hsl(134.526, 41.485%, 44.902%), inset 0 0 0 2px hsl(134.526, 41.485%, 44.902%), inset 0 0 0 3px hsl(240, 7.143%, 10.98%)';
const VOICE_ACTIVE_COLOR = 'hsl(132.809, 34.902%, 50%)';
async function encryptKeyForUsers(convex, channelId, keyHex, crypto) {
const users = await convex.query(api.auth.getPublicKeys, {});
@@ -389,7 +376,7 @@ function getScreenCaptureConstraints(selection) {
};
}
const VoiceUserContextMenu = ({ x, y, onClose, user, onMute, isMuted, onServerMute, isServerMuted, hasPermission, onMessage, isSelf, userVolume, onVolumeChange }) => {
const VoiceUserContextMenu = ({ x, y, onClose, user, onMute, isMuted, onServerMute, isServerMuted, hasPermission, onDisconnect, hasDisconnectPermission, onMessage, isSelf, userVolume, onVolumeChange }) => {
const menuRef = useRef(null);
const [pos, setPos] = useState({ top: y, left: x });
@@ -428,7 +415,7 @@ const VoiceUserContextMenu = ({ x, y, onClose, user, onMute, isMuted, onServerMu
value={userVolume}
onChange={(e) => onVolumeChange(Number(e.target.value))}
className="context-menu-volume-slider"
style={{ background: `linear-gradient(to right, hsl(235 86% 65%) ${sliderPercent}%, var(--bg-tertiary) ${sliderPercent}%)` }}
style={{ background: `linear-gradient(to right, hsl(235, 86%, 65%) ${sliderPercent}%, var(--bg-tertiary) ${sliderPercent}%)` }}
/>
</div>
<div className="context-menu-separator" />
@@ -479,6 +466,14 @@ const VoiceUserContextMenu = ({ x, y, onClose, user, onMute, isMuted, onServerMu
</div>
</div>
)}
{!isSelf && hasDisconnectPermission && (
<div
className="context-menu-item"
onClick={(e) => { e.stopPropagation(); onDisconnect(); onClose(); }}
>
<span style={{ color: SERVER_MUTE_RED }}>Disconnect</span>
</div>
)}
<div className="context-menu-separator" />
<div className="context-menu-item" onClick={(e) => { e.stopPropagation(); onMessage(); onClose(); }}>
<span>Message</span>
@@ -754,7 +749,7 @@ const DraggableVoiceUser = ({ userId, channelId, disabled, children }) => {
);
};
const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels, userId, serverName = 'Secure Chat', serverIconUrl }) => {
const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels, userId, serverName = 'Secure Chat', serverIconUrl, isMobile }) => {
const { crypto, settings } = usePlatform();
const [isCreating, setIsCreating] = useState(false);
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
@@ -829,6 +824,8 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
[dmChannels, unreadChannels, view, activeDMChannel]
);
const { connectToVoice, activeChannelId: voiceChannelId, connectionState, disconnectVoice, activeChannelName: voiceChannelName, voiceStates, room, activeSpeakers, setScreenSharing, isPersonallyMuted, togglePersonalMute, isMuted: selfMuted, toggleMute, serverMute, disconnectUser, isServerMuted, serverSettings, getUserVolume, setUserVolume, isReceivingScreenShareAudio } = useVoice();
const prevUnreadDMsRef = useRef(null);
useEffect(() => {
@@ -843,15 +840,17 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
for (const id of currentIds) {
if (!prevUnreadDMsRef.current.has(id)) {
const audio = new Audio(PingSound);
audio.volume = 0.5;
audio.play().catch(() => {});
if (!isReceivingScreenShareAudio) {
const audio = new Audio(PingSound);
audio.volume = 0.5;
audio.play().catch(() => {});
}
break;
}
}
prevUnreadDMsRef.current = currentIds;
}, [dmChannels, unreadChannels]);
}, [dmChannels, unreadChannels, isReceivingScreenShareAudio]);
const onRenameChannel = () => {};
@@ -859,8 +858,6 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
if (activeChannel === id) onSelectChannel(null);
};
const { connectToVoice, activeChannelId: voiceChannelId, connectionState, disconnectVoice, activeChannelName: voiceChannelName, voiceStates, room, activeSpeakers, setScreenSharing, isPersonallyMuted, togglePersonalMute, isMuted: selfMuted, toggleMute, serverMute, isServerMuted, serverSettings, getUserVolume, setUserVolume } = useVoice();
const handleStartCreate = () => {
setIsCreating(true);
setNewChannelName('');
@@ -987,7 +984,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
});
}
new Audio(screenShareStartSound).play();
if (!isReceivingScreenShareAudio) new Audio(screenShareStartSound).play();
setScreenSharing(true);
track.onended = () => {
@@ -1017,7 +1014,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
}
}
room.localParticipant.setScreenShareEnabled(false);
new Audio(screenShareStopSound).play();
if (!isReceivingScreenShareAudio) new Audio(screenShareStopSound).play();
setScreenSharing(false);
} else {
setIsScreenShareModalOpen(true);
@@ -1025,8 +1022,11 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
};
const handleChannelClick = (channel) => {
if (channel.type === 'voice' && voiceChannelId !== channel._id) {
connectToVoice(channel._id, channel.name, localStorage.getItem('userId'));
if (channel.type === 'voice') {
if (voiceChannelId !== channel._id) {
connectToVoice(channel._id, channel.name, localStorage.getItem('userId'));
}
onSelectChannel(channel._id);
} else {
onSelectChannel(channel._id);
}
@@ -1127,11 +1127,18 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
);
};
const toggleCategory = (cat) => {
const next = { ...collapsedCategories, [cat]: !collapsedCategories[cat] };
setCollapsedCategories(next);
setUserPref(userId, 'collapsedCategories', next, settings);
};
const toggleCategory = useCallback((cat) => {
setCollapsedCategories(prev => {
const next = { ...prev, [cat]: !prev[cat] };
setUserPref(userId, 'collapsedCategories', next, settings);
return next;
});
}, [userId, settings]);
const handleAddChannelToCategory = useCallback((groupId) => {
setCreateChannelCategoryId(groupId === '__uncategorized__' ? null : groupId);
setShowCreateChannelModal(true);
}, []);
// Group channels by categoryId
const groupedChannels = React.useMemo(() => {
@@ -1377,12 +1384,10 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
<SortableCategory key={group.id} id={`category-${group.id}`}>
<CategoryHeader
group={group}
groupId={group.id}
collapsed={collapsedCategories[group.id]}
onToggle={() => toggleCategory(group.id)}
onAddChannel={() => {
setCreateChannelCategoryId(group.id === '__uncategorized__' ? null : group.id);
setShowCreateChannelModal(true);
}}
onToggle={toggleCategory}
onAddChannel={handleAddChannelToCategory}
/>
{(() => {
const isCollapsed = collapsedCategories[group.id];
@@ -1578,7 +1583,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
margin: '8px 8px 0px 8px',
display: 'flex',
flexDirection: 'column',
borderBottom: "1px solid color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.0392156862745098) 100%,hsl(0 0% 0% /0.0392156862745098) 0%)"
borderBottom: '1px solid hsla(240, 4%, 60.784%, 0.039)'
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<div style={{ color: '#43b581', fontWeight: 'bold', fontSize: 13 }}>Voice Connected</div>
@@ -1648,6 +1653,8 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
isServerMuted={isServerMuted(voiceUserMenu.user.userId)}
onServerMute={() => serverMute(voiceUserMenu.user.userId, !isServerMuted(voiceUserMenu.user.userId))}
hasPermission={!!myPermissions.mute_members}
onDisconnect={() => disconnectUser(voiceUserMenu.user.userId)}
hasDisconnectPermission={!!myPermissions.move_members}
onMessage={() => {
onOpenDM(voiceUserMenu.user.userId, voiceUserMenu.user.username);
onViewChange('me');
@@ -1692,16 +1699,16 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
};
// Category header component (extracted for DnD drag handle)
const CategoryHeader = ({ group, collapsed, onToggle, onAddChannel, dragListeners }) => (
<div className="channel-category-header" onClick={onToggle} {...(dragListeners || {})}>
const CategoryHeader = React.memo(({ group, groupId, collapsed, onToggle, onAddChannel, dragListeners }) => (
<div className="channel-category-header" onClick={() => onToggle(groupId)} {...(dragListeners || {})}>
<span className="category-label">{group.name}</span>
<div className={`category-chevron ${collapsed ? 'collapsed' : ''}`}>
<ColoredIcon src={categoryCollapsedIcon} color="currentColor" size="12px" />
</div>
<button className="category-add-btn" onClick={(e) => { e.stopPropagation(); onAddChannel(); }} title="Create Channel">
<button className="category-add-btn" onClick={(e) => { e.stopPropagation(); onAddChannel(groupId); }} title="Create Channel">
+
</button>
</div>
);
));
export default Sidebar;

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, createContext, useContext } from 'react';
import { usePlatform } from '../platform';
import ColoredIcon from './ColoredIcon';
import updateIcon from '../assets/icons/update.svg';
const RELEASE_URL = 'https://gitea.moyettes.com/Moyettes/DiscordClone/releases/tag/latest';
@@ -73,26 +74,7 @@ export function TitleBarUpdateIcon() {
style={{ borderRight: '1px solid var(--app-frame-border)' }}
>
<div style={{ marginRight: '12px' }}>
<div style={{
width: 20,
height: 20,
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}>
<img
src={updateIcon}
alt=""
style={{
width: 20,
height: 20,
transform: 'translateX(-1000px)',
filter: `drop-shadow(1000px 0 0 #3ba55c)`,
}}
/>
</div>
<ColoredIcon src={updateIcon} color="#3ba55c" size="20px" />
</div>
</button>
);

View File

@@ -15,8 +15,9 @@ import personalMuteIcon from '../assets/icons/personal_mute.svg';
import serverMuteIcon from '../assets/icons/server_mute.svg';
import screenShareStartSound from '../assets/sounds/screenshare_start.mp3';
import screenShareStopSound from '../assets/sounds/screenshare_stop.mp3';
import ColoredIcon from './ColoredIcon';
const SERVER_MUTE_RED = 'color-mix(in oklab, hsl(1.343 calc(1*84.81%) 69.02% /1) 100%, #000 0%)';
const SERVER_MUTE_RED = 'hsl(1.343, 84.81%, 69.02%)';
const getInitials = (name) => (name || '?').substring(0, 1).toUpperCase();
@@ -43,30 +44,6 @@ const WATCH_STREAM_BUTTON_STYLE = {
const THUMBNAIL_SIZE = { width: 120, height: 68 };
const BOTTOM_BAR_HEIGHT = 140;
// Helper Component for coloring SVGs (Reused from Sidebar)
const ColoredIcon = ({ src, color, size = '24px' }) => (
<div style={{
width: size,
height: size,
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}>
<img
src={src}
alt=""
style={{
width: size,
height: size,
transform: 'translateX(-1000px)',
filter: `drop-shadow(1000px 0 0 ${color})`
}}
/>
</div>
);
// --- Components ---
const ParticipantTile = ({ participant, username, avatarUrl }) => {
@@ -266,6 +243,47 @@ const ParticipantThumbnail = ({ participant, username, avatarUrl, isStreamer, is
);
};
// Inline SVG icons for volume control
const SpeakerIcon = ({ volume, muted }) => {
if (muted || volume === 0) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
<path d="M11 5L6 9H2v6h4l5 4V5z" />
<line x1="23" y1="9" x2="17" y2="15" stroke="white" strokeWidth="2" strokeLinecap="round" />
<line x1="17" y1="9" x2="23" y2="15" stroke="white" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
if (volume < 50) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
<path d="M11 5L6 9H2v6h4l5 4V5z" />
<path d="M15.54 8.46a5 5 0 010 7.07" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
<path d="M11 5L6 9H2v6h4l5 4V5z" />
<path d="M15.54 8.46a5 5 0 010 7.07" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" />
<path d="M19.07 4.93a10 10 0 010 14.14" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" />
</svg>
);
};
// Inline SVG icons for fullscreen
const ExpandIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="white">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
</svg>
);
const CompressIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="white">
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" />
</svg>
);
const FocusedStreamView = ({
streamParticipant,
streamerUsername,
@@ -278,11 +296,34 @@ const FocusedStreamView = ({
streamingIdentities,
voiceUsers,
isTabVisible,
isFullscreen,
onToggleFullscreen,
localIdentity,
}) => {
const screenTrack = useParticipantTrack(streamParticipant, 'screenshare');
const [barHover, setBarHover] = useState(false);
const [bottomEdgeHover, setBottomEdgeHover] = useState(false);
// Volume control state
const { getUserVolume, setUserVolume, togglePersonalMute, isPersonallyMuted } = useVoice();
const streamerId = streamParticipant.identity;
const isSelf = streamerId === localIdentity;
const isMutedByMe = isPersonallyMuted(streamerId);
const userVolume = getUserVolume(streamerId);
const [volumeExpanded, setVolumeExpanded] = useState(false);
const volumeHideTimeout = useRef(null);
const handleVolumeMouseEnter = () => {
if (volumeHideTimeout.current) clearTimeout(volumeHideTimeout.current);
setVolumeExpanded(true);
};
const handleVolumeMouseLeave = () => {
volumeHideTimeout.current = setTimeout(() => setVolumeExpanded(false), 1500);
};
useEffect(() => () => { if (volumeHideTimeout.current) clearTimeout(volumeHideTimeout.current); }, []);
const sliderPercent = (userVolume / 200) * 100;
// Auto-exit if stream track disappears
useEffect(() => {
if (!streamParticipant) {
@@ -362,23 +403,90 @@ const FocusedStreamView = ({
<span style={LIVE_BADGE_STYLE}>LIVE</span>
</div>
{/* Top-right: close button */}
<button
onClick={onStopWatching}
title="Stop Watching"
style={{
position: 'absolute', top: '12px', right: '12px',
width: '32px', height: '32px', borderRadius: '50%',
backgroundColor: 'rgba(0,0,0,0.6)', border: 'none',
color: 'white', fontSize: '18px', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'background-color 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.8)'}
onMouseLeave={e => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.6)'}
>
</button>
{/* Top-right: button group (fullscreen + close) */}
<div style={{
position: 'absolute', top: '12px', right: '12px',
display: 'flex', gap: '8px',
}}>
<button
onClick={onToggleFullscreen}
title={isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
style={{
width: '32px', height: '32px', borderRadius: '50%',
backgroundColor: 'rgba(0,0,0,0.6)', border: 'none',
color: 'white', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'background-color 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.8)'}
onMouseLeave={e => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.6)'}
>
{isFullscreen ? <CompressIcon /> : <ExpandIcon />}
</button>
<button
onClick={onStopWatching}
title="Stop Watching"
style={{
width: '32px', height: '32px', borderRadius: '50%',
backgroundColor: 'rgba(0,0,0,0.6)', border: 'none',
color: 'white', fontSize: '18px', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'background-color 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.8)'}
onMouseLeave={e => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.6)'}
>
</button>
</div>
{/* Bottom-left: volume control (hidden when watching own stream) */}
{!isSelf && (
<div
onMouseEnter={handleVolumeMouseEnter}
onMouseLeave={handleVolumeMouseLeave}
style={{
position: 'absolute', bottom: '12px', left: '12px',
display: 'flex', alignItems: 'center', gap: '8px',
backgroundColor: 'rgba(0,0,0,0.6)',
padding: '6px 10px',
borderRadius: '6px',
transition: 'width 0.2s ease',
}}
>
<button
onClick={() => togglePersonalMute(streamerId)}
title={isMutedByMe ? "Unmute" : "Mute"}
style={{
background: 'none', border: 'none', cursor: 'pointer',
padding: 0, display: 'flex', alignItems: 'center',
opacity: isMutedByMe ? 0.6 : 1,
}}
>
<SpeakerIcon volume={userVolume} muted={isMutedByMe} />
</button>
{volumeExpanded && (
<>
<input
type="range"
min="0"
max="200"
value={isMutedByMe ? 0 : userVolume}
onChange={(e) => setUserVolume(streamerId, Number(e.target.value))}
onMouseDown={(e) => e.stopPropagation()}
className="context-menu-volume-slider"
style={{
width: '100px',
background: `linear-gradient(to right, hsl(235, 86%, 65%) ${isMutedByMe ? 0 : sliderPercent}%, rgba(255,255,255,0.2) ${isMutedByMe ? 0 : sliderPercent}%)`,
}}
/>
<span style={{ color: 'white', fontSize: '12px', minWidth: '32px', textAlign: 'right' }}>
{isMutedByMe ? 0 : userVolume}%
</span>
</>
)}
</div>
)}
</div>
{/* Bottom participants bar */}
@@ -473,13 +581,37 @@ const FocusedStreamView = ({
const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
const [participants, setParticipants] = useState([]);
const { isMuted, toggleMute, disconnectVoice, setScreenSharing, connectToVoice, watchingStreamOf, setWatchingStreamOf } = useVoice();
const { isMuted, toggleMute, disconnectVoice, setScreenSharing, connectToVoice, watchingStreamOf, setWatchingStreamOf, isReceivingScreenShareAudio } = useVoice();
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
const [isScreenShareActive, setIsScreenShareActive] = useState(false);
const screenShareAudioTrackRef = useRef(null);
const [participantsCollapsed, setParticipantsCollapsed] = useState(false);
// Fullscreen support
const stageContainerRef = useRef(null);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
const toggleFullscreen = useCallback(() => {
if (!stageContainerRef.current) return;
if (document.fullscreenElement) {
document.exitFullscreen().catch(console.error);
} else {
stageContainerRef.current.requestFullscreen().catch(console.error);
}
}, []);
const isReceivingScreenShareAudioRef = useRef(false);
useEffect(() => { isReceivingScreenShareAudioRef.current = isReceivingScreenShareAudio; }, [isReceivingScreenShareAudio]);
useEffect(() => {
if (!room) return;
@@ -496,7 +628,7 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
room.on(RoomEvent.ParticipantDisconnected, updateParticipants);
room.localParticipant.on('localTrackPublished', updateParticipants);
room.localParticipant.on('localTrackUnpublished', (pub) => {
if (pub.source === Track.Source.ScreenShare || pub.source === 'screen_share') {
if ((pub.source === Track.Source.ScreenShare || pub.source === 'screen_share') && !isReceivingScreenShareAudioRef.current) {
new Audio(screenShareStopSound).play();
}
updateParticipants();
@@ -510,10 +642,11 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
};
}, [room]);
// Reset collapsed state when room disconnects
// Reset collapsed state and exit fullscreen when room disconnects
useEffect(() => {
if (!room) {
setParticipantsCollapsed(false);
if (document.fullscreenElement) document.exitFullscreen().catch(console.error);
}
}, [room]);
@@ -524,6 +657,7 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
);
const handleStopWatching = useCallback(() => {
if (document.fullscreenElement) document.exitFullscreen().catch(console.error);
setWatchingStreamOf(null);
setParticipantsCollapsed(false);
}, []);
@@ -589,7 +723,7 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
}
const track = stream.getVideoTracks()[0];
if (track) {
new Audio(screenShareStartSound).play();
if (!isReceivingScreenShareAudio) new Audio(screenShareStartSound).play();
await room.localParticipant.publishTrack(track, {
name: 'screen_share',
source: Track.Source.ScreenShare
@@ -657,6 +791,21 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
return () => document.removeEventListener('visibilitychange', handler);
}, []);
// F key shortcut to toggle fullscreen when watching a stream
useEffect(() => {
if (!watchingStreamOf) return;
const handleKeyDown = (e) => {
if (e.key === 'f' || e.key === 'F') {
const tag = e.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return;
e.preventDefault();
toggleFullscreen();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [watchingStreamOf, toggleFullscreen]);
if (!room) {
return (
<div style={{
@@ -732,7 +881,7 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
: null;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', flex: 1, backgroundColor: 'black', width: '100%' }}>
<div ref={stageContainerRef} style={{ display: 'flex', flexDirection: 'column', height: '100%', flex: 1, backgroundColor: 'black', width: '100%' }}>
{watchingStreamOf && watchedParticipant ? (
/* Focused/Fullscreen View */
<FocusedStreamView
@@ -747,6 +896,9 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
streamingIdentities={streamingIdentities}
voiceUsers={voiceUsers}
isTabVisible={isTabVisible}
isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen}
localIdentity={room.localParticipant.identity}
/>
) : (
/* Grid View */

View File

@@ -1,5 +1,5 @@
import React, { createContext, useContext, useMemo } from 'react';
import usePresence from '@convex-dev/presence/react';
import usePresence from '../hooks/usePresence.js';
import { api } from '../../../../convex/_generated/api';
const PresenceContext = createContext({

View File

@@ -31,7 +31,10 @@ const VoiceContext = createContext();
export const useVoice = () => useContext(VoiceContext);
let _suppressAppSounds = false;
function playSound(type) {
if (_suppressAppSounds) return;
const src = soundMap[type];
if (!src) return;
const audio = new Audio(src);
@@ -40,6 +43,7 @@ function playSound(type) {
}
function playSoundUrl(url) {
if (_suppressAppSounds) return;
const audio = new Audio(url);
audio.volume = 0.5;
audio.play().catch(e => console.error("Sound play failed", e));
@@ -60,6 +64,7 @@ export const VoiceProvider = ({ children }) => {
parseInt(localStorage.getItem('voiceOutputVolume') || '100')
);
const isMovingRef = useRef(false);
const [isReceivingScreenShareAudio, setIsReceivingScreenShareAudio] = useState(false);
const convex = useConvex();
@@ -113,7 +118,7 @@ export const VoiceProvider = ({ children }) => {
// Apply volume to LiveKit participant (factoring in global output volume)
const participant = room?.remoteParticipants?.get(userId);
const globalVol = globalOutputVolume / 100;
if (participant) participant.setVolume((volume / 100) * globalVol);
if (participant) participant.setVolume(Math.min(1, (volume / 100) * globalVol));
// Sync personal mute state
if (volume === 0) {
setPersonallyMutedUsers(prev => {
@@ -147,7 +152,7 @@ export const VoiceProvider = ({ children }) => {
const vol = userVolumes[userId] ?? 100;
const restoreVol = vol === 0 ? 100 : vol;
const participant = room?.remoteParticipants?.get(userId);
if (participant) participant.setVolume((restoreVol / 100) * globalVol);
if (participant) participant.setVolume(Math.min(1, (restoreVol / 100) * globalVol));
// Update stored volume if it was 0
if (vol === 0) {
setUserVolumes(p => {
@@ -178,6 +183,16 @@ export const VoiceProvider = ({ children }) => {
}
};
const disconnectUser = async (targetUserId) => {
const actorUserId = localStorage.getItem('userId');
if (!actorUserId) return;
try {
await convex.mutation(api.voiceState.disconnectUser, { actorUserId, targetUserId });
} catch (e) {
console.error('Failed to disconnect user:', e);
}
};
const isServerMuted = (userId) => {
for (const users of Object.values(voiceStates)) {
const user = users.find(u => u.userId === userId);
@@ -197,7 +212,7 @@ export const VoiceProvider = ({ children }) => {
);
// Refs for detecting other-user joins via voiceStates changes
const prevChannelUsersRef = useRef(new Map());
const prevChannelUsersRef = useRef(new Set());
const otherJoinInitRef = useRef(false);
const isInAfkChannel = !!(activeChannelId && serverSettings?.afkChannelId === activeChannelId);
@@ -378,7 +393,7 @@ export const VoiceProvider = ({ children }) => {
participant.setVolume(0);
} else {
const userVol = (userVolumes[identity] ?? 100) / 100;
participant.setVolume(userVol * globalVol);
participant.setVolume(Math.min(1, userVol * globalVol));
}
}
};
@@ -418,31 +433,34 @@ export const VoiceProvider = ({ children }) => {
// Detect other users joining the same voice channel and play their join sound
useEffect(() => {
if (!activeChannelId) {
prevChannelUsersRef.current = new Map();
prevChannelUsersRef.current = new Set();
otherJoinInitRef.current = false;
return;
}
const selfId = localStorage.getItem('userId');
const channelUsers = voiceStates[activeChannelId] || [];
const currentUsers = new Map();
for (const u of channelUsers) {
currentUsers.set(u.userId, u);
const currentUserIds = new Set(channelUsers.map(u => u.userId));
// Guard: ignore transient empty states when we previously had users
if (currentUserIds.size === 0 && prevChannelUsersRef.current.size > 0) {
return;
}
// Skip the first render after joining to avoid playing sounds for users already in the channel
if (!otherJoinInitRef.current) {
otherJoinInitRef.current = true;
prevChannelUsersRef.current = currentUsers;
prevChannelUsersRef.current = currentUserIds;
return;
}
const prev = prevChannelUsersRef.current;
const prevIds = prevChannelUsersRef.current;
// Detect new users (not self)
for (const [uid, userData] of currentUsers) {
if (uid !== selfId && !prev.has(uid)) {
if (userData.joinSoundUrl) {
for (const uid of currentUserIds) {
if (uid !== selfId && !prevIds.has(uid)) {
const userData = channelUsers.find(u => u.userId === uid);
if (userData?.joinSoundUrl) {
playSoundUrl(userData.joinSoundUrl);
} else {
playSound('join');
@@ -451,7 +469,7 @@ export const VoiceProvider = ({ children }) => {
}
}
prevChannelUsersRef.current = currentUsers;
prevChannelUsersRef.current = currentUserIds;
}, [voiceStates, activeChannelId]);
// Manage screen share subscriptions — only subscribe when actively watching
@@ -459,6 +477,7 @@ export const VoiceProvider = ({ children }) => {
if (!room) return;
const manageSubscriptions = () => {
let receivingAudio = false;
for (const p of room.remoteParticipants.values()) {
const { screenSharePub, screenShareAudioPub } = findTrackPubs(p);
@@ -470,7 +489,13 @@ export const VoiceProvider = ({ children }) => {
if (screenShareAudioPub && screenShareAudioPub.isSubscribed !== shouldSubscribe) {
screenShareAudioPub.setSubscribed(shouldSubscribe);
}
if (shouldSubscribe && screenShareAudioPub && screenShareAudioPub.isSubscribed) {
receivingAudio = true;
}
}
_suppressAppSounds = receivingAudio;
setIsReceivingScreenShareAudio(receivingAudio);
};
manageSubscriptions();
@@ -478,10 +503,14 @@ export const VoiceProvider = ({ children }) => {
const onTrackChange = () => manageSubscriptions();
room.on(RoomEvent.TrackPublished, onTrackChange);
room.on(RoomEvent.TrackSubscribed, onTrackChange);
room.on(RoomEvent.TrackUnsubscribed, onTrackChange);
return () => {
room.off(RoomEvent.TrackPublished, onTrackChange);
room.off(RoomEvent.TrackSubscribed, onTrackChange);
room.off(RoomEvent.TrackUnsubscribed, onTrackChange);
_suppressAppSounds = false;
setIsReceivingScreenShareAudio(false);
};
}, [room, watchingStreamOf]);
@@ -639,6 +668,7 @@ export const VoiceProvider = ({ children }) => {
setUserVolume,
getUserVolume,
serverMute,
disconnectUser,
isServerMuted,
isInAfkChannel,
serverSettings,
@@ -647,6 +677,7 @@ export const VoiceProvider = ({ children }) => {
switchDevice,
globalOutputVolume,
setGlobalOutputVolume,
isReceivingScreenShareAudio,
}}>
{children}
{room && (

View File

@@ -0,0 +1,18 @@
import { useState, useEffect } from 'react';
const MOBILE_BREAKPOINT = '(max-width: 768px)';
export function useIsMobile() {
const [isMobile, setIsMobile] = useState(() =>
typeof window !== 'undefined' && window.matchMedia(MOBILE_BREAKPOINT).matches
);
useEffect(() => {
const mql = window.matchMedia(MOBILE_BREAKPOINT);
const handler = (e) => setIsMobile(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
return isMobile;
}

View File

@@ -0,0 +1,123 @@
/**
* Custom usePresence hook based on @convex-dev/presence/react.
*
* Fix: The upstream hook disconnects on `visibilitychange` (document.hidden),
* which marks the user offline when the Electron window is minimized.
* This version keeps heartbeats running when hidden and only sends an
* immediate heartbeat + restarts the interval when becoming visible again
* (in case the browser had throttled timers).
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useMutation, useConvex } from "convex/react";
import useSingleFlight from "./useSingleFlight.js";
export default function usePresence(presence, roomId, userId, interval = 10000, convexUrl) {
const hasMounted = useRef(false);
const convex = useConvex();
const baseUrl = convexUrl ?? convex.url;
const [sessionId, setSessionId] = useState(() => crypto.randomUUID());
const [sessionToken, setSessionToken] = useState(null);
const sessionTokenRef = useRef(null);
const [roomToken, setRoomToken] = useState(null);
const roomTokenRef = useRef(null);
const intervalRef = useRef(null);
const heartbeat = useSingleFlight(useMutation(presence.heartbeat));
const disconnect = useSingleFlight(useMutation(presence.disconnect));
useEffect(() => {
// Reset session state when roomId or userId changes.
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (sessionTokenRef.current) {
void disconnect({ sessionToken: sessionTokenRef.current });
}
setSessionId(crypto.randomUUID());
setSessionToken(null);
setRoomToken(null);
}, [roomId, userId, disconnect]);
useEffect(() => {
sessionTokenRef.current = sessionToken;
roomTokenRef.current = roomToken;
}, [sessionToken, roomToken]);
useEffect(() => {
const sendHeartbeat = async () => {
const result = await heartbeat({ roomId, userId, sessionId, interval });
setRoomToken(result.roomToken);
setSessionToken(result.sessionToken);
};
// Send initial heartbeat
void sendHeartbeat();
// Clear any existing interval before setting a new one
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
intervalRef.current = setInterval(sendHeartbeat, interval);
// Handle page unload.
const handleUnload = () => {
if (sessionTokenRef.current) {
const blob = new Blob([
JSON.stringify({
path: "presence:disconnect",
args: { sessionToken: sessionTokenRef.current },
}),
], { type: "application/json" });
navigator.sendBeacon(`${baseUrl}/api/mutation`, blob);
}
};
window.addEventListener("beforeunload", handleUnload);
// Handle visibility changes.
// FIX: Do NOT disconnect when hidden. Electron timers keep running
// when minimized, so heartbeats continue normally. Only send an
// immediate heartbeat when becoming visible again to recover quickly
// in case the browser had throttled the interval.
const handleVisibility = async () => {
if (!document.hidden) {
void sendHeartbeat();
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
intervalRef.current = setInterval(sendHeartbeat, interval);
}
};
const wrappedHandleVisibility = () => {
handleVisibility().catch(console.error);
};
document.addEventListener("visibilitychange", wrappedHandleVisibility);
// Cleanup.
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
document.removeEventListener("visibilitychange", wrappedHandleVisibility);
window.removeEventListener("beforeunload", handleUnload);
// Don't disconnect on first render in strict mode.
if (hasMounted.current) {
if (sessionTokenRef.current) {
void disconnect({ sessionToken: sessionTokenRef.current });
}
}
};
}, [heartbeat, disconnect, roomId, userId, baseUrl, interval, sessionId]);
useEffect(() => {
hasMounted.current = true;
}, []);
const state = useQuery(presence.list, roomToken ? { roomToken } : "skip");
return useMemo(() => state?.slice().sort((a, b) => {
if (a.userId === userId) return -1;
if (b.userId === userId) return 1;
return 0;
}), [state, userId]);
}

View File

@@ -0,0 +1,40 @@
import { useCallback, useRef } from "react";
/**
* Wraps a function to single-flight invocations, using the latest args.
*
* Copied from @convex-dev/presence/dist/react/useSingleFlight.js
*/
export default function useSingleFlight(fn) {
const flightStatus = useRef({
inFlight: false,
upNext: null,
});
return useCallback((...args) => {
if (flightStatus.current.inFlight) {
return new Promise((resolve, reject) => {
flightStatus.current.upNext = { fn, resolve, reject, args };
});
}
flightStatus.current.inFlight = true;
const firstReq = fn(...args);
void (async () => {
try {
await firstReq;
}
finally {
// If it failed, we naively just move on to the next request.
}
while (flightStatus.current.upNext) {
const cur = flightStatus.current.upNext;
flightStatus.current.upNext = null;
await cur
.fn(...cur.args)
.then(cur.resolve)
.catch(cur.reject);
}
flightStatus.current.inFlight = false;
})();
return firstReq;
}, [fn]);
}

View File

@@ -28,9 +28,14 @@
font-style: normal;
}
html {
height: 100%;
}
body {
margin: 0;
padding: 0;
height: 100%;
font-family: "gg sans", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
background-color: var(--bg-primary);
color: var(--text-normal);
@@ -1220,15 +1225,15 @@ body {
}
.context-menu-item:hover {
background-color: hsl(240 calc(1*4%) 60.784% /0.0784313725490196);
background-color: hsla(240, 4%, 60.784%, 0.078);
}
.context-menu-item-danger {
color: color-mix(in oklab, hsl(1.353 calc(1*82.609%) 68.431% /1) 100%, #000 0%);
color: hsl(1.353, 82.609%, 68.431%);
}
.context-menu-item-danger:hover {
background-color: color-mix(in oklab,hsl(355.636 calc(1*64.706%) 50% /0.0784313725490196) 100%,hsl(0 0% 0% /0.0784313725490196) 0%);
background-color: hsla(355.636, 64.706%, 50%, 0.078);
}
.context-menu-checkbox-item {
@@ -1255,8 +1260,8 @@ body {
}
.context-menu-checkbox-indicator.checked {
background-color: hsl(235 86% 65%);
border-color: hsl(235 86% 65%);
background-color: hsl(235, 86%, 65%);
border-color: hsl(235, 86%, 65%);
}
.context-menu-separator {
@@ -1465,6 +1470,7 @@ body {
align-items: center;
justify-content: center;
width: 100%;
margin-top: 8px;
margin-bottom: 8px;
}
@@ -3085,4 +3091,104 @@ body {
background-color: rgba(88, 101, 242, 0.15) !important;
outline: 2px dashed var(--brand-experiment);
border-radius: 4px;
}
/* ============================================
MOBILE BACK BUTTON
============================================ */
.mobile-back-btn {
background: none;
border: none;
color: var(--header-secondary);
cursor: pointer;
padding: 4px;
margin-right: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.mobile-back-btn:hover {
color: var(--header-primary);
}
/* ============================================
MOBILE RESPONSIVE (max-width: 768px)
============================================ */
@media (max-width: 768px) {
/* App container: full dynamic viewport, no titlebar gap */
.app-container.is-mobile {
height: 100dvh;
padding-bottom: env(safe-area-inset-bottom, 0px);
box-sizing: border-box;
}
/* Sidebar fills entire screen on mobile */
.is-mobile .sidebar {
width: 100vw;
min-width: 100vw;
}
/* Hide members list on mobile (also enforced in JS) */
.is-mobile .members-list {
display: none !important;
}
/* Auth box responsive */
.auth-box {
width: calc(100vw - 32px);
max-width: 480px;
}
/* Pinned panel full-width on mobile */
.is-mobile .pinned-panel {
width: 100vw;
right: 0;
border-radius: 0;
}
/* Toast container centered on mobile */
.is-mobile .toast-container {
right: auto;
left: 50%;
transform: translateX(-50%);
bottom: 16px;
}
.is-mobile .toast {
min-width: 260px;
}
/* Responsive modals */
.create-channel-modal {
width: calc(100vw - 32px);
}
.theme-selector-modal {
width: calc(100vw - 32px);
}
.avatar-crop-dialog {
width: calc(100vw - 32px);
}
.forced-update-modal {
width: calc(100vw - 32px);
}
/* Chat container takes full width */
.is-mobile .chat-container {
width: 100vw;
}
/* Channel topic - hide on very small screens (also hidden via JS) */
.is-mobile .chat-header-topic {
display: none;
}
/* FriendsView takes full width */
.is-mobile .friends-view {
width: 100vw;
}
}

View File

@@ -13,9 +13,11 @@ import { useToasts } from '../components/Toast';
import { PresenceProvider } from '../contexts/PresenceContext';
import { getUserPref, setUserPref } from '../utils/userPreferences';
import { usePlatform } from '../platform';
import { useIsMobile } from '../hooks/useIsMobile';
const Chat = () => {
const { crypto, settings } = usePlatform();
const isMobile = useIsMobile();
const [userId, setUserId] = useState(() => localStorage.getItem('userId'));
const [username, setUsername] = useState(() => localStorage.getItem('username') || '');
const [view, setView] = useState(() => {
@@ -27,6 +29,7 @@ const Chat = () => {
const [activeDMChannel, setActiveDMChannel] = useState(null);
const [showMembers, setShowMembers] = useState(true);
const [showPinned, setShowPinned] = useState(false);
const [mobileView, setMobileView] = useState('sidebar');
const convex = useConvex();
const { toasts, addToast, removeToast, ToastContainer } = useToasts();
@@ -156,15 +159,21 @@ const Chat = () => {
setActiveDMChannel({ channel_id: channelId, other_username: targetUsername });
setView('me');
if (isMobile) setMobileView('chat');
} catch (err) {
console.error('Error opening DM:', err);
}
}, [convex]);
}, [convex, isMobile]);
const handleSelectChannel = useCallback((channelId) => {
setActiveChannel(channelId);
setShowPinned(false);
if (isMobile) setMobileView('chat');
}, [isMobile]);
const handleMobileBack = useCallback(() => {
setMobileView('sidebar');
}, []);
const activeChannelObj = channels.find(c => c._id === activeChannel);
@@ -173,6 +182,7 @@ const Chat = () => {
const isDMView = view === 'me' && activeDMChannel;
const isServerTextChannel = view === 'server' && activeChannel && activeChannelObj?.type !== 'voice';
const currentChannelId = isDMView ? activeDMChannel.channel_id : activeChannel;
const effectiveShowMembers = isMobile ? false : showMembers;
// PiP: show when watching a stream and NOT currently viewing the voice channel in VoiceStage
const isViewingVoiceStage = view === 'server' && activeChannelObj?.type === 'voice' && activeChannel === voiceActiveChannelId;
@@ -196,6 +206,8 @@ const Chat = () => {
onToggleMembers={() => {}}
showMembers={false}
onTogglePinned={() => setShowPinned(p => !p)}
isMobile={isMobile}
onMobileBack={handleMobileBack}
/>
<div className="chat-content">
<ChatArea
@@ -215,12 +227,43 @@ const Chat = () => {
</div>
);
}
return <FriendsView onOpenDM={openDM} />;
return (
<>
{isMobile && (
<div className="chat-header" style={{ position: 'sticky', top: 0, zIndex: 10 }}>
<div className="chat-header-left">
<button className="mobile-back-btn" onClick={handleMobileBack}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
</button>
<span className="chat-header-name">Friends</span>
</div>
</div>
)}
<FriendsView onOpenDM={openDM} />
</>
);
}
if (activeChannel) {
if (activeChannelObj?.type === 'voice') {
return <VoiceStage room={room} channelId={activeChannel} voiceStates={voiceStates} channelName={activeChannelObj?.name} />;
return (
<div className="chat-container">
{isMobile && (
<div className="chat-header">
<div className="chat-header-left">
<button className="mobile-back-btn" onClick={handleMobileBack}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
</button>
<svg width="20" height="20" viewBox="0 0 24 24" style={{ color: 'var(--text-muted)', marginRight: 4 }}>
<path fill="currentColor" d="M11.383 3.07904C11.009 2.92504 10.579 3.01004 10.293 3.29604L6.586 7.00304H3C2.45 7.00304 2 7.45304 2 8.00304V16.003C2 16.553 2.45 17.003 3 17.003H6.586L10.293 20.71C10.579 20.996 11.009 21.082 11.383 20.927C11.757 20.772 12 20.407 12 20.003V4.00304C12 3.59904 11.757 3.23404 11.383 3.07904Z" />
</svg>
<span className="chat-header-name">{activeChannelObj?.name}</span>
</div>
</div>
)}
<VoiceStage room={room} channelId={activeChannel} voiceStates={voiceStates} channelName={activeChannelObj?.name} />
</div>
);
}
return (
<div className="chat-container">
@@ -229,9 +272,11 @@ const Chat = () => {
channelType="text"
channelTopic={activeChannelObj?.topic}
onToggleMembers={() => setShowMembers(!showMembers)}
showMembers={showMembers}
showMembers={effectiveShowMembers}
onTogglePinned={() => setShowPinned(p => !p)}
serverName={serverName}
isMobile={isMobile}
onMobileBack={handleMobileBack}
/>
<div className="chat-content">
<ChatArea
@@ -241,7 +286,7 @@ const Chat = () => {
channelKey={channelKeys[activeChannel]}
username={username}
userId={userId}
showMembers={showMembers}
showMembers={effectiveShowMembers}
onToggleMembers={() => setShowMembers(!showMembers)}
onOpenDM={openDM}
showPinned={showPinned}
@@ -249,7 +294,7 @@ const Chat = () => {
/>
<MembersList
channelId={activeChannel}
visible={showMembers}
visible={effectiveShowMembers}
onMemberClick={(member) => {}}
/>
</div>
@@ -266,6 +311,16 @@ const Chat = () => {
);
}
const handleSetActiveDMChannel = useCallback((dm) => {
setActiveDMChannel(dm);
if (isMobile && dm) setMobileView('chat');
}, [isMobile]);
const handleViewChange = useCallback((newView) => {
setView(newView);
if (isMobile) setMobileView('sidebar');
}, [isMobile]);
if (!userId) {
return (
<div className="app-container">
@@ -276,27 +331,33 @@ const Chat = () => {
);
}
const showSidebar = !isMobile || mobileView === 'sidebar';
const showMainContent = !isMobile || mobileView === 'chat';
return (
<PresenceProvider userId={userId}>
<div className="app-container">
<Sidebar
channels={channels}
categories={categories}
activeChannel={activeChannel}
onSelectChannel={handleSelectChannel}
username={username}
channelKeys={channelKeys}
view={view}
onViewChange={setView}
onOpenDM={openDM}
activeDMChannel={activeDMChannel}
setActiveDMChannel={setActiveDMChannel}
dmChannels={dmChannels}
userId={userId}
serverName={serverName}
serverIconUrl={serverIconUrl}
/>
{renderMainContent()}
<div className={`app-container${isMobile ? ' is-mobile' : ''}`}>
{showSidebar && (
<Sidebar
channels={channels}
categories={categories}
activeChannel={activeChannel}
onSelectChannel={handleSelectChannel}
username={username}
channelKeys={channelKeys}
view={view}
onViewChange={handleViewChange}
onOpenDM={openDM}
activeDMChannel={activeDMChannel}
setActiveDMChannel={handleSetActiveDMChannel}
dmChannels={dmChannels}
userId={userId}
serverName={serverName}
serverIconUrl={serverIconUrl}
isMobile={isMobile}
/>
)}
{showMainContent && renderMainContent()}
{showPiP && <FloatingStreamPiP onGoBackToStream={handleGoBackToStream} />}
<ToastContainer />
</div>

View File

@@ -50,7 +50,7 @@
--border-muted: rgba(255, 255, 255, 0.04);
--border-normal: rgba(255, 255, 255, 0.2);
--border-strong: rgba(255, 255, 255, 0.44);
--app-frame-border: color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%);
--app-frame-border: hsla(240, 4%, 60.784%, 0.122);
/* Icons */
--icon-default: #dbdee1;
@@ -95,7 +95,7 @@
--background-modifier-selected: rgba(78, 80, 88, 0.6);
--div-border: #1e1f22;
--text-feedback-warning: color-mix(in oklab, hsl(38.455 calc(1*100%) 43.137% /1) 100%, #000 0%);
--text-feedback-warning: hsl(38.455, 100%, 43.137%);
}
@@ -141,7 +141,7 @@
--border-muted: rgba(0, 0, 0, 0.2);
--border-normal: rgba(0, 0, 0, 0.36);
--border-strong: rgba(0, 0, 0, 0.48);
--app-frame-border: color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%);
--app-frame-border: hsla(240, 4%, 60.784%, 0.122);
/* Icons */
--icon-default: #313338;
@@ -186,7 +186,7 @@
--background-modifier-selected: rgba(116, 124, 138, 0.30);
--div-border: #e1e2e4;
--text-feedback-warning: color-mix(in oklab, hsl(38.455 calc(1*100%) 43.137% /1) 100%, #000 0%);
--text-feedback-warning: hsl(38.455, 100%, 43.137%);
}
@@ -204,7 +204,7 @@
--chat-background: #202225;
--channeltextarea-background: #252529;
--modal-background: #292b2f;
--panel-bg: color-mix(in oklab, hsl(240 calc(1*5.882%) 13.333% /1) 100%, #000 0%);
--panel-bg: hsl(240, 5.882%, 13.333%);
--embed-background: #242529;
/* Text */
@@ -232,7 +232,7 @@
--border-muted: rgba(255, 255, 255, 0.04);
--border-normal: rgba(255, 255, 255, 0.2);
--border-strong: rgba(255, 255, 255, 0.44);
--app-frame-border: color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%);
--app-frame-border: hsla(240, 4%, 60.784%, 0.122);
/* Icons */
--icon-default: #dddfe4;
@@ -277,7 +277,7 @@
--background-modifier-selected: rgba(78, 80, 88, 0.4);
--div-border: #111214;
--text-feedback-warning: color-mix(in oklab, hsl(38.455 calc(1*100%) 43.137% /1) 100%, #000 0%);
--text-feedback-warning: hsl(38.455, 100%, 43.137%);
}
@@ -323,7 +323,7 @@
--border-muted: rgba(255, 255, 255, 0.16);
--border-normal: rgba(255, 255, 255, 0.24);
--border-strong: rgba(255, 255, 255, 0.44);
--app-frame-border: color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%);
--app-frame-border: hsla(240, 4%, 60.784%, 0.122);
/* Icons */
--icon-default: #e0def0;
@@ -368,5 +368,5 @@
--background-modifier-selected: rgba(78, 73, 106, 0.48);
--div-border: #080810;
--text-feedback-warning: color-mix(in oklab, hsl(38.455 calc(1*100%) 43.137% /1) 100%, #000 0%);
--text-feedback-warning: hsl(38.455, 100%, 43.137%);
}