import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { Virtuoso } from 'react-virtuoso'; import { useQuery, usePaginatedQuery, useMutation, useConvex } from 'convex/react'; import { api } from '../../../../convex/_generated/api'; import { GifIcon, StickerIcon, EmojieIcon, EmojiesColored, EmojiesGreyscale, EditIcon, ReplyIcon, DeleteIcon, PinIcon, TypingIcon, AddIcon, SpoilerIcon } from '../assets/icons'; import PingSound from '../assets/sounds/ping.mp3'; import CategorizedEmojis, { AllEmojis, getEmojiUrl } from '../assets/emojis'; import GifPicker from './GifPicker'; import PinnedMessagesPanel from './PinnedMessagesPanel'; import Tooltip from './Tooltip'; import UserProfilePopup from './UserProfilePopup'; import Avatar from './Avatar'; import MentionMenu from './MentionMenu'; import SlashCommandMenu from './SlashCommandMenu'; import MessageItem, { getUserColor } from './MessageItem'; import ColoredIcon from './ColoredIcon'; import { usePlatform } from '../platform'; import { useVoice } from '../contexts/VoiceContext'; import { useSearch } from '../contexts/SearchContext'; import { generateUniqueMessage } from '../utils/floodMessages'; const SCROLL_DEBUG = true; const scrollLog = (...args) => { if (SCROLL_DEBUG) console.log(...args); }; const metadataCache = new Map(); const attachmentCache = new Map(); const CONVEX_PUBLIC_URL = 'http://72.26.56.3:3210'; const rewriteStorageUrl = (url) => { try { const u = new URL(url); const pub = new URL(CONVEX_PUBLIC_URL); u.hostname = pub.hostname; u.port = pub.port; u.protocol = pub.protocol; return u.toString(); } catch { return url; } }; // Persistent global decryption cache (survives channel switches) // Keyed by message _id, stores { content, isVerified, decryptedReply } const messageDecryptionCache = new Map(); const MESSAGE_CACHE_MAX = 2000; function evictCacheIfNeeded() { if (messageDecryptionCache.size <= MESSAGE_CACHE_MAX) return; const keysToDelete = [...messageDecryptionCache.keys()].slice(0, messageDecryptionCache.size - MESSAGE_CACHE_MAX); for (const key of keysToDelete) { messageDecryptionCache.delete(key); } } // Exported for logout clearing export function clearMessageDecryptionCache() { messageDecryptionCache.clear(); } const ICON_COLOR_DEFAULT = 'hsl(240, 4.294%, 68.039%)'; const ICON_COLOR_DANGER = 'hsl(1.353, 82.609%, 68.431%)'; const fromHexString = (hexString) => new Uint8Array(hexString.match(/.{1,2}/g).map((byte) => parseInt(byte, 16))); const toHexString = (bytes) => bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), ''); const VIDEO_EXT_RE = /\.(mp4|webm|ogg|mov)(\?[^\s]*)?$/i; const isVideoUrl = (url) => VIDEO_EXT_RE.test(url); const DirectVideo = ({ src, marginTop = 8 }) => { const ref = useRef(null); const [showControls, setShowControls] = useState(false); const handlePlay = () => { setShowControls(true); if (ref.current) ref.current.play(); }; return (
); }; const getYouTubeId = (link) => { const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|shorts\/|watch\?v=|&v=)([^#&?]*).*/; const match = link.match(regExp); return (match && match[2].length === 11) ? match[2] : null; }; const filterMembersForMention = (members, query) => { if (!members) return []; const q = query.toLowerCase(); if (!q) return members; const prefix = []; const substring = []; for (const m of members) { const name = m.username.toLowerCase(); const nick = (m.displayName || '').toLowerCase(); if (name.startsWith(q) || nick.startsWith(q)) prefix.push(m); else if (name.includes(q) || nick.includes(q)) substring.push(m); } return [...prefix, ...substring]; }; const filterRolesForMention = (roles, query) => { if (!roles) return []; const q = query.toLowerCase(); if (!q) return roles; const prefix = []; const substring = []; for (const r of roles) { const name = r.name.replace(/^@/, '').toLowerCase(); if (name.startsWith(q)) prefix.push(r); else if (name.includes(q)) substring.push(r); } return [...prefix, ...substring]; }; const SLASH_COMMANDS = [ { name: 'ping', description: 'Responds with Pong!', category: 'Built-In' }, { name: 'flood', description: 'Generate test messages (e.g. /flood 100)', category: 'Testing' }, ]; const filterSlashCommands = (commands, query) => { if (!query) return commands; const q = query.toLowerCase(); return commands.filter(c => c.name.toLowerCase().startsWith(q)); }; const isNewDay = (current, previous) => { if (!previous) return true; return current.getDate() !== previous.getDate() || current.getMonth() !== previous.getMonth() || current.getFullYear() !== previous.getFullYear(); }; const getProviderClass = (url) => { try { const hostname = new URL(url).hostname.replace(/^www\./, ''); if (hostname === 'twitter.com' || hostname === 'x.com') return 'twitter-preview'; if (hostname === 'open.spotify.com') return 'spotify-preview'; if (hostname === 'reddit.com') return 'reddit-preview'; } catch {} return ''; }; export const LinkPreview = ({ url }) => { const { links } = usePlatform(); const [metadata, setMetadata] = useState(metadataCache.get(url) || null); const [loading, setLoading] = useState(!metadataCache.has(url)); const [playing, setPlaying] = useState(false); const [showControls, setShowControls] = useState(false); const videoRef = useRef(null); useEffect(() => { if (metadataCache.has(url)) { setMetadata(metadataCache.get(url)); setLoading(false); return; } let isMounted = true; const fetchMeta = async () => { try { const data = await links.fetchMetadata(url); if (isMounted) { if (data) metadataCache.set(url, data); setMetadata(data); setLoading(false); } } catch (err) { console.error("Failed to fetch metadata", err); if (isMounted) setLoading(false); } }; fetchMeta(); return () => { isMounted = false; }; }, [url]); const videoId = getYouTubeId(url); const isYouTube = !!videoId; const isDirectVideoUrl = isVideoUrl(url); if (isDirectVideoUrl) { return ; } if (loading || !metadata || (!metadata.title && !metadata.image && !metadata.video)) return null; if (metadata.video && !isYouTube) { const handlePlayClick = () => { setShowControls(true); if (videoRef.current) videoRef.current.play(); }; return (
); } if (metadata.description === 'Image File' && metadata.image) { return (
Preview
); } const providerClass = getProviderClass(url); const isLargeImage = providerClass === 'twitter-preview' || metadata.type === 'article' || metadata.type === 'summary_large_image'; return (
{metadata.siteName &&
{metadata.siteName}
} {metadata.author &&
{metadata.author}
} {metadata.title && ( { e.preventDefault(); links.openExternal(url); }} className="preview-title"> {metadata.title} )} {metadata.description &&
{metadata.description}
} {isYouTube && playing && (