import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Virtuoso } from 'react-virtuoso';
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 SlashCommandMenu from './SlashCommandMenu';
import MessageItem, { getUserColor } from './MessageItem';
import ColoredIcon from './ColoredIcon';
import { usePlatform } from '../platform';
import { useVoice } from '../contexts/VoiceContext';
import { useSearch } from '../contexts/SearchContext';
import { generateUniqueMessage } from '../utils/floodMessages';
const SCROLL_DEBUG = true;
const scrollLog = (...args) => { if (SCROLL_DEBUG) console.log(...args); };
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();
const nick = (m.displayName || '').toLowerCase();
if (name.startsWith(q) || nick.startsWith(q)) prefix.push(m);
else if (name.includes(q) || nick.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 SLASH_COMMANDS = [
{ name: 'ping', description: 'Responds with Pong!', category: 'Built-In' },
{ name: 'flood', description: 'Generate test messages (e.g. /flood 100)', category: 'Testing' },
];
const filterSlashCommands = (commands, query) => {
if (!query) return commands;
const q = query.toLowerCase();
return commands.filter(c => c.name.toLowerCase().startsWith(q));
};
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, jumpToMessageId, onClearJumpToMessage }) => {
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 [slashQuery, setSlashQuery] = useState(null);
const [slashIndex, setSlashIndex] = useState(0);
const [ephemeralMessages, setEphemeralMessages] = useState([]);
const [unreadDividerTimestamp, setUnreadDividerTimestamp] = useState(null);
const [reactionPickerMsgId, setReactionPickerMsgId] = useState(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 initialScrollScheduledRef = useRef(false);
const decryptionDoneRef = useRef(false);
const channelLoadIdRef = useRef(0);
const jumpToMessageIdRef = useRef(null);
const pingSeededRef = useRef(false);
const statusRef = useRef(null);
const loadMoreRef = useRef(null);
const userSentMessageRef = useRef(false);
const scrollOnNextDataRef = useRef(false);
const notifiedMessageIdsRef = useRef(new Set());
const pendingNotificationIdsRef = useRef(new Set());
const lastPingTimeRef = useRef(0);
// Virtuoso refs and state
const virtuosoRef = useRef(null);
const scrollerElRef = useRef(null);
const chatInputFormRef = useRef(null);
const INITIAL_FIRST_INDEX = 100000;
const [firstItemIndex, setFirstItemIndex] = useState(INITIAL_FIRST_INDEX);
const prevMessageCountRef = useRef(0);
const prevFirstMsgIdRef = useRef(null);
const isAtBottomRef = useRef(true);
const isLoadingMoreRef = useRef(false);
const loadMoreSettlingRef = useRef(false);
const loadMoreSettlingTimerRef = useRef(null);
const realDistanceFromBottomRef = useRef(0);
const userIsScrolledUpRef = useRef(false);
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 customEmojis = useQuery(api.customEmojis.list) || [];
const { results: rawMessages, status, loadMore } = usePaginatedQuery(
api.messages.list,
channelId ? { channelId, userId: currentUserId || undefined } : "skip",
{ initialNumItems: 50 }
);
useEffect(() => {
statusRef.current = status;
loadMoreRef.current = loadMore;
if (status !== 'LoadingMore') {
isLoadingMoreRef.current = false;
if (loadMoreSettlingRef.current) {
if (loadMoreSettlingTimerRef.current) clearTimeout(loadMoreSettlingTimerRef.current);
loadMoreSettlingTimerRef.current = setTimeout(() => {
loadMoreSettlingRef.current = false;
loadMoreSettlingTimerRef.current = null;
}, 150);
}
}
}, [status, loadMore]);
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 sendBatchMutation = useMutation(api.messages.sendBatch);
const floodInProgressRef = useRef(false);
const floodAbortRef = useRef(false);
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,
};
});
};
const newMessages = buildFromCache();
// Adjust firstItemIndex atomically with data to prevent Virtuoso scroll jump
const prevCount = prevMessageCountRef.current;
const newCount = newMessages.length;
if (newCount > prevCount && prevCount > 0) {
if (prevFirstMsgIdRef.current && newMessages[0]?.id !== prevFirstMsgIdRef.current) {
const prependedCount = newCount - prevCount;
setFirstItemIndex(prev => prev - prependedCount);
}
}
prevMessageCountRef.current = newCount;
prevFirstMsgIdRef.current = newMessages[0]?.id || null;
scrollLog('[SCROLL:decrypt] Phase 1 — setDecryptedMessages from cache', { count: newMessages.length });
setDecryptedMessages(newMessages);
// 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) {
decryptionDoneRef.current = true;
scrollLog('[SCROLL:decrypt] Phase 2 — all cached, setDecryptedMessages');
setDecryptedMessages(buildFromCache());
if (isInitialLoadRef.current && !initialScrollScheduledRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
initialScrollScheduledRef.current = true;
const loadId = channelLoadIdRef.current;
scrollLog('[SCROLL:initialLoad] scheduling scroll chain');
const scrollEnd = () => { const el = scrollerElRef.current; if (el) { scrollLog('[SCROLL:initialLoad] scrollEnd exec'); el.scrollTop = el.scrollHeight; } };
requestAnimationFrame(() => requestAnimationFrame(() => {
if (channelLoadIdRef.current === loadId) scrollEnd();
}));
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 300);
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 800);
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 1200);
setTimeout(() => { if (channelLoadIdRef.current === loadId) isInitialLoadRef.current = false; }, 1500);
}
}
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
decryptionDoneRef.current = true;
scrollLog('[SCROLL:decrypt] Phase 3 — decrypted, setDecryptedMessages', { count: needsDecryption.length });
setDecryptedMessages(buildFromCache());
// After decryption, items may be taller — re-scroll to bottom.
// Double-rAF waits for paint + ResizeObserver cycle; escalating timeouts are safety nets.
if (isInitialLoadRef.current && !initialScrollScheduledRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
initialScrollScheduledRef.current = true;
const loadId = channelLoadIdRef.current;
scrollLog('[SCROLL:initialLoad] scheduling scroll chain (phase 3)');
const scrollEnd = () => { const el = scrollerElRef.current; if (el) { scrollLog('[SCROLL:initialLoad] scrollEnd exec (phase 3)'); el.scrollTop = el.scrollHeight; } };
requestAnimationFrame(() => requestAnimationFrame(() => {
if (channelLoadIdRef.current === loadId) scrollEnd();
}));
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 300);
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 800);
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 1200);
setTimeout(() => { if (channelLoadIdRef.current === loadId) isInitialLoadRef.current = false; }, 1500);
}
};
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
channelLoadIdRef.current += 1;
setDecryptedMessages([]);
isInitialLoadRef.current = true;
initialScrollScheduledRef.current = false;
decryptionDoneRef.current = false;
pingSeededRef.current = false;
notifiedMessageIdsRef.current = new Set();
pendingNotificationIdsRef.current = new Set();
setReplyingTo(null);
setEditingMessage(null);
setMentionQuery(null);
setUnreadDividerTimestamp(null);
setReactionPickerMsgId(null);
setSlashQuery(null);
setEphemeralMessages([]);
floodAbortRef.current = true;
isLoadingMoreRef.current = false;
loadMoreSettlingRef.current = false;
userIsScrolledUpRef.current = false;
realDistanceFromBottomRef.current = 0;
if (loadMoreSettlingTimerRef.current) {
clearTimeout(loadMoreSettlingTimerRef.current);
loadMoreSettlingTimerRef.current = null;
}
setFirstItemIndex(INITIAL_FIRST_INDEX);
prevMessageCountRef.current = 0;
prevFirstMsgIdRef.current = null;
onTogglePinned();
}, [channelId]);
// Sync jumpToMessageId prop to ref
useEffect(() => {
jumpToMessageIdRef.current = jumpToMessageId || null;
}, [jumpToMessageId]);
// Jump to a specific message (from search results)
useEffect(() => {
if (!jumpToMessageId || !decryptedMessages.length || !decryptionDoneRef.current) return;
const idx = decryptedMessages.findIndex(m => m.id === jumpToMessageId);
if (idx !== -1 && virtuosoRef.current) {
scrollLog('[SCROLL:jumpToMessage]', { jumpToMessageId, idx });
isInitialLoadRef.current = false;
virtuosoRef.current.scrollToIndex({ index: idx, align: 'center', behavior: 'smooth' });
setTimeout(() => {
const el = document.getElementById(`msg-${jumpToMessageId}`);
if (el) {
el.classList.add('message-highlight');
setTimeout(() => el.classList.remove('message-highlight'), 2000);
}
}, 300);
onClearJumpToMessage?.();
}
}, [jumpToMessageId, decryptedMessages, onClearJumpToMessage]);
// Safety timeout: clear jumpToMessageId if message never found (too old / not loaded)
useEffect(() => {
if (!jumpToMessageId) return;
const timer = setTimeout(() => onClearJumpToMessage?.(), 5000);
return () => clearTimeout(timer);
}, [jumpToMessageId, onClearJumpToMessage]);
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]);
// Ref to avoid decryptedMessages in markChannelAsRead deps (prevents handleAtBottomStateChange churn)
const decryptedMessagesRef = useRef(decryptedMessages);
decryptedMessagesRef.current = decryptedMessages;
// Mark channel as read when scrolled to bottom
const markChannelAsRead = useCallback(() => {
const msgs = decryptedMessagesRef.current;
if (!currentUserId || !channelId || !msgs.length) return;
const lastMsg = msgs[msgs.length - 1];
if (!lastMsg?.created_at) return;
markReadMutation({ userId: currentUserId, channelId, lastReadTimestamp: new Date(lastMsg.created_at).getTime() }).catch(() => {});
setUnreadDividerTimestamp(null);
}, [currentUserId, channelId, 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) => {
// Guard: when used as an event handler (e.g. img onLoad), the event
// object is passed as `force`. Coerce to boolean to ignore it.
if (typeof force !== 'boolean') force = false;
scrollLog('[SCROLL:scrollToBottom]', { force, initialLoad: isInitialLoadRef.current, userScrolledUp: userIsScrolledUpRef.current });
if (isInitialLoadRef.current) {
const el = scrollerElRef.current;
if (el) el.scrollTop = el.scrollHeight;
return;
}
if (force) {
// Direct DOM scroll is more reliable than scrollToIndex for user-sent messages
// Also reset userIsScrolledUpRef since we're explicitly scrolling to bottom
userIsScrolledUpRef.current = false;
const snap = () => {
const el = scrollerElRef.current;
if (el) el.scrollTop = el.scrollHeight;
};
snap();
// Escalating retries for late-sizing content (images, embeds)
setTimeout(snap, 50);
setTimeout(snap, 150);
} else if (virtuosoRef.current && !userIsScrolledUpRef.current) {
virtuosoRef.current.scrollToIndex({
index: 'LAST',
align: 'end',
behavior: 'smooth',
});
}
}, []);
// Virtuoso: startReached replaces IntersectionObserver
const handleStartReached = useCallback(() => {
if (isLoadingMoreRef.current) return;
if (statusRef.current === 'CanLoadMore') {
isLoadingMoreRef.current = true;
loadMoreSettlingRef.current = true;
if (loadMoreSettlingTimerRef.current) {
clearTimeout(loadMoreSettlingTimerRef.current);
loadMoreSettlingTimerRef.current = null;
}
loadMoreRef.current(50);
}
}, []);
// Virtuoso: followOutput auto-scrolls on new messages and handles initial load
const followOutput = useCallback((isAtBottom) => {
const metrics = {
isAtBottom,
userScrolledUp: userIsScrolledUpRef.current,
realDist: realDistanceFromBottomRef.current,
jumpTo: jumpToMessageIdRef.current,
userSent: userSentMessageRef.current,
initialLoad: isInitialLoadRef.current,
settling: loadMoreSettlingRef.current,
};
if (jumpToMessageIdRef.current) {
scrollLog('[SCROLL:followOutput] BLOCKED by jumpToMessage', metrics);
return false;
}
// If user sent a message, ALWAYS scroll to bottom aggressively
if (userSentMessageRef.current) {
userSentMessageRef.current = false;
scrollLog('[SCROLL:followOutput] USER SENT MSG → auto', metrics);
return 'auto';
}
// During initial load, disable followOutput so it doesn't conflict with manual scrollToIndex calls
if (isInitialLoadRef.current) {
scrollLog('[SCROLL:followOutput] BLOCKED by initialLoad', metrics);
return false;
}
// During load-more settling, don't auto-scroll (prevents snap-to-bottom when header changes)
if (loadMoreSettlingRef.current) {
scrollLog('[SCROLL:followOutput] BLOCKED by settling', metrics);
return false;
}
// CORE FIX: If user has scrolled >150px from bottom, never auto-scroll
// regardless of what Virtuoso's internal isAtBottom state thinks
if (userIsScrolledUpRef.current) {
scrollLog('[SCROLL:followOutput] BLOCKED by userIsScrolledUp', metrics);
return false;
}
const decision = isAtBottom ? 'smooth' : false;
scrollLog('[SCROLL:followOutput] decision:', decision, metrics);
return decision;
}, []);
// Virtuoso: atBottomStateChange replaces manual scroll listener for read state
const handleAtBottomStateChange = useCallback((atBottom) => {
scrollLog('[SCROLL:atBottomStateChange]', { atBottom, settling: loadMoreSettlingRef.current, userScrolledUp: userIsScrolledUpRef.current });
if (loadMoreSettlingRef.current && atBottom) {
return;
}
isAtBottomRef.current = atBottom;
if (atBottom) {
markChannelAsRead();
// Delay clearing isInitialLoadRef so self-correction has time for late-loading content
if (isInitialLoadRef.current && decryptionDoneRef.current) {
const loadId = channelLoadIdRef.current;
setTimeout(() => {
if (channelLoadIdRef.current === loadId) {
isInitialLoadRef.current = false;
}
}, 300);
}
}
}, [markChannelAsRead]);
// Mark as read when component unmounts (e.g., switching to voice channel)
useEffect(() => {
return () => {
markChannelAsReadRef.current();
};
}, []);
// Track input height for synchronous scroll adjustment
const prevInputHeightRef = useRef(0);
// Use useLayoutEffect to adjust scroll BEFORE paint when React updates (e.g. isMultiline change)
React.useLayoutEffect(() => {
const el = chatInputFormRef.current;
if (!el) return;
const currentHeight = el.clientHeight;
if (prevInputHeightRef.current > 0 && currentHeight !== prevInputHeightRef.current) {
const heightDiff = currentHeight - prevInputHeightRef.current;
const scroller = scrollerElRef.current;
if (scroller) {
const scrollTop = scroller.scrollTop;
const scrollHeight = scroller.scrollHeight;
const clientHeight = scroller.clientHeight;
const currentDistanceFromBottom = scrollHeight - scrollTop - clientHeight;
const previousDistanceFromBottom = currentDistanceFromBottom - heightDiff;
// If we were at bottom (approx) AND user hasn't scrolled up, force stay at bottom
const willScroll = previousDistanceFromBottom < 50 && !userIsScrolledUpRef.current;
scrollLog('[SCROLL:layoutEffect]', { heightDiff, previousDistanceFromBottom, userScrolledUp: userIsScrolledUpRef.current, willScroll });
if (willScroll) {
scroller.scrollTop = scrollHeight;
}
}
}
prevInputHeightRef.current = currentHeight;
});
useEffect(() => {
const el = chatInputFormRef.current;
if (!el) {
console.error('[ResizeObserver] chatInputFormRef is null!');
return;
}
console.log('[ResizeObserver] Attaching to form', el);
const observer = new ResizeObserver(() => {
const newHeight = el.clientHeight;
// We use a separate ref for ResizeObserver to avoid conflict/loop with layout effect if needed,
// but sharing prevInputHeightRef is mostly fine if we are careful.
// Actually, let's just use the ref we have.
if (newHeight !== prevInputHeightRef.current) {
const heightDiff = newHeight - prevInputHeightRef.current;
prevInputHeightRef.current = newHeight;
const scroller = scrollerElRef.current;
if (!scroller) return;
const scrollTop = scroller.scrollTop;
const scrollHeight = scroller.scrollHeight;
const clientHeight = scroller.clientHeight;
const currentDistanceFromBottom = scrollHeight - scrollTop - clientHeight;
const previousDistanceFromBottom = currentDistanceFromBottom - heightDiff;
const willScroll = previousDistanceFromBottom < 50 && !userIsScrolledUpRef.current;
scrollLog('[SCROLL:resizeObserver]', {
newHeight,
heightDiff,
previousDistanceFromBottom,
userScrolledUp: userIsScrolledUpRef.current,
willScroll,
});
if (willScroll) {
scroller.scrollTop = scrollHeight;
}
}
});
observer.observe(el);
return () => observer.disconnect();
}, []);
// Passive scroll listener: track real pixel distance from bottom
// This is the ground-truth for whether the user has scrolled up
useEffect(() => {
const scroller = scrollerElRef.current;
if (!scroller) return;
const onScroll = () => {
const dist = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
realDistanceFromBottomRef.current = dist;
const wasUp = userIsScrolledUpRef.current;
// User is "scrolled up" if >150px from bottom
userIsScrolledUpRef.current = dist > 150;
if (wasUp !== userIsScrolledUpRef.current) {
scrollLog('[SCROLL:userScrollState]', { dist, scrolledUp: userIsScrolledUpRef.current });
}
};
scroller.addEventListener('scroll', onScroll, { passive: true });
return () => scroller.removeEventListener('scroll', onScroll);
}, [scrollerElRef.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 = customEmojis.find(e => e.name === name) || 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 checkSlashTrigger = () => {
if (!inputDivRef.current) return;
const text = inputDivRef.current.textContent;
if (text.startsWith('/')) {
setSlashQuery(text.slice(1));
setSlashIndex(0);
} else {
setSlashQuery(null);
}
};
const handleSlashSelect = (cmd) => {
if (!inputDivRef.current) return;
inputDivRef.current.textContent = `/${cmd.name}`;
setInput(`/${cmd.name}`);
setSlashQuery(null);
// Place cursor at end
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(inputDivRef.current);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
inputDivRef.current.focus();
};
const filteredSlashCommands = slashQuery !== null ? filterSlashCommands(SLASH_COMMANDS, slashQuery) : [];
const executeSlashCommand = (command, cmdArgs) => {
if (command.name === 'ping') {
setEphemeralMessages(prev => [...prev, {
id: `ephemeral-${Date.now()}`,
type: 'ephemeral',
command: '/ping',
username,
content: 'Pong!',
created_at: Date.now(),
}]);
} else if (command.name === 'flood') {
const count = Math.min(Math.max(parseInt(cmdArgs) || 100, 1), 5000);
if (floodInProgressRef.current) {
setEphemeralMessages(prev => [...prev, {
id: `ephemeral-${Date.now()}`,
type: 'ephemeral',
command: '/flood',
username,
content: 'A flood is already in progress. Please wait for it to finish.',
created_at: Date.now(),
}]);
return;
}
if (!channelKey) {
setEphemeralMessages(prev => [...prev, {
id: `ephemeral-${Date.now()}`,
type: 'ephemeral',
command: '/flood',
username,
content: 'Cannot flood: Missing encryption key for this channel.',
created_at: Date.now(),
}]);
return;
}
const senderId = localStorage.getItem('userId');
const signingKey = sessionStorage.getItem('signingKey');
if (!senderId || !signingKey) return;
floodInProgressRef.current = true;
floodAbortRef.current = false;
const progressId = `ephemeral-flood-${Date.now()}`;
setEphemeralMessages(prev => [...prev, {
id: progressId,
type: 'ephemeral',
command: '/flood',
username,
content: `Generating messages... 0/${count} (0%)`,
created_at: Date.now(),
}]);
(async () => {
const BATCH_SIZE = 50;
let sent = 0;
try {
for (let i = 0; i < count; i += BATCH_SIZE) {
if (floodAbortRef.current) break;
const batchEnd = Math.min(i + BATCH_SIZE, count);
const batch = [];
for (let j = i; j < batchEnd; j++) {
const text = generateUniqueMessage(j);
const { content: encryptedContent, iv, tag } = await crypto.encryptData(text, channelKey);
const ciphertext = encryptedContent + tag;
const signature = await crypto.signMessage(signingKey, ciphertext);
batch.push({
channelId,
senderId,
ciphertext,
nonce: iv,
signature,
keyVersion: 1,
});
}
await sendBatchMutation({ messages: batch });
sent += batch.length;
const pct = Math.round((sent / count) * 100);
setEphemeralMessages(prev => prev.map(m =>
m.id === progressId
? { ...m, content: `Generating messages... ${sent}/${count} (${pct}%)` }
: m
));
}
setEphemeralMessages(prev => prev.map(m =>
m.id === progressId
? { ...m, content: floodAbortRef.current ? `Flood stopped. Sent ${sent}/${count} messages.` : `Done! Sent ${sent} test messages.` }
: m
));
} catch (err) {
console.error('Flood error:', err);
setEphemeralMessages(prev => prev.map(m =>
m.id === progressId
? { ...m, content: `Flood error after ${sent} messages: ${err.message}` }
: m
));
} finally {
floodInProgressRef.current = false;
}
})();
}
};
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 MAX_ATTACHMENT_SIZE = 500 * 1024 * 1024; // 500MB
const processFile = (file) => { if (file.size > MAX_ATTACHMENT_SIZE) { alert('File must be under 500MB'); return; } 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.mutation(api.files.validateUpload, { 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;
// Intercept slash commands
if (messageContent.startsWith('/') && pendingFiles.length === 0) {
const parts = messageContent.slice(1).split(/\s+/);
const cmdName = parts[0];
const cmdArgs = parts.slice(1).join(' ');
const command = SLASH_COMMANDS.find(c => c.name === cmdName);
if (command) {
executeSlashCommand(command, cmdArgs);
if (inputDivRef.current) inputDivRef.current.innerHTML = '';
setInput(''); setHasImages(false);
setSlashQuery(null);
clearTypingState();
userSentMessageRef.current = true;
scrollOnNextDataRef.current = true;
isInitialLoadRef.current = false;
setTimeout(() => scrollToBottom(true), 100);
return;
}
}
setUploading(true);
userSentMessageRef.current = true;
scrollOnNextDataRef.current = true;
isInitialLoadRef.current = false;
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();
setTimeout(() => scrollToBottom(true), 100);
} 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 (slashQuery !== null && filteredSlashCommands.length > 0) {
if (e.key === 'ArrowDown') { e.preventDefault(); setSlashIndex(i => (i + 1) % filteredSlashCommands.length); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); setSlashIndex(i => (i - 1 + filteredSlashCommands.length) % filteredSlashCommands.length); return; }
if (e.key === 'Tab') { e.preventDefault(); handleSlashSelect(filteredSlashCommands[slashIndex]); return; }
if (e.key === 'Escape') { e.preventDefault(); setSlashQuery(null); return; }
}
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':
setReactionPickerMsgId(msg.id);
break;
}
setContextMenu(null);
};
const scrollToMessage = useCallback((messageId) => {
const idx = decryptedMessages.findIndex(m => m.id === messageId);
if (idx !== -1 && virtuosoRef.current) {
virtuosoRef.current.scrollToIndex({ index: idx, align: 'center', behavior: 'smooth' });
setTimeout(() => {
const el = document.getElementById(`msg-${messageId}`);
if (el) {
el.classList.add('message-highlight');
setTimeout(() => el.classList.remove('message-highlight'), 2000);
}
}, 300);
}
}, [decryptedMessages]);
// 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}`;
// Merge messages + ephemeral for Virtuoso data
const allDisplayMessages = useMemo(() => {
return [...decryptedMessages, ...ephemeralMessages];
}, [decryptedMessages, ephemeralMessages]);
// When user sends a message, scroll to bottom once the new message arrives in data
// Virtuoso handles followOutput automatically via the prop
// We don't need manual scrolling here which might conflict
useEffect(() => {
if (scrollOnNextDataRef.current) {
scrollOnNextDataRef.current = false;
scrollLog('[SCROLL:scrollOnNextData] user sent message, forcing scroll to bottom');
// Reset scrolled-up state since user just sent a message
userIsScrolledUpRef.current = false;
// followOutput already returned 'auto' but it's unreliable — force DOM scroll
requestAnimationFrame(() => {
const el = scrollerElRef.current;
if (el) el.scrollTop = el.scrollHeight;
});
}
}, [allDisplayMessages]);
// Header component for Virtuoso — shows skeleton loader or channel beginning
const renderListHeader = useCallback(() => {
return (
<>
{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, decryptedMessages.length, rawMessages.length, isDM, channelName]);
// Stable Virtuoso components — avoids remounting Header/Footer every render
const virtuosoComponents = useMemo(() => ({
Header: () => renderListHeader(),
Footer: () => ,
}), [renderListHeader]);
// Render individual message item for Virtuoso
const renderMessageItem = useCallback((item, arrayIndex) => {
// Handle ephemeral messages (they come after decryptedMessages in allDisplayMessages)
if (item.type === 'ephemeral') {
const emsg = item;
return (
System
{emsg.username} used {emsg.command}
System
BOT
{new Date(emsg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
{emsg.content}
Only you can see this
·
setEphemeralMessages(prev => prev.filter(m => m.id !== emsg.id))}
>
Dismiss message
);
}
// Regular message
const msg = item;
const idx = arrayIndex;
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' }) : '';
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) => { if (emoji) { addReaction({ messageId: msg.id, userId: currentUserId, emoji }); } else { setReactionPickerMsgId(reactionPickerMsgId === msg.id ? null : msg.id); } }}
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}
/>
);
}, [decryptedMessages, username, myPermissions, isMentionedInContent, unreadDividerTimestamp, editingMessage, hoveredMessageId, editInput, roles, customEmojis, reactionPickerMsgId, currentUserId, addReaction, handleEditKeyDown, handleEditSave, handleReactionClick, scrollToMessage, handleProfilePopup, scrollToBottom]);
return (
{isDragging &&
}
{status === 'LoadingFirstPage' ? (
) : (
{ scrollerElRef.current = el; }}
firstItemIndex={firstItemIndex}
initialTopMostItemIndex={{ index: 'LAST', align: 'end' }}
alignToBottom={true}
atBottomThreshold={20}
data={allDisplayMessages}
startReached={handleStartReached}
followOutput={followOutput}
atBottomStateChange={handleAtBottomStateChange}
increaseViewportBy={{ top: 400, bottom: 400 }}
defaultItemHeight={60}
computeItemKey={(index, item) => item.id || `idx-${index}`}
components={virtuosoComponents}
itemContent={(index, item) => renderMessageItem(item, index - firstItemIndex)}
/>
)}
{contextMenu &&
setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />}
{reactionPickerMsgId && (
setReactionPickerMsgId(null)}>
e.stopPropagation()}>
{
if (typeof data !== 'string' && data.name) {
addReaction({ messageId: reactionPickerMsgId, userId: currentUserId, emoji: data.name });
}
setReactionPickerMsgId(null);
}}
onClose={() => setReactionPickerMsgId(null)}
/>
)}
{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 {}
}} />}