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