Files
DiscordClone/packages/shared/src/components/ChatArea.jsx
2026-02-16 19:06:17 -06:00

1578 lines
76 KiB
JavaScript

import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { useQuery, usePaginatedQuery, useMutation, useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import {
GifIcon,
StickerIcon,
EmojieIcon,
EmojiesColored,
EmojiesGreyscale,
EditIcon,
ReplyIcon,
DeleteIcon,
PinIcon,
TypingIcon,
AddIcon,
SpoilerIcon
} from '../assets/icons';
import PingSound from '../assets/sounds/ping.mp3';
import CategorizedEmojis, { AllEmojis, getEmojiUrl } from '../assets/emojis';
import GifPicker from './GifPicker';
import PinnedMessagesPanel from './PinnedMessagesPanel';
import Tooltip from './Tooltip';
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';
import { useSearch } from '../contexts/SearchContext';
const metadataCache = new Map();
const attachmentCache = new Map();
const CONVEX_PUBLIC_URL = 'http://72.26.56.3:3210';
const rewriteStorageUrl = (url) => {
try {
const u = new URL(url);
const pub = new URL(CONVEX_PUBLIC_URL);
u.hostname = pub.hostname;
u.port = pub.port;
u.protocol = pub.protocol;
return u.toString();
} catch { return url; }
};
// Persistent global decryption cache (survives channel switches)
// Keyed by message _id, stores { content, isVerified, decryptedReply }
const messageDecryptionCache = new Map();
const MESSAGE_CACHE_MAX = 2000;
function evictCacheIfNeeded() {
if (messageDecryptionCache.size <= MESSAGE_CACHE_MAX) return;
const keysToDelete = [...messageDecryptionCache.keys()].slice(0, messageDecryptionCache.size - MESSAGE_CACHE_MAX);
for (const key of keysToDelete) {
messageDecryptionCache.delete(key);
}
}
// Exported for logout clearing
export function clearMessageDecryptionCache() {
messageDecryptionCache.clear();
}
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)));
const toHexString = (bytes) =>
bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '');
const VIDEO_EXT_RE = /\.(mp4|webm|ogg|mov)(\?[^\s]*)?$/i;
const isVideoUrl = (url) => VIDEO_EXT_RE.test(url);
const DirectVideo = ({ src, marginTop = 8 }) => {
const ref = useRef(null);
const [showControls, setShowControls] = useState(false);
const handlePlay = () => {
setShowControls(true);
if (ref.current) ref.current.play();
};
return (
<div style={{ marginTop, position: 'relative', display: 'inline-block', maxWidth: '100%' }}>
<video
ref={ref}
src={src}
controls={showControls}
preload="metadata"
style={{ maxWidth: '100%', maxHeight: '300px', borderRadius: '8px', backgroundColor: 'black', display: 'block' }}
/>
{!showControls && (
<div className="play-icon" onClick={handlePlay} style={{ cursor: 'pointer' }}>
</div>
)}
</div>
);
};
const getYouTubeId = (link) => {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|shorts\/|watch\?v=|&v=)([^#&?]*).*/;
const match = link.match(regExp);
return (match && match[2].length === 11) ? match[2] : null;
};
const filterMembersForMention = (members, query) => {
if (!members) return [];
const q = query.toLowerCase();
if (!q) return members;
const prefix = [];
const substring = [];
for (const m of members) {
const name = m.username.toLowerCase();
if (name.startsWith(q)) prefix.push(m);
else if (name.includes(q)) substring.push(m);
}
return [...prefix, ...substring];
};
const filterRolesForMention = (roles, query) => {
if (!roles) return [];
const q = query.toLowerCase();
if (!q) return roles;
const prefix = [];
const substring = [];
for (const r of roles) {
const name = r.name.replace(/^@/, '').toLowerCase();
if (name.startsWith(q)) prefix.push(r);
else if (name.includes(q)) substring.push(r);
}
return [...prefix, ...substring];
};
const isNewDay = (current, previous) => {
if (!previous) return true;
return current.getDate() !== previous.getDate()
|| current.getMonth() !== previous.getMonth()
|| current.getFullYear() !== previous.getFullYear();
};
const getProviderClass = (url) => {
try {
const hostname = new URL(url).hostname.replace(/^www\./, '');
if (hostname === 'twitter.com' || hostname === 'x.com') return 'twitter-preview';
if (hostname === 'open.spotify.com') return 'spotify-preview';
if (hostname === 'reddit.com') return 'reddit-preview';
} catch {}
return '';
};
export const LinkPreview = ({ url }) => {
const { links } = usePlatform();
const [metadata, setMetadata] = useState(metadataCache.get(url) || null);
const [loading, setLoading] = useState(!metadataCache.has(url));
const [playing, setPlaying] = useState(false);
const [showControls, setShowControls] = useState(false);
const videoRef = useRef(null);
useEffect(() => {
if (metadataCache.has(url)) {
setMetadata(metadataCache.get(url));
setLoading(false);
return;
}
let isMounted = true;
const fetchMeta = async () => {
try {
const data = await links.fetchMetadata(url);
if (isMounted) {
if (data) metadataCache.set(url, data);
setMetadata(data);
setLoading(false);
}
} catch (err) {
console.error("Failed to fetch metadata", err);
if (isMounted) setLoading(false);
}
};
fetchMeta();
return () => { isMounted = false; };
}, [url]);
const videoId = getYouTubeId(url);
const isYouTube = !!videoId;
const isDirectVideoUrl = isVideoUrl(url);
if (isDirectVideoUrl) {
return <DirectVideo src={url} />;
}
if (loading || !metadata || (!metadata.title && !metadata.image && !metadata.video)) return null;
if (metadata.video && !isYouTube) {
const handlePlayClick = () => {
setShowControls(true);
if (videoRef.current) videoRef.current.play();
};
return (
<div className="preview-video-standalone" style={{ marginTop: 8, position: 'relative', display: 'inline-block', maxWidth: '100%' }}>
<video
ref={videoRef}
src={metadata.video}
controls={showControls}
style={{ maxWidth: '100%', maxHeight: '300px', borderRadius: '4px', backgroundColor: 'black', display: 'block' }}
/>
{!showControls && (
<div className="play-icon" onClick={handlePlayClick} style={{ cursor: 'pointer' }}>
</div>
)}
</div>
);
}
if (metadata.description === 'Image File' && metadata.image) {
return (
<div className="preview-image-standalone" style={{ marginTop: 8, display: 'inline-block', maxWidth: '100%', cursor: 'pointer' }}>
<img src={metadata.image} alt="Preview" draggable="false" style={{ maxWidth: '100%', maxHeight: '350px', borderRadius: '8px', display: 'block' }} />
</div>
);
}
const providerClass = getProviderClass(url);
const isLargeImage = providerClass === 'twitter-preview' || metadata.type === 'article' || metadata.type === 'summary_large_image';
return (
<div className={`link-preview ${isYouTube ? 'youtube-preview' : ''} ${providerClass} ${isLargeImage && !isYouTube ? 'large-image-layout' : ''}`} style={{ borderLeftColor: metadata.themeColor || '#202225' }}>
<div className="preview-content">
{metadata.siteName && <div className="preview-site-name">{metadata.siteName}</div>}
{metadata.author && <div className="preview-author">{metadata.author}</div>}
{metadata.title && (
<a href={url} onClick={(e) => { e.preventDefault(); links.openExternal(url); }} className="preview-title">
{metadata.title}
</a>
)}
{metadata.description && <div className="preview-description">{metadata.description}</div>}
{isYouTube && playing && (
<div className="youtube-video-wrapper">
<iframe
src={`https://www.youtube.com/embed/${videoId}?autoplay=1`}
title={metadata.title || "YouTube video player"}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
)}
{isLargeImage && !isYouTube && metadata.image && (
<div className="preview-image-container large-image">
<img src={metadata.image} alt="Preview" draggable="false" className="preview-image" />
</div>
)}
</div>
{!isLargeImage && !isYouTube && metadata.image && (
<div className="preview-image-container">
<img src={metadata.image} alt="Preview" draggable="false" className="preview-image" />
</div>
)}
{isYouTube && metadata.image && !playing && (
<div className="preview-image-container" onClick={() => setPlaying(true)} style={{ cursor: 'pointer' }}>
<img src={metadata.image} alt="Preview" draggable="false" className="preview-image" />
<div className="play-icon"></div>
</div>
)}
</div>
);
};
const Attachment = ({ metadata, onLoad, onImageClick }) => {
const { crypto } = usePlatform();
const fetchUrl = rewriteStorageUrl(metadata.url);
const [url, setUrl] = useState(attachmentCache.get(fetchUrl) || null);
const [loading, setLoading] = useState(!attachmentCache.has(fetchUrl));
const [error, setError] = useState(null);
const [showControls, setShowControls] = useState(false);
const videoRef = useRef(null);
useEffect(() => {
if (attachmentCache.has(fetchUrl)) {
setUrl(attachmentCache.get(fetchUrl));
setLoading(false);
return;
}
let isMounted = true;
const decryptFile = async () => {
try {
const res = await fetch(fetchUrl);
const blob = await res.blob();
const arrayBuffer = await blob.arrayBuffer();
const hexInput = toHexString(new Uint8Array(arrayBuffer));
if (hexInput.length < 32) throw new Error('Invalid file data');
const TAG_HEX_LEN = 32;
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
const tagHex = hexInput.slice(-TAG_HEX_LEN);
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
const objectUrl = URL.createObjectURL(decryptedBlob);
if (isMounted) {
attachmentCache.set(fetchUrl, objectUrl);
setUrl(objectUrl);
setLoading(false);
}
} catch (err) {
console.error('Attachment decrypt error:', err);
if (isMounted) { setError('Failed to decrypt'); setLoading(false); }
}
};
decryptFile();
return () => { isMounted = false; };
}, [metadata, onLoad]);
if (loading) return <div style={{ color: 'var(--header-secondary)', fontSize: '12px' }}>Downloading & Decrypting...</div>;
if (error) return <div style={{ color: '#ed4245', fontSize: '12px' }}>{error}</div>;
if (metadata.mimeType.startsWith('image/')) {
return <img src={url} alt={metadata.filename} draggable="false" style={{ maxHeight: '300px', borderRadius: '4px', cursor: 'zoom-in' }} onLoad={onLoad} onClick={() => onImageClick(url)} />;
}
if (metadata.mimeType.startsWith('video/')) {
const handlePlayClick = () => { setShowControls(true); if (videoRef.current) videoRef.current.play(); };
return (
<div style={{ marginTop: 8, position: 'relative', display: 'inline-block', maxWidth: '300px' }}>
<video ref={videoRef} src={url} controls={showControls} style={{ maxWidth: '300px', borderRadius: '4px', display: 'block', backgroundColor: 'black' }} onLoadedData={onLoad} />
{!showControls && <div className="play-icon" onClick={handlePlayClick} style={{ cursor: 'pointer' }}></div>}
</div>
);
}
return (
<div style={{ display: 'flex', alignItems: 'center', backgroundColor: 'var(--embed-background)', padding: '10px', borderRadius: '4px', maxWidth: '300px' }}>
<span style={{ marginRight: '10px', fontSize: '24px' }}>📄</span>
<div style={{ overflow: 'hidden' }}>
<div style={{ color: 'var(--text-link)', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{metadata.filename}</div>
<div style={{ color: 'var(--header-secondary)', fontSize: '12px' }}>{(metadata.size / 1024).toFixed(1)} KB</div>
<a href={url} download={metadata.filename} style={{ color: 'var(--header-secondary)', fontSize: '12px', textDecoration: 'underline' }}>Download</a>
</div>
</div>
);
};
const PendingFilePreview = ({ file, onRemove }) => {
const [preview, setPreview] = useState(null);
const [isVideo, setIsVideo] = useState(false);
useEffect(() => {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onloadend = () => setPreview(reader.result);
reader.readAsDataURL(file);
} else if (file.type.startsWith('video/')) {
const url = URL.createObjectURL(file);
setPreview(url);
setIsVideo(true);
return () => URL.revokeObjectURL(url);
}
}, [file]);
const ActionButton = ({ icon, onClick, bg = 'rgba(0,0,0,0.7)', hoverBg = 'rgba(0,0,0,0.9)' }) => (
<div onClick={(e) => { e.stopPropagation(); onClick(); }} style={{ width: '28px', height: '28px', borderRadius: '50%', backgroundColor: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'background-color 0.15s' }} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = hoverBg} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = bg}>
<ColoredIcon src={icon} color="#fff" size="16px" />
</div>
);
let previewContent;
if (preview && isVideo) {
previewContent = (
<>
<video src={preview} muted preload="metadata" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', fontSize: '24px', color: 'white', textShadow: '0 1px 4px rgba(0,0,0,0.7)', pointerEvents: 'none' }}></div>
</>
);
} else if (preview) {
previewContent = <img src={preview} alt="Preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />;
} else {
previewContent = (
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '24px' }}>📄</div>
<div style={{ fontSize: '10px', color: 'var(--header-secondary)', marginTop: '4px', maxWidth: '90px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</div>
</div>
);
}
return (
<div style={{ display: 'inline-flex', flexDirection: 'column', marginRight: '10px' }}>
<div style={{ position: 'relative', width: '200px', height: '200px', borderRadius: '8px', backgroundColor: 'var(--embed-background)', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
{previewContent}
<div style={{ position: 'absolute', top: '4px', right: '4px', display: 'flex', gap: '4px', padding: '4px' }}>
<ActionButton icon={SpoilerIcon} onClick={() => {}} />
<ActionButton icon={EditIcon} onClick={() => {}} />
<ActionButton icon={DeleteIcon} onClick={() => onRemove(file)} bg="#da373c" hoverBg="#a12d31" />
</div>
</div>
{preview && (
<div style={{ fontSize: '11px', color: 'var(--header-secondary)', marginTop: '4px', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</div>
)}
</div>
);
};
const DragOverlay = () => (
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(88, 101, 242, 0.9)', zIndex: 2000, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: 'white', pointerEvents: 'none' }}>
<div style={{ backgroundColor: 'white', borderRadius: '50%', padding: '20px', marginBottom: '20px' }}>
<svg width="48" height="48" viewBox="0 0 24 24" fill="#5865F2"><path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"/></svg>
</div>
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>Upload to #{'channel'}</div>
<div style={{ fontSize: '16px', marginTop: '8px' }}>Hold Shift to upload directly</div>
</div>
);
const EmojiButton = ({ onClick, active }) => {
const [hovered, setHovered] = useState(false);
const [bgPos, setBgPos] = useState('0px 0px');
const getRandomPos = () => {
const totalSprites = 77;
const index = Math.floor(Math.random() * totalSprites);
const col = index % 20;
const row = Math.floor(index / 20);
return `-${col * 24}px -${row * 24}px`;
};
return (
<Tooltip text="Select Emoji" position="top">
<div className="chat-input-icon-btn" onClick={(e) => { e.stopPropagation(); if(onClick) onClick(); }} onMouseEnter={() => { setHovered(true); setBgPos(getRandomPos()); }} onMouseLeave={() => { setHovered(false); setBgPos(getRandomPos()); }} style={{ width: '24px', height: '24px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', marginLeft: '4px' }}>
<div style={{ width: '24px', height: '24px', backgroundImage: `url(${(hovered || active) ? EmojiesColored : EmojiesGreyscale})`, backgroundPosition: bgPos, backgroundSize: '480px 96px', backgroundRepeat: 'no-repeat' }} />
</div>
</Tooltip>
);
};
const MessageContextMenu = ({ x, y, onInteract, onClose, isOwner, canDelete }) => {
const menuRef = useRef(null);
const [pos, setPos] = useState({ top: y, left: x });
useEffect(() => { const h = () => onClose(); window.addEventListener('click', h); window.addEventListener('close-context-menus', h); return () => { window.removeEventListener('click', h); window.removeEventListener('close-context-menus', 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]);
const MenuItem = ({ label, iconSrc, iconColor, onClick, danger }) => (
<div onClick={(e) => { e.stopPropagation(); onClick(); onClose(); }} className={`context-menu-item ${danger ? 'context-menu-item-danger' : ''}`}>
<span>{label}</span>
<div style={{ marginLeft: '12px' }}><ColoredIcon src={iconSrc} color={iconColor} size="18px" /></div>
</div>
);
return (
<div ref={menuRef} className="context-menu" style={{ top: pos.top, left: pos.left }} onClick={(e) => e.stopPropagation()}>
<MenuItem label="Add Reaction" iconSrc={EmojieIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('reaction')} />
{isOwner && <MenuItem label="Edit Message" iconSrc={EditIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('edit')} />}
<MenuItem label="Reply" iconSrc={ReplyIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('reply')} />
<div className="context-menu-separator" />
<MenuItem label="Pin Message" iconSrc={PinIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('pin')} />
<div className="context-menu-separator" />
{canDelete && <MenuItem label="Delete Message" iconSrc={DeleteIcon} iconColor={ICON_COLOR_DANGER} danger onClick={() => onInteract('delete')} />}
</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); window.addEventListener('close-context-menus', h); return () => { window.removeEventListener('click', h); window.removeEventListener('close-context-menus', 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 searchCtx = useSearch();
const [decryptedMessages, setDecryptedMessages] = useState([]);
const [input, setInput] = useState('');
const [zoomedImage, setZoomedImage] = useState(null);
const [pickerTab, setPickerTab] = useState(null);
const [isDragging, setIsDragging] = useState(false);
const [pendingFiles, setPendingFiles] = useState([]);
const [hasImages, setHasImages] = useState(false);
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);
const [editInput, setEditInput] = useState('');
const [profilePopup, setProfilePopup] = useState(null);
const [mentionQuery, setMentionQuery] = useState(null);
const [mentionIndex, setMentionIndex] = useState(0);
const [unreadDividerTimestamp, setUnreadDividerTimestamp] = useState(null);
const messagesEndRef = useRef(null);
const messagesContainerRef = useRef(null);
const inputDivRef = useRef(null);
const savedRangeRef = useRef(null);
const fileInputRef = useRef(null);
const typingTimeoutRef = useRef(null);
const lastTypingEmitRef = useRef(0);
const isInitialLoadRef = useRef(true);
const pingSeededRef = useRef(false);
const prevScrollHeightRef = useRef(0);
const isLoadingMoreRef = useRef(false);
const userSentMessageRef = useRef(false);
const topSentinelRef = useRef(null);
const notifiedMessageIdsRef = useRef(new Set());
const pendingNotificationIdsRef = useRef(new Set());
const lastPingTimeRef = useRef(0);
const convex = useConvex();
const members = useQuery(api.members.getChannelMembers, channelId ? { channelId } : "skip") || [];
const roles = useQuery(api.roles.list, channelType !== 'dm' ? {} : "skip") || [];
const myPermissions = useQuery(api.roles.getMyPermissions, currentUserId ? { userId: currentUserId } : "skip");
const { results: rawMessages, status, loadMore } = usePaginatedQuery(
api.messages.list,
channelId ? { channelId, userId: currentUserId || undefined } : "skip",
{ initialNumItems: 50 }
);
const typingData = useQuery(
api.typing.getTyping,
channelId ? { channelId } : "skip"
) || [];
const sendMessageMutation = useMutation(api.messages.send);
const editMessageMutation = useMutation(api.messages.edit);
const pinMessageMutation = useMutation(api.messages.pin);
const deleteMessageMutation = useMutation(api.messages.remove);
const addReaction = useMutation(api.reactions.add);
const removeReaction = useMutation(api.reactions.remove);
const startTypingMutation = useMutation(api.typing.startTyping);
const stopTypingMutation = useMutation(api.typing.stopTyping);
const markReadMutation = useMutation(api.readState.markRead);
const readState = useQuery(
api.readState.getReadState,
channelId && currentUserId ? { userId: currentUserId, channelId } : "skip"
);
const showGifPicker = pickerTab !== null;
useEffect(() => {
const handleClickOutside = () => { if (showGifPicker) setPickerTab(null); };
window.addEventListener('click', handleClickOutside);
return () => window.removeEventListener('click', handleClickOutside);
}, [showGifPicker]);
const TAG_LENGTH = 32;
useEffect(() => {
if (!rawMessages || rawMessages.length === 0) {
setDecryptedMessages([]);
return;
}
let cancelled = false;
// Phase 1: Immediately render from cache (cached = content, uncached = "[Decrypting...]")
const buildFromCache = () => {
return [...rawMessages].reverse().map(msg => {
const cached = messageDecryptionCache.get(msg.id);
return {
...msg,
content: cached?.content ?? '[Decrypting...]',
isVerified: cached?.isVerified ?? null,
decryptedReply: cached?.decryptedReply ?? null,
};
});
};
setDecryptedMessages(buildFromCache());
// Phase 2: Batch-decrypt only uncached messages in background
const processUncached = async () => {
if (!channelKey) return;
// Optimistic: check if any uncached messages match ciphertext from our own sends
for (const msg of rawMessages) {
if (messageDecryptionCache.has(msg.id)) continue;
const plaintext = optimisticMapRef.current.get(msg.ciphertext);
if (plaintext) {
messageDecryptionCache.set(msg.id, { content: plaintext, isVerified: true, decryptedReply: null });
optimisticMapRef.current.delete(msg.ciphertext);
}
}
const needsDecryption = rawMessages.filter(msg => {
const cached = messageDecryptionCache.get(msg.id);
if (!cached) return true;
if (msg.replyToNonce && msg.replyToContent && cached.decryptedReply === null) return true;
return false;
});
if (needsDecryption.length === 0) {
// Still re-render from cache in case optimistic matches were added
if (!cancelled) setDecryptedMessages(buildFromCache());
return;
}
// Build batch arrays for decrypt and verify
const decryptItems = [];
const decryptMsgMap = []; // parallel array to track which msg each item belongs to
const replyDecryptItems = [];
const replyMsgMap = [];
const verifyItems = [];
const verifyMsgMap = [];
for (const msg of needsDecryption) {
if (msg.ciphertext && msg.ciphertext.length >= TAG_LENGTH) {
const tag = msg.ciphertext.slice(-TAG_LENGTH);
const content = msg.ciphertext.slice(0, -TAG_LENGTH);
decryptItems.push({ ciphertext: content, key: channelKey, iv: msg.nonce, tag });
decryptMsgMap.push(msg);
}
if (msg.replyToContent && msg.replyToNonce) {
const rTag = msg.replyToContent.slice(-TAG_LENGTH);
const rContent = msg.replyToContent.slice(0, -TAG_LENGTH);
replyDecryptItems.push({ ciphertext: rContent, key: channelKey, iv: msg.replyToNonce, tag: rTag });
replyMsgMap.push(msg);
}
if (msg.signature && msg.public_signing_key) {
verifyItems.push({ publicKey: msg.public_signing_key, message: msg.ciphertext, signature: msg.signature });
verifyMsgMap.push(msg);
}
}
// Execute batch IPC calls in parallel (2-3 calls instead of 100+)
const [decryptResults, replyResults, verifyResults] = await Promise.all([
decryptItems.length > 0
? crypto.decryptBatch(decryptItems)
: [],
replyDecryptItems.length > 0
? crypto.decryptBatch(replyDecryptItems)
: [],
verifyItems.length > 0
? crypto.verifyBatch(verifyItems)
: [],
]);
if (cancelled) return;
// Build lookup maps from batch results
const decryptedMap = new Map();
for (let i = 0; i < decryptResults.length; i++) {
const msg = decryptMsgMap[i];
const result = decryptResults[i];
decryptedMap.set(msg.id, result.success ? result.data : '[Decryption Error]');
}
const replyMap = new Map();
for (let i = 0; i < replyResults.length; i++) {
const msg = replyMsgMap[i];
const result = replyResults[i];
if (result.success) {
let text = result.data;
if (text.startsWith('{')) text = '[Attachment]';
else if (text.length > 100) text = text.substring(0, 100) + '...';
replyMap.set(msg.id, text);
} else {
replyMap.set(msg.id, '[Encrypted]');
}
}
const verifyMap = new Map();
for (let i = 0; i < verifyResults.length; i++) {
const msg = verifyMsgMap[i];
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.has(msg.id) ? verifyMap.get(msg.id) : null;
const decryptedReply = replyMap.get(msg.id) ?? null;
messageDecryptionCache.set(msg.id, { content, isVerified, decryptedReply });
}
evictCacheIfNeeded();
// Index successfully decrypted messages for search
if (searchCtx?.isReady) {
const toIndex = needsDecryption.map(msg => {
const cached = messageDecryptionCache.get(msg.id);
if (!cached || cached.content.startsWith('[')) return null;
return {
id: msg.id,
channel_id: channelId,
sender_id: msg.sender_id,
username: msg.username,
content: cached.content,
created_at: msg.created_at,
pinned: msg.pinned,
replyToId: msg.replyToId,
};
}).filter(Boolean);
if (toIndex.length > 0) searchCtx.indexMessages(toIndex);
}
if (cancelled) return;
// Phase 3: Re-render with newly decrypted content
setDecryptedMessages(buildFromCache());
};
processUncached();
return () => { cancelled = true; };
}, [rawMessages, channelKey]);
// Index cached messages when search DB becomes ready (covers messages decrypted before DB init)
useEffect(() => {
if (!searchCtx?.isReady || !channelId || decryptedMessages.length === 0) return;
const toIndex = decryptedMessages
.filter(m => m.content && !m.content.startsWith('['))
.map(m => ({
id: m.id,
channel_id: channelId,
sender_id: m.sender_id,
username: m.username,
content: m.content,
created_at: m.created_at,
pinned: m.pinned,
replyToId: m.replyToId,
}));
if (toIndex.length > 0) searchCtx.indexMessages(toIndex);
}, [searchCtx?.isReady]);
useEffect(() => {
// Don't clear messageDecryptionCache — it persists across channel switches
setDecryptedMessages([]);
isInitialLoadRef.current = true;
pingSeededRef.current = false;
notifiedMessageIdsRef.current = new Set();
pendingNotificationIdsRef.current = new Set();
setReplyingTo(null);
setEditingMessage(null);
setMentionQuery(null);
setUnreadDividerTimestamp(null);
onTogglePinned();
}, [channelId]);
const myRoleNames = members?.find(m => m.username === username)?.roles?.map(r => r.name) || [];
const isMentionedInContent = useCallback((content) => {
if (!content) return false;
return content.includes(`@${username}`) ||
myRoleNames.some(rn =>
rn.startsWith('@') ? content.includes(rn) : content.includes(`@role:${rn}`)
);
}, [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(() => {
if (!decryptedMessages.length) return;
// Initial load: seed all IDs, no sound
if (!pingSeededRef.current) {
for (const msg of decryptedMessages) {
if (msg.id) notifiedMessageIdsRef.current.add(msg.id);
}
pingSeededRef.current = true;
return;
}
let shouldPing = false;
// Check newest messages (end of array) backwards — stop at first known ID
for (let i = decryptedMessages.length - 1; i >= 0; i--) {
const msg = decryptedMessages[i];
if (!msg.id) continue;
if (notifiedMessageIdsRef.current.has(msg.id)) break;
// Skip own messages
if (msg.sender_id === currentUserId) {
notifiedMessageIdsRef.current.add(msg.id);
continue;
}
// Still decrypting — mark pending
if (msg.content === '[Decrypting...]') {
pendingNotificationIdsRef.current.add(msg.id);
continue;
}
notifiedMessageIdsRef.current.add(msg.id);
pendingNotificationIdsRef.current.delete(msg.id);
if (isMentionedInContent(msg.content)) shouldPing = true;
}
// Re-check previously pending messages now decrypted
if (!shouldPing && pendingNotificationIdsRef.current.size > 0) {
for (const msg of decryptedMessages) {
if (!pendingNotificationIdsRef.current.has(msg.id)) continue;
if (msg.content === '[Decrypting...]') continue;
pendingNotificationIdsRef.current.delete(msg.id);
notifiedMessageIdsRef.current.add(msg.id);
if (isMentionedInContent(msg.content)) shouldPing = true;
}
}
if (shouldPing) playPingSound();
}, [decryptedMessages, currentUserId, isMentionedInContent, playPingSound]);
// Capture the unread divider position when read state loads for a channel
const unreadDividerCapturedRef = useRef(null);
useEffect(() => {
if (!channelId) return;
// Reset when channel changes
unreadDividerCapturedRef.current = null;
setUnreadDividerTimestamp(null);
}, [channelId]);
useEffect(() => {
if (unreadDividerCapturedRef.current === channelId) return;
if (readState === undefined) return; // still loading
if (readState === null) {
// Never read this channel — no divider needed (first visit)
unreadDividerCapturedRef.current = channelId;
return;
}
unreadDividerCapturedRef.current = channelId;
setUnreadDividerTimestamp(readState.lastReadTimestamp);
}, [readState, channelId]);
// Mark channel as read when scrolled to bottom
const markChannelAsRead = useCallback(() => {
if (!currentUserId || !channelId || !decryptedMessages.length) return;
const lastMsg = decryptedMessages[decryptedMessages.length - 1];
if (!lastMsg?.created_at) return;
markReadMutation({ userId: currentUserId, channelId, lastReadTimestamp: new Date(lastMsg.created_at).getTime() }).catch(() => {});
setUnreadDividerTimestamp(null);
}, [currentUserId, channelId, decryptedMessages, markReadMutation]);
const markChannelAsReadRef = useRef(markChannelAsRead);
markChannelAsReadRef.current = markChannelAsRead;
const typingUsers = typingData.filter(t => t.username !== username);
const mentionableRoles = roles.filter(r => r.name !== 'Owner');
const filteredMentionRoles = mentionQuery !== null && channelType !== 'dm'
? filterRolesForMention(mentionableRoles, mentionQuery) : [];
const filteredMentionMembers = mentionQuery !== null
? filterMembersForMention(members, mentionQuery) : [];
const mentionItems = [
...filteredMentionRoles.map(r => ({ type: 'role', ...r })),
...filteredMentionMembers.map(m => ({ type: 'member', ...m })),
];
const scrollToBottom = useCallback((force = false) => {
const container = messagesContainerRef.current;
if (!container) return;
if (force) {
container.scrollTop = container.scrollHeight;
return;
}
const { scrollTop, scrollHeight, clientHeight } = container;
if (scrollHeight - scrollTop - clientHeight < 300) {
container.scrollTop = container.scrollHeight;
}
}, []);
useLayoutEffect(() => {
const container = messagesContainerRef.current;
if (!container || decryptedMessages.length === 0) return;
if (isLoadingMoreRef.current) {
const newScrollHeight = container.scrollHeight;
const heightDifference = newScrollHeight - prevScrollHeightRef.current;
container.scrollTop += heightDifference;
isLoadingMoreRef.current = false;
return;
}
if (userSentMessageRef.current || isInitialLoadRef.current) {
container.scrollTop = container.scrollHeight;
userSentMessageRef.current = false;
isInitialLoadRef.current = false;
return;
}
// Always auto-scroll if near bottom — handles decryption content changes,
// new messages, and any height shifts
const { scrollTop, scrollHeight, clientHeight } = container;
if (scrollHeight - scrollTop - clientHeight < 300) {
container.scrollTop = container.scrollHeight;
}
}, [decryptedMessages, rawMessages?.length]);
useEffect(() => {
const sentinel = topSentinelRef.current;
const container = messagesContainerRef.current;
if (!sentinel || !container) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && status === 'CanLoadMore') {
prevScrollHeightRef.current = container.scrollHeight;
isLoadingMoreRef.current = true;
loadMore(50);
}
},
{ root: container, rootMargin: '200px 0px 0px 0px', threshold: 0 }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [status, loadMore]);
// Mark as read when scrolled to bottom
useEffect(() => {
const container = messagesContainerRef.current;
if (!container) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
if (scrollHeight - scrollTop - clientHeight < 50) {
markChannelAsRead();
}
};
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [markChannelAsRead]);
// Mark as read on initial load (already scrolled to bottom)
useEffect(() => {
if (decryptedMessages.length > 0) {
const container = messagesContainerRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
if (scrollHeight - scrollTop - clientHeight < 50) {
markChannelAsRead();
}
}
}, [decryptedMessages.length, markChannelAsRead]);
// Mark as read when component unmounts (e.g., switching to voice channel)
useEffect(() => {
return () => {
markChannelAsReadRef.current();
};
}, []);
const saveSelection = () => {
const sel = window.getSelection();
if (sel.rangeCount > 0) savedRangeRef.current = sel.getRangeAt(0).cloneRange();
};
const insertEmoji = (emoji) => {
if (!inputDivRef.current) return;
const img = document.createElement('img');
img.src = emoji.src; img.alt = `:${emoji.name}:`; img.className = "inline-emoji";
img.style.width = "22px"; img.style.height = "22px"; img.style.verticalAlign = "bottom"; img.style.margin = "0 1px"; img.contentEditable = "false";
const space = document.createTextNode(' ');
inputDivRef.current.focus();
const sel = window.getSelection();
if (savedRangeRef.current && inputDivRef.current.contains(savedRangeRef.current.commonAncestorContainer)) { sel.removeAllRanges(); sel.addRange(savedRangeRef.current); }
if (sel.rangeCount > 0 && inputDivRef.current.contains(sel.getRangeAt(0).commonAncestorContainer)) {
const range = sel.getRangeAt(0); range.deleteContents(); range.insertNode(space); range.insertNode(img); range.setStartAfter(space); range.collapse(true); sel.removeAllRanges(); sel.addRange(range);
} else {
inputDivRef.current.appendChild(img); inputDivRef.current.appendChild(space);
const range = document.createRange(); range.setStartAfter(space); range.collapse(true); sel.removeAllRanges(); sel.addRange(range);
}
setInput(inputDivRef.current.textContent); setHasImages(true);
};
const checkTypedEmoji = () => {
if (!inputDivRef.current) return;
const selection = window.getSelection();
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
const node = range.startContainer;
if (node.nodeType !== Node.TEXT_NODE) return;
const content = node.textContent.substring(0, range.startOffset);
const match = content.match(/:([a-zA-Z0-9_]+):$/);
if (!match) return;
const name = match[1];
const emoji = AllEmojis.find(e => e.name === name);
if (!emoji) return;
const img = document.createElement('img');
img.src = emoji.src; img.alt = `:${name}:`; img.className = "inline-emoji";
img.style.width = "22px"; img.style.height = "22px"; img.style.verticalAlign = "bottom"; img.style.margin = "0 1px"; img.contentEditable = "false";
const textBefore = node.textContent.substring(0, range.startOffset - match[0].length);
const textAfter = node.textContent.substring(range.startOffset);
node.textContent = textBefore;
const afterNode = document.createTextNode(textAfter);
if (node.nextSibling) { node.parentNode.insertBefore(img, node.nextSibling); node.parentNode.insertBefore(afterNode, img.nextSibling); }
else { node.parentNode.appendChild(img); node.parentNode.appendChild(afterNode); }
const newRange = document.createRange(); newRange.setStart(afterNode, 0); newRange.collapse(true); selection.removeAllRanges(); selection.addRange(newRange);
};
const checkMentionTrigger = () => {
if (!inputDivRef.current) return;
const selection = window.getSelection();
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
const node = range.startContainer;
if (node.nodeType !== Node.TEXT_NODE) { setMentionQuery(null); return; }
const content = node.textContent.substring(0, range.startOffset);
const match = content.match(/(?:^|\s)@(\w*)$/);
if (match) {
setMentionQuery(match[1]);
setMentionIndex(0);
} else {
setMentionQuery(null);
}
};
const insertMention = (item) => {
if (!inputDivRef.current) return;
const selection = window.getSelection();
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
const node = range.startContainer;
if (node.nodeType !== Node.TEXT_NODE) return;
const content = node.textContent.substring(0, range.startOffset);
const match = content.match(/(?:^|\s)@(\w*)$/);
if (!match) return;
const matchStart = match.index + (match[0].startsWith(' ') ? 1 : 0);
const before = node.textContent.substring(0, matchStart);
const after = node.textContent.substring(range.startOffset);
const insertText = item.type === 'role'
? (item.name.startsWith('@') ? `${item.name} ` : `@role:${item.name} `)
: `@${item.username} `;
node.textContent = before + insertText + after;
const newOffset = before.length + insertText.length;
const newRange = document.createRange();
newRange.setStart(node, newOffset);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
setMentionQuery(null);
setInput(inputDivRef.current.textContent);
};
const processFile = (file) => { setPendingFiles(prev => [...prev, file]); };
const handleFileSelect = (e) => { if (e.target.files && e.target.files.length > 0) Array.from(e.target.files).forEach(processFile); };
const isExternalFileDrag = (e) => {
const types = Array.from(e.dataTransfer.types);
return types.includes('Files') && !types.includes('text/uri-list') && !types.includes('text/html');
};
const handleDragOver = (e) => { if (!isExternalFileDrag(e)) return; e.preventDefault(); e.stopPropagation(); if (!isDragging) setIsDragging(true); };
const handleDragLeave = (e) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget.contains(e.relatedTarget)) return; setIsDragging(false); };
const handleDrop = (e) => { if (!isDragging) return; e.preventDefault(); e.stopPropagation(); setIsDragging(false); if (e.dataTransfer.files && e.dataTransfer.files.length > 0) Array.from(e.dataTransfer.files).forEach(processFile); };
const uploadAndSendFile = async (file) => {
const fileKey = await crypto.randomBytes(32);
const arrayBuffer = await file.arrayBuffer();
const fileBytes = new Uint8Array(arrayBuffer);
const encrypted = await crypto.encryptData(fileBytes, fileKey);
const encryptedHex = encrypted.content + encrypted.tag;
const encryptedBytes = fromHexString(encryptedHex);
const blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
const uploadUrl = await convex.mutation(api.files.generateUploadUrl, {});
const uploadRes = await fetch(uploadUrl, { method: 'POST', headers: { 'Content-Type': blob.type }, body: blob });
const { storageId } = await uploadRes.json();
const fileUrl = await convex.query(api.files.getFileUrl, { storageId });
const metadata = {
type: 'attachment',
url: fileUrl,
filename: file.name,
mimeType: file.type,
size: file.size,
key: fileKey,
iv: encrypted.iv
};
await sendMessage(JSON.stringify(metadata));
};
// Store ciphertext→plaintext mapping for optimistic display
const optimisticMapRef = useRef(new Map());
const sendMessage = async (contentString, replyToId) => {
try {
if (!channelKey) { alert("Cannot send: Missing Encryption Key"); return; }
const { content: encryptedContent, iv, tag } = await crypto.encryptData(contentString, channelKey);
const ciphertext = encryptedContent + tag;
const senderId = localStorage.getItem('userId');
const signingKey = sessionStorage.getItem('signingKey');
if (!senderId || !signingKey) return;
// Optimistic: store ciphertext→plaintext so processMessages can skip decryption
optimisticMapRef.current.set(ciphertext, contentString);
const args = {
channelId,
senderId,
ciphertext,
nonce: iv,
signature: await crypto.signMessage(signingKey, ciphertext),
keyVersion: 1
};
if (replyToId) args.replyTo = replyToId;
await sendMessageMutation(args);
} catch (err) {
console.error('Send error:', err);
}
};
const clearTypingState = () => {
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
if (currentUserId && channelId) {
stopTypingMutation({ channelId, userId: currentUserId }).catch(() => {});
}
lastTypingEmitRef.current = 0;
};
const handleSend = async (e) => {
e.preventDefault();
let messageContent = '';
if (inputDivRef.current) {
inputDivRef.current.childNodes.forEach(node => {
if (node.nodeType === Node.TEXT_NODE) messageContent += node.textContent;
else if (node.nodeName === 'IMG' && node.alt) messageContent += node.alt;
else if (node.tagName === 'DIV' || node.tagName === 'BR') messageContent += '\n';
else messageContent += node.textContent;
});
messageContent = messageContent.trim();
}
if (!messageContent && pendingFiles.length === 0) return;
setUploading(true);
userSentMessageRef.current = true;
const replyId = replyingTo?.messageId;
try {
for (const file of pendingFiles) await uploadAndSendFile(file);
setPendingFiles([]);
if (messageContent) {
await sendMessage(messageContent, replyId);
if (inputDivRef.current) inputDivRef.current.innerHTML = '';
setInput(''); setHasImages(false);
clearTypingState();
}
setReplyingTo(null);
setMentionQuery(null);
markChannelAsRead();
} catch (err) {
console.error("Error sending message/files:", err);
alert("Failed to send message/files");
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleEditSave = async () => {
if (!editingMessage || !editInput.trim()) {
setEditingMessage(null);
return;
}
try {
const { content: encryptedContent, iv, tag } = await crypto.encryptData(editInput, channelKey);
const ciphertext = encryptedContent + tag;
const signingKey = sessionStorage.getItem('signingKey');
await editMessageMutation({
id: editingMessage.id,
ciphertext,
nonce: iv,
signature: await crypto.signMessage(signingKey, ciphertext),
});
messageDecryptionCache.delete(editingMessage.id);
setEditingMessage(null);
setEditInput('');
} catch (err) {
console.error('Edit error:', err);
}
};
const handleKeyDown = (e) => {
if (mentionQuery !== null && mentionItems.length > 0) {
if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIndex(i => (i + 1) % mentionItems.length); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIndex(i => (i - 1 + mentionItems.length) % mentionItems.length); return; }
if ((e.key === 'Enter' && !e.shiftKey) || e.key === 'Tab') { e.preventDefault(); insertMention(mentionItems[mentionIndex]); return; }
if (e.key === 'Escape') { e.preventDefault(); setMentionQuery(null); return; }
}
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(e); }
if (e.key === 'Escape' && replyingTo) { setReplyingTo(null); return; }
if (e.key === 'Backspace' && inputDivRef.current) {
const sel = window.getSelection();
if (sel.rangeCount > 0 && sel.isCollapsed) {
const range = sel.getRangeAt(0);
if (range.startOffset === 0 && range.startContainer !== inputDivRef.current) {
const prevNode = range.startContainer.previousSibling;
if (prevNode && prevNode.nodeName === 'IMG' && prevNode.classList.contains('inline-emoji')) { e.preventDefault(); prevNode.remove(); setHasImages(inputDivRef.current.querySelectorAll('img').length > 0); return; }
} else if (range.startOffset > 0) {
if (range.startContainer.nodeType === Node.ELEMENT_NODE) {
const nodeBefore = range.startContainer.childNodes[range.startOffset - 1];
if (nodeBefore && nodeBefore.nodeName === 'IMG' && nodeBefore.classList.contains('inline-emoji')) { e.preventDefault(); nodeBefore.remove(); setHasImages(inputDivRef.current.querySelectorAll('img').length > 0); return; }
}
}
}
}
};
const handleEditKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleEditSave(); }
if (e.key === 'Escape') { setEditingMessage(null); setEditInput(''); }
};
const handleReactionClick = async (messageId, emoji, hasMyReaction) => {
if (!currentUserId) return;
if (hasMyReaction) {
await removeReaction({ messageId, userId: currentUserId, emoji });
} else {
await addReaction({ messageId, userId: currentUserId, emoji });
}
};
const togglePicker = (tab) => {
if (pickerTab === tab) {
setPickerTab(null);
} else {
setPickerTab(tab);
}
};
const handleContextInteract = (action, messageId) => {
const msg = decryptedMessages.find(m => m.id === messageId);
if (!msg) return;
switch (action) {
case 'reply':
setReplyingTo({ messageId: msg.id, username: msg.username, content: msg.content?.substring(0, 100) });
break;
case 'edit':
setEditingMessage({ id: msg.id, content: msg.content });
setEditInput(msg.content);
break;
case 'pin':
pinMessageMutation({ id: msg.id, pinned: !msg.pinned });
break;
case 'delete':
deleteMessageMutation({ id: msg.id, userId: currentUserId });
break;
case 'reaction':
addReaction({ messageId: msg.id, userId: currentUserId, emoji: 'heart' });
break;
}
setContextMenu(null);
};
const scrollToMessage = (messageId) => {
const el = document.getElementById(`msg-${messageId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('message-highlight');
setTimeout(() => el.classList.remove('message-highlight'), 2000);
}
};
// Stable callbacks for MessageItem
const handleProfilePopup = useCallback((e, msg) => {
setProfilePopup({ userId: msg.sender_id, username: msg.username, avatarUrl: msg.avatarUrl, position: { x: e.clientX, y: e.clientY } });
}, []);
const isDM = channelType === 'dm';
const placeholderText = isDM ? `Message @${channelName || 'user'}` : `Message #${channelName || channelId}`;
return (
<div className="chat-area" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} style={{ position: 'relative' }}>
{isDragging && <DragOverlay />}
<PinnedMessagesPanel
channelId={channelId}
visible={showPinned}
onClose={onTogglePinned}
channelKey={channelKey}
onJumpToMessage={scrollToMessage}
userId={currentUserId}
username={username}
roles={roles}
Attachment={Attachment}
LinkPreview={LinkPreview}
DirectVideo={DirectVideo}
onReactionClick={handleReactionClick}
onProfilePopup={handleProfilePopup}
onImageClick={setZoomedImage}
/>
<div className="messages-list" ref={messagesContainerRef}>
<div className="messages-content-wrapper">
<div ref={topSentinelRef} style={{ height: '1px', width: '100%' }} />
{status === 'LoadingMore' && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0' }}>
<div className="loading-spinner" />
</div>
)}
{status === 'Exhausted' && (decryptedMessages.length > 0 || rawMessages.length === 0) && (
<div className="channel-beginning">
<div className="channel-beginning-icon">{isDM ? '@' : '#'}</div>
<h1 className="channel-beginning-title">
{isDM ? `${channelName}` : `Welcome to #${channelName}`}
</h1>
<p className="channel-beginning-subtitle">
{isDM
? `This is the beginning of your direct message history with ${channelName}.`
: `This is the start of the #${channelName} channel.`
}
</p>
</div>
)}
{status === 'LoadingFirstPage' && (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flex: 1, padding: '40px 0' }}>
<div className="loading-spinner" />
</div>
)}
{decryptedMessages.map((msg, idx) => {
const currentDate = new Date(msg.created_at);
const previousDate = idx > 0 ? new Date(decryptedMessages[idx - 1].created_at) : null;
const isMentioned = isMentionedInContent(msg.content);
const isOwner = msg.username === username;
const canDelete = isOwner || !!myPermissions?.manage_messages;
const prevMsg = idx > 0 ? decryptedMessages[idx - 1] : null;
const isGrouped = prevMsg
&& prevMsg.username === msg.username
&& !isNewDay(currentDate, previousDate)
&& (currentDate - new Date(prevMsg.created_at)) < 60000
&& !msg.replyToId;
const showDateDivider = isNewDay(currentDate, previousDate);
const dateLabel = showDateDivider ? currentDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) : '';
// Show unread divider before the first message after lastReadTimestamp
const showUnreadDivider = unreadDividerTimestamp != null
&& msg.created_at > unreadDividerTimestamp
&& (idx === 0 || decryptedMessages[idx - 1].created_at <= unreadDividerTimestamp);
return (
<MessageItem
key={msg.id || idx}
msg={msg}
isGrouped={isGrouped}
showDateDivider={showDateDivider}
showUnreadDivider={showUnreadDivider}
dateLabel={dateLabel}
isMentioned={isMentioned}
isOwner={isOwner}
roles={roles}
isEditing={editingMessage?.id === msg.id}
isHovered={hoveredMessageId === msg.id}
editInput={editInput}
username={username}
onHover={() => setHoveredMessageId(msg.id)}
onLeave={() => setHoveredMessageId(null)}
onContextMenu={(e) => { e.preventDefault(); window.dispatchEvent(new Event('close-context-menus')); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner, canDelete }); }}
onAddReaction={(emoji) => { addReaction({ messageId: msg.id, userId: currentUserId, emoji: emoji || 'heart' }); }}
onEdit={() => { setEditingMessage({ id: msg.id, content: msg.content }); setEditInput(msg.content); }}
onReply={() => setReplyingTo({ messageId: msg.id, username: msg.username, content: msg.content?.substring(0, 100) })}
onMore={(e) => { const rect = e.target.getBoundingClientRect(); setContextMenu({ x: rect.left, y: rect.bottom, messageId: msg.id, isOwner, canDelete }); }}
onEditInputChange={(e) => setEditInput(e.target.value)}
onEditKeyDown={handleEditKeyDown}
onEditSave={handleEditSave}
onEditCancel={() => { setEditingMessage(null); setEditInput(''); }}
onReactionClick={handleReactionClick}
onScrollToMessage={scrollToMessage}
onProfilePopup={handleProfilePopup}
onImageClick={setZoomedImage}
scrollToBottom={scrollToBottom}
Attachment={Attachment}
LinkPreview={LinkPreview}
DirectVideo={DirectVideo}
/>
);
})}
<div ref={messagesEndRef} />
</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 && (
<MentionMenu
items={mentionItems}
selectedIndex={mentionIndex}
onSelect={insertMention}
onHover={setMentionIndex}
/>
)}
{typingUsers.length > 0 && (
<div style={{ position: 'absolute', top: '-24px', left: '0', padding: '0 8px', display: 'flex', alignItems: 'center', gap: '6px', color: '#dbdee1', fontSize: '12px', fontWeight: 'bold', pointerEvents: 'none' }}>
<ColoredIcon src={TypingIcon} size="24px" color="#dbdee1" />
<span>{typingUsers.map(t => t.username).join(', ')} is typing...</span>
</div>
)}
{replyingTo && (
<div className="reply-preview-bar">
<div className="reply-preview-content">
Replying to <strong>{replyingTo.username}</strong>
<span className="reply-preview-text">{replyingTo.content}</span>
</div>
<button className="reply-preview-close" onClick={() => setReplyingTo(null)}>&times;</button>
</div>
)}
{pendingFiles.length > 0 && (
<div style={{ display: 'flex', padding: '10px 16px 0', overflowX: 'auto', backgroundColor: 'var(--channeltextarea-background)', borderRadius: '8px 8px 0 0' }}>
{pendingFiles.map((file, idx) => <PendingFilePreview key={idx} file={file} onRemove={(f) => setPendingFiles(prev => prev.filter(item => item !== f))} />)}
</div>
)}
<div className="chat-input-wrapper" style={pendingFiles.length > 0 ? { borderTopLeftRadius: 0, borderTopRightRadius: 0 } : {}}>
<input type="file" ref={fileInputRef} style={{ display: 'none' }} onChange={handleFileSelect} multiple />
<button type="button" className="chat-input-file-btn" onClick={() => fileInputRef.current.click()} disabled={uploading}>
{uploading ? <div className="spinner" style={{ width: 24, height: 24, borderRadius: '50%', border: '2px solid #b9bbbe', borderTopColor: 'transparent', animation: 'spin 1s linear infinite' }}></div> : <ColoredIcon src={AddIcon} color={ICON_COLOR_DEFAULT} size="24px" />}
</button>
<div ref={inputDivRef} contentEditable className="chat-input-richtext" role="textbox" aria-multiline="true"
onDrop={(e) => e.preventDefault()}
onBlur={saveSelection}
onMouseUp={saveSelection}
onKeyUp={saveSelection}
onContextMenu={(e) => {
e.preventDefault();
window.dispatchEvent(new Event('close-context-menus'));
setInputContextMenu({ x: e.clientX, y: e.clientY });
}}
onPaste={(e) => {
const items = e.clipboardData?.items;
if (items) {
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const file = item.getAsFile();
if (file) processFile(file);
return;
}
}
}
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
}}
onInput={(e) => {
const textContent = e.currentTarget.textContent;
setInput(textContent);
setHasImages(e.currentTarget.querySelectorAll('img').length > 0);
// Clean up browser artifacts (residual <br>) when content is fully erased
if (!textContent && !e.currentTarget.querySelectorAll('img').length) {
e.currentTarget.innerHTML = '';
setIsMultiline(false);
} else {
const text = e.currentTarget.innerText;
setIsMultiline(text.includes('\n') || e.currentTarget.scrollHeight > 50);
}
checkTypedEmoji();
checkMentionTrigger();
const now = Date.now();
if (now - lastTypingEmitRef.current > 2000 && currentUserId && channelId) {
startTypingMutation({ channelId, userId: currentUserId, username }).catch(() => {});
lastTypingEmitRef.current = now;
}
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = setTimeout(() => {
if (currentUserId && channelId) stopTypingMutation({ channelId, userId: currentUserId }).catch(() => {});
lastTypingEmitRef.current = 0;
}, 3000);
}}
onKeyDown={handleKeyDown}
style={{ flex: 1, backgroundColor: 'transparent', border: 'none', color: 'var(--text-normal)', fontSize: '16px', marginTop: '20px', marginLeft: '6px', minHeight: '44px', maxHeight: '200px', overflowY: 'auto', outline: 'none', whiteSpace: 'pre-wrap', wordBreak: 'break-word', lineHeight: '1.375rem', marginBottom: isMultiline ? '20px' : '0px' }}
/>
{!input && !hasImages && <div style={{ position: 'absolute', left: '70px', color: 'var(--text-muted)', pointerEvents: 'none', userSelect: 'none' }}>{placeholderText}</div>}
<div className="chat-input-icons" style={{ position: 'relative' }}>
<Tooltip text="GIF" position="top">
<button type="button" className="chat-input-icon-btn" onClick={(e) => { e.stopPropagation(); togglePicker('GIFs'); }}>
<ColoredIcon src={GifIcon} color={pickerTab === 'GIFs' ? '#5865f2' : ICON_COLOR_DEFAULT} size="24px" />
</button>
</Tooltip>
<Tooltip text="Stickers" position="top">
<button type="button" className="chat-input-icon-btn" onClick={(e) => { e.stopPropagation(); togglePicker('Stickers'); }}>
<ColoredIcon src={StickerIcon} color={pickerTab === 'Stickers' ? '#5865f2' : ICON_COLOR_DEFAULT} size="24px" />
</button>
</Tooltip>
{showGifPicker && (
<GifPicker onSelect={(data) => { if (typeof data === 'string') { sendMessage(data); setPickerTab(null); } else { insertEmoji(data); setPickerTab(null); } }} onClose={() => setPickerTab(null)} currentTab={pickerTab} onTabChange={setPickerTab} />
)}
<EmojiButton active={pickerTab === 'Emoji'} onClick={() => togglePicker('Emoji')} />
</div>
</div>
</form>
{zoomedImage && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.85)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'zoom-out' }} onClick={() => setZoomedImage(null)}>
<img src={zoomedImage} alt="Zoomed" style={{ maxWidth: '90%', maxHeight: '90%', boxShadow: '0 8px 16px rgba(0,0,0,0.5)', borderRadius: '4px', cursor: 'default' }} onClick={(e) => e.stopPropagation()} />
</div>
)}
{profilePopup && (
<UserProfilePopup
userId={profilePopup.userId}
username={profilePopup.username}
avatarUrl={profilePopup.avatarUrl}
status="online"
position={profilePopup.position}
onClose={() => setProfilePopup(null)}
onSendMessage={onOpenDM}
/>
)}
</div>
);
};
export default ChatArea;