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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user