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
Some checks failed
Build and Release / build-and-release (push) Failing after 0s
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
31
packages/shared/src/components/ColoredIcon.jsx
Normal file
31
packages/shared/src/components/ColoredIcon.jsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
18
packages/shared/src/hooks/useIsMobile.js
Normal file
18
packages/shared/src/hooks/useIsMobile.js
Normal 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;
|
||||
}
|
||||
123
packages/shared/src/hooks/usePresence.js
Normal file
123
packages/shared/src/hooks/usePresence.js
Normal 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]);
|
||||
}
|
||||
40
packages/shared/src/hooks/useSingleFlight.js
Normal file
40
packages/shared/src/hooks/useSingleFlight.js
Normal 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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user