All checks were successful
Build and Release / build-and-release (push) Successful in 9m44s
2157 lines
106 KiB
JavaScript
2157 lines
106 KiB
JavaScript
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 (
|
|
<div style={{ marginTop, position: 'relative', display: 'inline-block', maxWidth: '100%' }}>
|
|
<video
|
|
ref={ref}
|
|
src={src}
|
|
controls={showControls}
|
|
preload="metadata"
|
|
style={{ maxWidth: '100%', maxHeight: '300px', borderRadius: '8px', backgroundColor: 'black', display: 'block' }}
|
|
/>
|
|
{!showControls && (
|
|
<div className="play-icon" onClick={handlePlay} style={{ cursor: 'pointer' }}>
|
|
▶
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const getYouTubeId = (link) => {
|
|
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|shorts\/|watch\?v=|&v=)([^#&?]*).*/;
|
|
const match = link.match(regExp);
|
|
return (match && match[2].length === 11) ? match[2] : null;
|
|
};
|
|
|
|
const filterMembersForMention = (members, query) => {
|
|
if (!members) return [];
|
|
const q = query.toLowerCase();
|
|
if (!q) return members;
|
|
const prefix = [];
|
|
const substring = [];
|
|
for (const m of members) {
|
|
const name = m.username.toLowerCase();
|
|
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 <DirectVideo src={url} />;
|
|
}
|
|
|
|
if (loading || !metadata || (!metadata.title && !metadata.image && !metadata.video)) return null;
|
|
|
|
if (metadata.video && !isYouTube) {
|
|
const handlePlayClick = () => {
|
|
setShowControls(true);
|
|
if (videoRef.current) videoRef.current.play();
|
|
};
|
|
|
|
return (
|
|
<div className="preview-video-standalone" style={{ marginTop: 8, position: 'relative', display: 'inline-block', maxWidth: '100%' }}>
|
|
<video
|
|
ref={videoRef}
|
|
src={metadata.video}
|
|
controls={showControls}
|
|
style={{ maxWidth: '100%', maxHeight: '300px', borderRadius: '4px', backgroundColor: 'black', display: 'block' }}
|
|
/>
|
|
{!showControls && (
|
|
<div className="play-icon" onClick={handlePlayClick} style={{ cursor: 'pointer' }}>
|
|
▶
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (metadata.description === 'Image File' && metadata.image) {
|
|
return (
|
|
<div className="preview-image-standalone" style={{ marginTop: 8, display: 'inline-block', maxWidth: '100%', cursor: 'pointer' }}>
|
|
<img src={metadata.image} alt="Preview" draggable="false" style={{ maxWidth: '100%', maxHeight: '350px', borderRadius: '8px', display: 'block' }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const providerClass = getProviderClass(url);
|
|
const isLargeImage = providerClass === 'twitter-preview' || metadata.type === 'article' || metadata.type === 'summary_large_image';
|
|
|
|
return (
|
|
<div className={`link-preview ${isYouTube ? 'youtube-preview' : ''} ${providerClass} ${isLargeImage && !isYouTube ? 'large-image-layout' : ''}`} style={{ borderLeftColor: metadata.themeColor || '#202225' }}>
|
|
<div className="preview-content">
|
|
{metadata.siteName && <div className="preview-site-name">{metadata.siteName}</div>}
|
|
{metadata.author && <div className="preview-author">{metadata.author}</div>}
|
|
{metadata.title && (
|
|
<a href={url} onClick={(e) => { e.preventDefault(); links.openExternal(url); }} className="preview-title">
|
|
{metadata.title}
|
|
</a>
|
|
)}
|
|
{metadata.description && <div className="preview-description">{metadata.description}</div>}
|
|
{isYouTube && playing && (
|
|
<div className="youtube-video-wrapper">
|
|
<iframe
|
|
src={`https://www.youtube.com/embed/${videoId}?autoplay=1`}
|
|
title={metadata.title || "YouTube video player"}
|
|
frameBorder="0"
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
allowFullScreen
|
|
/>
|
|
</div>
|
|
)}
|
|
{isLargeImage && !isYouTube && metadata.image && (
|
|
<div className="preview-image-container large-image">
|
|
<img src={metadata.image} alt="Preview" draggable="false" className="preview-image" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
{!isLargeImage && !isYouTube && metadata.image && (
|
|
<div className="preview-image-container">
|
|
<img src={metadata.image} alt="Preview" draggable="false" className="preview-image" />
|
|
</div>
|
|
)}
|
|
{isYouTube && metadata.image && !playing && (
|
|
<div className="preview-image-container" onClick={() => setPlaying(true)} style={{ cursor: 'pointer' }}>
|
|
<img src={metadata.image} alt="Preview" draggable="false" className="preview-image" />
|
|
<div className="play-icon">▶</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Attachment = ({ metadata, onLoad, onImageClick }) => {
|
|
const { crypto } = usePlatform();
|
|
const fetchUrl = rewriteStorageUrl(metadata.url);
|
|
const [url, setUrl] = useState(attachmentCache.get(fetchUrl) || null);
|
|
const [loading, setLoading] = useState(!attachmentCache.has(fetchUrl));
|
|
const [error, setError] = useState(null);
|
|
const [showControls, setShowControls] = useState(false);
|
|
const videoRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
if (attachmentCache.has(fetchUrl)) {
|
|
setUrl(attachmentCache.get(fetchUrl));
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
let isMounted = true;
|
|
const decryptFile = async () => {
|
|
try {
|
|
const res = await fetch(fetchUrl);
|
|
const blob = await res.blob();
|
|
const arrayBuffer = await blob.arrayBuffer();
|
|
const hexInput = toHexString(new Uint8Array(arrayBuffer));
|
|
|
|
if (hexInput.length < 32) throw new Error('Invalid file data');
|
|
|
|
const TAG_HEX_LEN = 32;
|
|
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
|
|
const tagHex = hexInput.slice(-TAG_HEX_LEN);
|
|
|
|
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
|
|
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
|
|
const objectUrl = URL.createObjectURL(decryptedBlob);
|
|
|
|
if (isMounted) {
|
|
attachmentCache.set(fetchUrl, objectUrl);
|
|
setUrl(objectUrl);
|
|
setLoading(false);
|
|
}
|
|
} catch (err) {
|
|
console.error('Attachment decrypt error:', err);
|
|
if (isMounted) { setError('Failed to decrypt'); setLoading(false); }
|
|
}
|
|
};
|
|
decryptFile();
|
|
return () => { isMounted = false; };
|
|
}, [metadata, onLoad]);
|
|
|
|
if (loading) return <div style={{ color: 'var(--header-secondary)', fontSize: '12px' }}>Downloading & Decrypting...</div>;
|
|
if (error) return <div style={{ color: '#ed4245', fontSize: '12px' }}>{error}</div>;
|
|
|
|
if (metadata.mimeType.startsWith('image/')) {
|
|
return <img src={url} alt={metadata.filename} draggable="false" style={{ maxHeight: '300px', borderRadius: '4px', cursor: 'zoom-in' }} onLoad={onLoad} onClick={() => onImageClick(url)} />;
|
|
}
|
|
if (metadata.mimeType.startsWith('video/')) {
|
|
const handlePlayClick = () => { setShowControls(true); if (videoRef.current) videoRef.current.play(); };
|
|
return (
|
|
<div style={{ marginTop: 8, position: 'relative', display: 'inline-block', maxWidth: '300px' }}>
|
|
<video ref={videoRef} src={url} controls={showControls} style={{ maxWidth: '300px', borderRadius: '4px', display: 'block', backgroundColor: 'black' }} onLoadedData={onLoad} />
|
|
{!showControls && <div className="play-icon" onClick={handlePlayClick} style={{ cursor: 'pointer' }}>▶</div>}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div style={{ display: 'flex', alignItems: 'center', backgroundColor: 'var(--embed-background)', padding: '10px', borderRadius: '4px', maxWidth: '300px' }}>
|
|
<span style={{ marginRight: '10px', fontSize: '24px' }}>📄</span>
|
|
<div style={{ overflow: 'hidden' }}>
|
|
<div style={{ color: 'var(--text-link)', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{metadata.filename}</div>
|
|
<div style={{ color: 'var(--header-secondary)', fontSize: '12px' }}>{(metadata.size / 1024).toFixed(1)} KB</div>
|
|
<a href={url} download={metadata.filename} style={{ color: 'var(--header-secondary)', fontSize: '12px', textDecoration: 'underline' }}>Download</a>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const PendingFilePreview = ({ file, onRemove }) => {
|
|
const [preview, setPreview] = useState(null);
|
|
const [isVideo, setIsVideo] = useState(false);
|
|
useEffect(() => {
|
|
if (file.type.startsWith('image/')) {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => setPreview(reader.result);
|
|
reader.readAsDataURL(file);
|
|
} else if (file.type.startsWith('video/')) {
|
|
const url = URL.createObjectURL(file);
|
|
setPreview(url);
|
|
setIsVideo(true);
|
|
return () => URL.revokeObjectURL(url);
|
|
}
|
|
}, [file]);
|
|
const ActionButton = ({ icon, onClick, bg = 'rgba(0,0,0,0.7)', hoverBg = 'rgba(0,0,0,0.9)' }) => (
|
|
<div onClick={(e) => { e.stopPropagation(); onClick(); }} style={{ width: '28px', height: '28px', borderRadius: '50%', backgroundColor: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'background-color 0.15s' }} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = hoverBg} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = bg}>
|
|
<ColoredIcon src={icon} color="#fff" size="16px" />
|
|
</div>
|
|
);
|
|
|
|
let previewContent;
|
|
if (preview && isVideo) {
|
|
previewContent = (
|
|
<>
|
|
<video src={preview} muted preload="metadata" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', fontSize: '24px', color: 'white', textShadow: '0 1px 4px rgba(0,0,0,0.7)', pointerEvents: 'none' }}>▶</div>
|
|
</>
|
|
);
|
|
} else if (preview) {
|
|
previewContent = <img src={preview} alt="Preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />;
|
|
} else {
|
|
previewContent = (
|
|
<div style={{ textAlign: 'center' }}>
|
|
<div style={{ fontSize: '24px' }}>📄</div>
|
|
<div style={{ fontSize: '10px', color: 'var(--header-secondary)', marginTop: '4px', maxWidth: '90px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: 'inline-flex', flexDirection: 'column', marginRight: '10px' }}>
|
|
<div style={{ position: 'relative', width: '200px', height: '200px', borderRadius: '8px', backgroundColor: 'var(--embed-background)', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
|
|
{previewContent}
|
|
<div style={{ position: 'absolute', top: '4px', right: '4px', display: 'flex', gap: '4px', padding: '4px' }}>
|
|
<ActionButton icon={SpoilerIcon} onClick={() => {}} />
|
|
<ActionButton icon={EditIcon} onClick={() => {}} />
|
|
<ActionButton icon={DeleteIcon} onClick={() => onRemove(file)} bg="#da373c" hoverBg="#a12d31" />
|
|
</div>
|
|
</div>
|
|
{preview && (
|
|
<div style={{ fontSize: '11px', color: 'var(--header-secondary)', marginTop: '4px', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const DragOverlay = () => (
|
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(88, 101, 242, 0.9)', zIndex: 2000, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: 'white', pointerEvents: 'none' }}>
|
|
<div style={{ backgroundColor: 'white', borderRadius: '50%', padding: '20px', marginBottom: '20px' }}>
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="#5865F2"><path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"/></svg>
|
|
</div>
|
|
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>Upload to #{'channel'}</div>
|
|
<div style={{ fontSize: '16px', marginTop: '8px' }}>Hold Shift to upload directly</div>
|
|
</div>
|
|
);
|
|
|
|
const EmojiButton = ({ onClick, active }) => {
|
|
const [hovered, setHovered] = useState(false);
|
|
const [bgPos, setBgPos] = useState('0px 0px');
|
|
const getRandomPos = () => {
|
|
const totalSprites = 77;
|
|
const index = Math.floor(Math.random() * totalSprites);
|
|
const col = index % 20;
|
|
const row = Math.floor(index / 20);
|
|
return `-${col * 24}px -${row * 24}px`;
|
|
};
|
|
return (
|
|
<Tooltip text="Select Emoji" position="top">
|
|
<div className="chat-input-icon-btn" onClick={(e) => { e.stopPropagation(); if(onClick) onClick(); }} onMouseEnter={() => { setHovered(true); setBgPos(getRandomPos()); }} onMouseLeave={() => { setHovered(false); setBgPos(getRandomPos()); }} style={{ width: '24px', height: '24px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', marginLeft: '4px' }}>
|
|
<div style={{ width: '24px', height: '24px', backgroundImage: `url(${(hovered || active) ? EmojiesColored : EmojiesGreyscale})`, backgroundPosition: bgPos, backgroundSize: '480px 96px', backgroundRepeat: 'no-repeat' }} />
|
|
</div>
|
|
</Tooltip>
|
|
);
|
|
};
|
|
|
|
const MessageContextMenu = ({ x, y, onInteract, onClose, isOwner, canDelete }) => {
|
|
const menuRef = useRef(null);
|
|
const [pos, setPos] = useState({ top: y, left: x });
|
|
useEffect(() => { const h = () => onClose(); window.addEventListener('click', h); window.addEventListener('close-context-menus', h); return () => { window.removeEventListener('click', h); window.removeEventListener('close-context-menus', h); }; }, [onClose]);
|
|
React.useLayoutEffect(() => {
|
|
if (!menuRef.current) return;
|
|
const rect = menuRef.current.getBoundingClientRect();
|
|
let newTop = y, newLeft = x;
|
|
if (x + rect.width > window.innerWidth) newLeft = x - rect.width;
|
|
if (y + rect.height > window.innerHeight) newTop = y - rect.height;
|
|
if (newLeft < 0) newLeft = 10;
|
|
if (newTop < 0) newTop = 10;
|
|
setPos({ top: newTop, left: newLeft });
|
|
}, [x, y]);
|
|
|
|
const MenuItem = ({ label, iconSrc, iconColor, onClick, danger }) => (
|
|
<div onClick={(e) => { e.stopPropagation(); onClick(); onClose(); }} className={`context-menu-item ${danger ? 'context-menu-item-danger' : ''}`}>
|
|
<span>{label}</span>
|
|
<div style={{ marginLeft: '12px' }}><ColoredIcon src={iconSrc} color={iconColor} size="18px" /></div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div ref={menuRef} className="context-menu" style={{ top: pos.top, left: pos.left }} onClick={(e) => e.stopPropagation()}>
|
|
<MenuItem label="Add Reaction" iconSrc={EmojieIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('reaction')} />
|
|
{isOwner && <MenuItem label="Edit Message" iconSrc={EditIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('edit')} />}
|
|
<MenuItem label="Reply" iconSrc={ReplyIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('reply')} />
|
|
<div className="context-menu-separator" />
|
|
<MenuItem label="Pin Message" iconSrc={PinIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('pin')} />
|
|
<div className="context-menu-separator" />
|
|
{canDelete && <MenuItem label="Delete Message" iconSrc={DeleteIcon} iconColor={ICON_COLOR_DANGER} danger onClick={() => onInteract('delete')} />}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const InputContextMenu = ({ x, y, onClose, onPaste }) => {
|
|
const menuRef = useRef(null);
|
|
const [pos, setPos] = useState({ top: y, left: x });
|
|
useEffect(() => { const h = () => onClose(); window.addEventListener('click', h); window.addEventListener('close-context-menus', h); return () => { window.removeEventListener('click', h); window.removeEventListener('close-context-menus', h); }; }, [onClose]);
|
|
React.useLayoutEffect(() => {
|
|
if (!menuRef.current) return;
|
|
const rect = menuRef.current.getBoundingClientRect();
|
|
let newTop = y, newLeft = x;
|
|
if (x + rect.width > window.innerWidth) newLeft = x - rect.width;
|
|
if (y + rect.height > window.innerHeight) newTop = y - rect.height;
|
|
if (newLeft < 0) newLeft = 10;
|
|
if (newTop < 0) newTop = 10;
|
|
setPos({ top: newTop, left: newLeft });
|
|
}, [x, y]);
|
|
|
|
return (
|
|
<div ref={menuRef} className="context-menu" style={{ top: pos.top, left: pos.left }} onClick={(e) => e.stopPropagation()}>
|
|
<div className="context-menu-item" onClick={(e) => { e.stopPropagation(); onPaste(); onClose(); }}>
|
|
<span>Paste</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ChatArea = ({ channelId, channelName, channelType, username, channelKey, userId: currentUserId, showMembers, onToggleMembers, onOpenDM, showPinned, onTogglePinned, 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' && (
|
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
|
|
<div className="loading-spinner" style={{ width: '20px', height: '20px', borderWidth: '2px' }} />
|
|
</div>
|
|
)}
|
|
{status === 'Exhausted' && (decryptedMessages.length > 0 || rawMessages.length === 0) && (
|
|
<div className="channel-beginning">
|
|
<div className="channel-beginning-icon">{isDM ? '@' : '#'}</div>
|
|
<h1 className="channel-beginning-title">
|
|
{isDM ? `${channelName}` : `Welcome to #${channelName}`}
|
|
</h1>
|
|
<p className="channel-beginning-subtitle">
|
|
{isDM
|
|
? `This is the beginning of your direct message history with ${channelName}.`
|
|
: `This is the start of the #${channelName} channel.`
|
|
}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}, [status, decryptedMessages.length, rawMessages.length, isDM, channelName]);
|
|
|
|
// Stable Virtuoso components — avoids remounting Header/Footer every render
|
|
const virtuosoComponents = useMemo(() => ({
|
|
Header: () => renderListHeader(),
|
|
Footer: () => <div style={{ height: '1px' }} />,
|
|
}), [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 (
|
|
<div className="message-item ephemeral-message">
|
|
<div className="message-reply-context ephemeral-reply-context">
|
|
<div className="reply-spine" />
|
|
<div className="ephemeral-reply-avatar">
|
|
<svg width="16" height="12" viewBox="0 0 28 20">
|
|
<path fill="currentColor" d="M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.0172 1.11817 13.0907 1.11817 11.1372 1.4184C10.9331 0.934541 10.7018 0.461742 10.4496 0C8.59007 0.318797 6.78726 0.879656 5.0768 1.67671C1.65217 6.84883 0.706797 11.8997 1.166 16.858C3.39578 18.5135 5.56066 19.5165 7.6473 20.1417C8.16912 19.4246 8.63212 18.6713 9.02347 17.8863C8.25707 17.5929 7.52187 17.2415 6.82914 16.8374C7.0147 16.6975 7.19515 16.5518 7.36941 16.402C11.66 18.396 16.4523 18.396 20.7186 16.402C20.8953 16.5518 21.0758 16.6975 21.2613 16.8374C20.5663 17.2435 19.8288 17.5947 19.0624 17.8863C19.4537 18.6713 19.9167 19.4246 20.4385 20.1417C22.5276 19.5165 24.6925 18.5135 26.9223 16.858C27.4684 11.2365 25.9961 6.22055 23.0212 1.67671ZM9.68041 13.6383C8.39754 13.6383 7.34085 12.4456 7.34085 10.994C7.34085 9.5424 8.37555 8.34973 9.68041 8.34973C10.9893 8.34973 12.0395 9.5424 12.0175 10.994C12.0175 12.4456 10.9893 13.6383 9.68041 13.6383ZM18.3161 13.6383C17.0332 13.6383 15.9765 12.4456 15.9765 10.994C15.9765 9.5424 17.0112 8.34973 18.3161 8.34973C19.625 8.34973 20.6752 9.5424 20.6532 10.994C20.6532 12.4456 19.625 13.6383 18.3161 13.6383Z"/>
|
|
</svg>
|
|
</div>
|
|
<span className="reply-author" style={{ color: '#5865f2' }}>System</span>
|
|
<span className="reply-text">{emsg.username} used {emsg.command}</span>
|
|
</div>
|
|
<div className="message-avatar-wrapper">
|
|
<div className="ephemeral-avatar">
|
|
<svg width="28" height="20" viewBox="0 0 28 20">
|
|
<path fill="currentColor" d="M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.0172 1.11817 13.0907 1.11817 11.1372 1.4184C10.9331 0.934541 10.7018 0.461742 10.4496 0C8.59007 0.318797 6.78726 0.879656 5.0768 1.67671C1.65217 6.84883 0.706797 11.8997 1.166 16.858C3.39578 18.5135 5.56066 19.5165 7.6473 20.1417C8.16912 19.4246 8.63212 18.6713 9.02347 17.8863C8.25707 17.5929 7.52187 17.2415 6.82914 16.8374C7.0147 16.6975 7.19515 16.5518 7.36941 16.402C11.66 18.396 16.4523 18.396 20.7186 16.402C20.8953 16.5518 21.0758 16.6975 21.2613 16.8374C20.5663 17.2435 19.8288 17.5947 19.0624 17.8863C19.4537 18.6713 19.9167 19.4246 20.4385 20.1417C22.5276 19.5165 24.6925 18.5135 26.9223 16.858C27.4684 11.2365 25.9961 6.22055 23.0212 1.67671ZM9.68041 13.6383C8.39754 13.6383 7.34085 12.4456 7.34085 10.994C7.34085 9.5424 8.37555 8.34973 9.68041 8.34973C10.9893 8.34973 12.0395 9.5424 12.0175 10.994C12.0175 12.4456 10.9893 13.6383 9.68041 13.6383ZM18.3161 13.6383C17.0332 13.6383 15.9765 12.4456 15.9765 10.994C15.9765 9.5424 17.0112 8.34973 18.3161 8.34973C19.625 8.34973 20.6752 9.5424 20.6532 10.994C20.6532 12.4456 19.625 13.6383 18.3161 13.6383Z"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div className="message-body">
|
|
<div className="message-header">
|
|
<span className="username" style={{ color: '#5865f2' }}>System</span>
|
|
<span className="ephemeral-bot-badge">BOT</span>
|
|
<span className="timestamp">{new Date(emsg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
|
</div>
|
|
<div className="message-content">
|
|
<span>{emsg.content}</span>
|
|
</div>
|
|
<div className="ephemeral-message-footer">
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style={{ opacity: 0.6 }}>
|
|
<path d="M8 3C4.5 3 1.6 5.1.3 8c1.3 2.9 4.2 5 7.7 5s6.4-2.1 7.7-5c-1.3-2.9-4.2-5-7.7-5zm0 8.3c-1.8 0-3.3-1.5-3.3-3.3S6.2 4.7 8 4.7s3.3 1.5 3.3 3.3S9.8 11.3 8 11.3zM8 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
|
|
</svg>
|
|
<span className="ephemeral-message-footer-text">Only you can see this</span>
|
|
<span className="ephemeral-message-footer-sep">·</span>
|
|
<span
|
|
className="ephemeral-message-dismiss"
|
|
onClick={() => setEphemeralMessages(prev => prev.filter(m => m.id !== emsg.id))}
|
|
>
|
|
Dismiss message
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<MessageItem
|
|
msg={msg}
|
|
isGrouped={isGrouped}
|
|
showDateDivider={showDateDivider}
|
|
showUnreadDivider={showUnreadDivider}
|
|
dateLabel={dateLabel}
|
|
isMentioned={isMentioned}
|
|
isOwner={isOwner}
|
|
roles={roles}
|
|
customEmojis={customEmojis}
|
|
isEditing={editingMessage?.id === msg.id}
|
|
isHovered={hoveredMessageId === msg.id}
|
|
editInput={editInput}
|
|
username={username}
|
|
onHover={() => setHoveredMessageId(msg.id)}
|
|
onLeave={() => setHoveredMessageId(null)}
|
|
onContextMenu={(e) => { e.preventDefault(); window.dispatchEvent(new Event('close-context-menus')); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner, canDelete }); }}
|
|
onAddReaction={(emoji) => { 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 (
|
|
<div className="chat-area" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} style={{ position: 'relative' }}>
|
|
{isDragging && <DragOverlay />}
|
|
|
|
<PinnedMessagesPanel
|
|
channelId={channelId}
|
|
visible={showPinned}
|
|
onClose={onTogglePinned}
|
|
channelKey={channelKey}
|
|
onJumpToMessage={scrollToMessage}
|
|
userId={currentUserId}
|
|
username={username}
|
|
roles={roles}
|
|
Attachment={Attachment}
|
|
LinkPreview={LinkPreview}
|
|
DirectVideo={DirectVideo}
|
|
onReactionClick={handleReactionClick}
|
|
onProfilePopup={handleProfilePopup}
|
|
onImageClick={setZoomedImage}
|
|
/>
|
|
|
|
<div className="messages-list">
|
|
{status === 'LoadingFirstPage' ? (
|
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flex: 1, padding: '40px 0' }}>
|
|
<div className="loading-spinner" />
|
|
</div>
|
|
) : (
|
|
<Virtuoso
|
|
ref={virtuosoRef}
|
|
scrollerRef={(el) => { 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)}
|
|
/>
|
|
)}
|
|
</div>
|
|
{contextMenu && <MessageContextMenu x={contextMenu.x} y={contextMenu.y} isOwner={contextMenu.isOwner} canDelete={contextMenu.canDelete} onClose={() => setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />}
|
|
{reactionPickerMsgId && (
|
|
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 999 }} onClick={() => setReactionPickerMsgId(null)}>
|
|
<div style={{ position: 'absolute', right: '80px', top: '50%', transform: 'translateY(-50%)' }} onClick={(e) => e.stopPropagation()}>
|
|
<GifPicker
|
|
initialTab="Emoji"
|
|
onSelect={(data) => {
|
|
if (typeof data !== 'string' && data.name) {
|
|
addReaction({ messageId: reactionPickerMsgId, userId: currentUserId, emoji: data.name });
|
|
}
|
|
setReactionPickerMsgId(null);
|
|
}}
|
|
onClose={() => setReactionPickerMsgId(null)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{inputContextMenu && <InputContextMenu x={inputContextMenu.x} y={inputContextMenu.y} onClose={() => setInputContextMenu(null)} onPaste={async () => {
|
|
try {
|
|
if (inputDivRef.current) inputDivRef.current.focus();
|
|
// Try reading clipboard items for images first
|
|
if (navigator.clipboard.read) {
|
|
try {
|
|
const items = await navigator.clipboard.read();
|
|
for (const item of items) {
|
|
const imageType = item.types.find(t => t.startsWith('image/'));
|
|
if (imageType) {
|
|
const blob = await item.getType(imageType);
|
|
const file = new File([blob], `pasted-image.${imageType.split('/')[1] || 'png'}`, { type: imageType });
|
|
processFile(file);
|
|
return;
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
// Fall back to plain text
|
|
const text = await navigator.clipboard.readText();
|
|
if (text) {
|
|
document.execCommand('insertText', false, text);
|
|
// Sync state — onInput may not fire from async execCommand
|
|
const el = inputDivRef.current;
|
|
if (el) {
|
|
setInput(el.textContent);
|
|
const inner = el.innerText;
|
|
setIsMultiline(inner.includes('\n') || el.scrollHeight > 50);
|
|
}
|
|
}
|
|
} catch {}
|
|
}} />}
|
|
|
|
<form ref={chatInputFormRef} className="chat-input-form" onSubmit={handleSend} style={{ position: 'relative' }}>
|
|
{slashQuery !== null && filteredSlashCommands.length > 0 && (
|
|
<SlashCommandMenu
|
|
commands={filteredSlashCommands}
|
|
selectedIndex={slashIndex}
|
|
onSelect={handleSlashSelect}
|
|
onHover={setSlashIndex}
|
|
/>
|
|
)}
|
|
{mentionQuery !== null && mentionItems.length > 0 && (
|
|
<MentionMenu
|
|
items={mentionItems}
|
|
selectedIndex={mentionIndex}
|
|
onSelect={insertMention}
|
|
onHover={setMentionIndex}
|
|
/>
|
|
)}
|
|
{typingUsers.length > 0 && (
|
|
<div style={{ position: 'absolute', top: '-24px', left: '0', padding: '0 8px', display: 'flex', alignItems: 'center', gap: '6px', color: '#dbdee1', fontSize: '12px', fontWeight: 'bold', pointerEvents: 'none' }}>
|
|
<ColoredIcon src={TypingIcon} size="24px" color="#dbdee1" />
|
|
<span>{typingUsers.map(t => t.displayName || t.username).join(', ')} is typing...</span>
|
|
</div>
|
|
)}
|
|
|
|
{replyingTo && (
|
|
<div className="reply-preview-bar">
|
|
<div className="reply-preview-content">
|
|
Replying to <strong>{replyingTo.username}</strong>
|
|
<span className="reply-preview-text">{replyingTo.content}</span>
|
|
</div>
|
|
<button className="reply-preview-close" onClick={() => setReplyingTo(null)}>×</button>
|
|
</div>
|
|
)}
|
|
|
|
{pendingFiles.length > 0 && (
|
|
<div style={{ display: 'flex', padding: '10px 16px 0', overflowX: 'auto', backgroundColor: 'var(--channeltextarea-background)', borderRadius: '8px 8px 0 0' }}>
|
|
{pendingFiles.map((file, idx) => <PendingFilePreview key={idx} file={file} onRemove={(f) => setPendingFiles(prev => prev.filter(item => item !== f))} />)}
|
|
</div>
|
|
)}
|
|
<div className="chat-input-wrapper" style={pendingFiles.length > 0 ? { borderTopLeftRadius: 0, borderTopRightRadius: 0 } : {}}>
|
|
<input type="file" ref={fileInputRef} style={{ display: 'none' }} onChange={handleFileSelect} multiple />
|
|
<button type="button" className="chat-input-file-btn" onClick={() => fileInputRef.current.click()} disabled={uploading}>
|
|
{uploading ? <div className="spinner" style={{ width: 24, height: 24, borderRadius: '50%', border: '2px solid #b9bbbe', borderTopColor: 'transparent', animation: 'spin 1s linear infinite' }}></div> : <ColoredIcon src={AddIcon} color={ICON_COLOR_DEFAULT} size="24px" />}
|
|
</button>
|
|
<div ref={inputDivRef} contentEditable className="chat-input-richtext" role="textbox" aria-multiline="true"
|
|
onDrop={(e) => e.preventDefault()}
|
|
onBlur={saveSelection}
|
|
onMouseUp={saveSelection}
|
|
onKeyUp={saveSelection}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
window.dispatchEvent(new Event('close-context-menus'));
|
|
setInputContextMenu({ x: e.clientX, y: e.clientY });
|
|
}}
|
|
onPaste={(e) => {
|
|
const items = e.clipboardData?.items;
|
|
if (items) {
|
|
for (const item of items) {
|
|
if (item.type.startsWith('image/')) {
|
|
e.preventDefault();
|
|
const file = item.getAsFile();
|
|
if (file) processFile(file);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
e.preventDefault();
|
|
const text = e.clipboardData.getData('text/plain');
|
|
document.execCommand('insertText', false, text);
|
|
}}
|
|
onInput={(e) => {
|
|
const textContent = e.currentTarget.textContent;
|
|
setInput(textContent);
|
|
setHasImages(e.currentTarget.querySelectorAll('img').length > 0);
|
|
|
|
// Clean up browser artifacts (residual <br>) when content is fully erased
|
|
if (!textContent && !e.currentTarget.querySelectorAll('img').length) {
|
|
e.currentTarget.innerHTML = '';
|
|
setIsMultiline(false);
|
|
} else {
|
|
const text = e.currentTarget.innerText;
|
|
setIsMultiline(text.includes('\n') || e.currentTarget.scrollHeight > 50);
|
|
}
|
|
checkTypedEmoji();
|
|
checkMentionTrigger();
|
|
checkSlashTrigger();
|
|
const now = Date.now();
|
|
if (now - lastTypingEmitRef.current > 2000 && currentUserId && channelId) {
|
|
startTypingMutation({ channelId, userId: currentUserId, username }).catch(() => {});
|
|
lastTypingEmitRef.current = now;
|
|
}
|
|
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
|
typingTimeoutRef.current = setTimeout(() => {
|
|
if (currentUserId && channelId) stopTypingMutation({ channelId, userId: currentUserId }).catch(() => {});
|
|
lastTypingEmitRef.current = 0;
|
|
}, 3000);
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
style={{ flex: 1, backgroundColor: 'transparent', border: 'none', color: 'var(--text-normal)', fontSize: '16px', marginTop: '20px', marginLeft: '6px', minHeight: '44px', maxHeight: '200px', overflowY: 'auto', outline: 'none', whiteSpace: 'pre-wrap', wordBreak: 'break-word', lineHeight: '1.375rem', marginBottom: isMultiline ? '20px' : '0px' }}
|
|
/>
|
|
{!input && !hasImages && <div style={{ position: 'absolute', left: '70px', color: 'var(--text-muted)', pointerEvents: 'none', userSelect: 'none' }}>{placeholderText}</div>}
|
|
<div className="chat-input-icons" style={{ position: 'relative' }}>
|
|
<Tooltip text="GIF" position="top">
|
|
<button type="button" className="chat-input-icon-btn" onClick={(e) => { e.stopPropagation(); togglePicker('GIFs'); }}>
|
|
<ColoredIcon src={GifIcon} color={pickerTab === 'GIFs' ? '#5865f2' : ICON_COLOR_DEFAULT} size="24px" />
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip text="Stickers" position="top">
|
|
<button type="button" className="chat-input-icon-btn" onClick={(e) => { e.stopPropagation(); togglePicker('Stickers'); }}>
|
|
<ColoredIcon src={StickerIcon} color={pickerTab === 'Stickers' ? '#5865f2' : ICON_COLOR_DEFAULT} size="24px" />
|
|
</button>
|
|
</Tooltip>
|
|
{showGifPicker && (
|
|
<GifPicker onSelect={(data) => { if (typeof data === 'string') { sendMessage(data); setPickerTab(null); } else { insertEmoji(data); setPickerTab(null); } }} onClose={() => setPickerTab(null)} currentTab={pickerTab} onTabChange={setPickerTab} />
|
|
)}
|
|
<EmojiButton active={pickerTab === 'Emoji'} onClick={() => togglePicker('Emoji')} />
|
|
</div>
|
|
</div>
|
|
</form>
|
|
{zoomedImage && (
|
|
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.85)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'zoom-out' }} onClick={() => setZoomedImage(null)}>
|
|
<img src={zoomedImage} alt="Zoomed" style={{ maxWidth: '90%', maxHeight: '90%', boxShadow: '0 8px 16px rgba(0,0,0,0.5)', borderRadius: '4px', cursor: 'default' }} onClick={(e) => e.stopPropagation()} />
|
|
</div>
|
|
)}
|
|
{profilePopup && (
|
|
<UserProfilePopup
|
|
userId={profilePopup.userId}
|
|
username={profilePopup.username}
|
|
avatarUrl={profilePopup.avatarUrl}
|
|
status="online"
|
|
position={profilePopup.position}
|
|
onClose={() => setProfilePopup(null)}
|
|
onSendMessage={onOpenDM}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ChatArea;
|