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 (
);
};
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 ;
}
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 (
);
}
if (metadata.description === 'Image File' && metadata.image) {
return (
);
}
const providerClass = getProviderClass(url);
const isLargeImage = providerClass === 'twitter-preview' || metadata.type === 'article' || metadata.type === 'summary_large_image';
return (
{!isLargeImage && !isYouTube && metadata.image && (
)}
{isYouTube && metadata.image && !playing && (
setPlaying(true)} style={{ cursor: 'pointer' }}>
▶
)}
);
};
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 Downloading & Decrypting...
;
if (error) return {error}
;
if (metadata.mimeType.startsWith('image/')) {
return
onImageClick(url)} />;
}
if (metadata.mimeType.startsWith('video/')) {
const handlePlayClick = () => { setShowControls(true); if (videoRef.current) videoRef.current.play(); };
return (
);
}
return (
📄
{metadata.filename}
{(metadata.size / 1024).toFixed(1)} KB
Download
);
};
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)' }) => (
{ 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}>
);
let previewContent;
if (preview && isVideo) {
previewContent = (
<>
▶
>
);
} else if (preview) {
previewContent =
;
} else {
previewContent = (
);
}
return (
{previewContent}
{}} />
{}} />
onRemove(file)} bg="#da373c" hoverBg="#a12d31" />
{preview && (
{file.name}
)}
);
};
const DragOverlay = () => (
Upload to #{'channel'}
Hold Shift to upload directly
);
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 (
{ 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' }}>
);
};
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 }) => (
{ e.stopPropagation(); onClick(); onClose(); }} className={`context-menu-item ${danger ? 'context-menu-item-danger' : ''}`}>
{label}
);
return (
e.stopPropagation()}>
);
};
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 (
e.stopPropagation()}>
{ e.stopPropagation(); onPaste(); onClose(); }}>
Paste
);
};
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 (
{isDragging &&
}
{status === 'LoadingMore' && (
)}
{status === 'Exhausted' && (decryptedMessages.length > 0 || rawMessages.length === 0) && (
{isDM ? '@' : '#'}
{isDM ? `${channelName}` : `Welcome to #${channelName}`}
{isDM
? `This is the beginning of your direct message history with ${channelName}.`
: `This is the start of the #${channelName} channel.`
}
)}
{status === 'LoadingFirstPage' && (
)}
{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 (
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}
/>
);
})}
{contextMenu &&
setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />}
{inputContextMenu && 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 {}
}} />}