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

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