diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ff3181e..6b4d3d8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -34,7 +34,12 @@ "Bash(git add:*)", "Bash(git commit:*)", "Bash(git push:*)", - "Bash(npm run build:web:*)" + "Bash(npm run build:web:*)", + "WebFetch(domain:www.saurabhmisra.dev)", + "WebFetch(domain:getstream.io)", + "WebFetch(domain:blog.logrocket.com)", + "WebFetch(domain:virtuoso.dev)", + "WebFetch(domain:medium.com)" ] } } diff --git a/TODO.md b/TODO.md index beda5b2..b2768b4 100644 --- a/TODO.md +++ b/TODO.md @@ -5,5 +5,3 @@ - On mobile. lets redo the settings page to be more mobile friendly. I want it to look exactly the same on desktop but i need a little more mobile friendly for mobile. - Add photo / video albums like Commit https://commet.chat/ - -Lets allow custom emojis in the server. So if you go to the server settings on discord you can upload custom emojies. You put a image, a emoji name, and then you can use it in the chat like :emoji_name:. Discord resizes the image to a max of 32px thats either width or height depending on the image aspect ratio. The allow transparent background images and gif's. We dont allow duplicate names to existing emojis that we already have by default or custom emojies \ No newline at end of file diff --git a/apps/electron/package.json b/apps/electron/package.json index 370645c..412cb6b 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/electron", "private": true, - "version": "1.0.21", + "version": "1.0.22", "description": "Discord Clone - Electron app", "author": "Moyettes", "type": "module", diff --git a/convex/messages.ts b/convex/messages.ts index d13eb9e..f1d2332 100644 --- a/convex/messages.ts +++ b/convex/messages.ts @@ -113,6 +113,26 @@ export const send = mutation({ }, }); +export const sendBatch = mutation({ + args: { + messages: v.array(v.object({ + channelId: v.id("channels"), + senderId: v.id("userProfiles"), + ciphertext: v.string(), + nonce: v.string(), + signature: v.string(), + keyVersion: v.number(), + })), + }, + returns: v.object({ count: v.number() }), + handler: async (ctx, args) => { + for (const msg of args.messages) { + await ctx.db.insert("messages", { ...msg }); + } + return { count: args.messages.length }; + }, +}); + export const edit = mutation({ args: { id: v.id("messages"), diff --git a/package-lock.json b/package-lock.json index 1a6241c..c231e8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ }, "apps/electron": { "name": "@discord-clone/electron", - "version": "1.0.17", + "version": "1.0.21", "dependencies": { "@discord-clone/shared": "*", "electron-log": "^5.4.3", @@ -8176,6 +8176,16 @@ "react": ">= 0.14.0" } }, + "node_modules/react-virtuoso": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.1.tgz", + "integrity": "sha512-KF474cDwaSb9+SJ380xruBB4P+yGWcVkcu26HtMqYNMTYlYbrNy8vqMkE+GpAApPPufJqgOLMoWMFG/3pJMXUA==", + "license": "MIT", + "peerDependencies": { + "react": ">=16 || >=17 || >= 18 || >= 19", + "react-dom": ">=16 || >=17 || >= 18 || >=19" + } + }, "node_modules/read-binary-file-arch": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", @@ -9870,7 +9880,7 @@ }, "packages/shared": { "name": "@discord-clone/shared", - "version": "1.0.17", + "version": "1.0.21", "dependencies": { "@convex-dev/presence": "^0.3.0", "@dnd-kit/core": "^6.3.1", @@ -9886,6 +9896,7 @@ "react-markdown": "^10.1.0", "react-router-dom": "^7.11.0", "react-syntax-highlighter": "^16.1.0", + "react-virtuoso": "^4.18.1", "remark-gfm": "^4.0.1", "sql.js": "^1.12.0" } diff --git a/packages/shared/package.json b/packages/shared/package.json index 96c928a..ba48b79 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/shared", "private": true, - "version": "1.0.21", + "version": "1.0.22", "type": "module", "main": "src/App.jsx", "dependencies": { @@ -19,6 +19,7 @@ "react-markdown": "^10.1.0", "react-router-dom": "^7.11.0", "react-syntax-highlighter": "^16.1.0", + "react-virtuoso": "^4.18.1", "remark-gfm": "^4.0.1", "sql.js": "^1.12.0" } diff --git a/packages/shared/src/components/ChatArea.jsx b/packages/shared/src/components/ChatArea.jsx index d9db396..fada6b7 100644 --- a/packages/shared/src/components/ChatArea.jsx +++ b/packages/shared/src/components/ChatArea.jsx @@ -1,4 +1,5 @@ -import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react'; +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 { @@ -23,11 +24,13 @@ 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 metadataCache = new Map(); const attachmentCache = new Map(); @@ -133,6 +136,17 @@ const filterRolesForMention = (roles, query) => { 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() @@ -490,7 +504,7 @@ const InputContextMenu = ({ x, y, onClose, onPaste }) => { ); }; -const ChatArea = ({ channelId, channelName, channelType, username, channelKey, userId: currentUserId, showMembers, onToggleMembers, onOpenDM, showPinned, onTogglePinned }) => { +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(); @@ -512,26 +526,40 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u 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 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 decryptionDoneRef = useRef(false); + const channelLoadIdRef = useRef(0); + const jumpToMessageIdRef = useRef(null); const pingSeededRef = useRef(false); - const prevScrollHeightRef = useRef(0); - const isLoadingMoreRef = useRef(false); + const statusRef = useRef(null); + const loadMoreRef = useRef(null); const userSentMessageRef = useRef(false); - const topSentinelRef = useRef(null); + 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 convex = useConvex(); const members = useQuery(api.members.getChannelMembers, channelId ? { channelId } : "skip") || []; @@ -545,6 +573,11 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u { initialNumItems: 50 } ); + useEffect(() => { + statusRef.current = status; + loadMoreRef.current = loadMore; + }, [status, loadMore]); + const typingData = useQuery( api.typing.getTyping, channelId ? { channelId } : "skip" @@ -559,6 +592,9 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u 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, @@ -621,7 +657,21 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u if (needsDecryption.length === 0) { // Still re-render from cache in case optimistic matches were added - if (!cancelled) setDecryptedMessages(buildFromCache()); + if (!cancelled) { + decryptionDoneRef.current = true; + setDecryptedMessages(buildFromCache()); + + if (isInitialLoadRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) { + const loadId = channelLoadIdRef.current; + const scrollEnd = () => { const el = scrollerElRef.current; if (el) 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); + } + } return; } @@ -731,7 +781,21 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u if (cancelled) return; // Phase 3: Re-render with newly decrypted content + decryptionDoneRef.current = true; 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 && virtuosoRef.current && !jumpToMessageIdRef.current) { + const loadId = channelLoadIdRef.current; + const scrollEnd = () => { const el = scrollerElRef.current; if (el) 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); + } }; processUncached(); @@ -758,8 +822,10 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u useEffect(() => { // Don't clear messageDecryptionCache — it persists across channel switches + channelLoadIdRef.current += 1; setDecryptedMessages([]); isInitialLoadRef.current = true; + decryptionDoneRef.current = false; pingSeededRef.current = false; notifiedMessageIdsRef.current = new Set(); pendingNotificationIdsRef.current = new Set(); @@ -768,9 +834,45 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u setMentionQuery(null); setUnreadDividerTimestamp(null); setReactionPickerMsgId(null); + setSlashQuery(null); + setEphemeralMessages([]); + floodAbortRef.current = true; + 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) { + 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) => { @@ -888,91 +990,93 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u ...filteredMentionMembers.map(m => ({ type: 'member', ...m })), ]; const scrollToBottom = useCallback((force = false) => { - const container = messagesContainerRef.current; - if (!container) return; - if (force) { - container.scrollTop = container.scrollHeight; + if (isInitialLoadRef.current) { + const el = scrollerElRef.current; + if (el) el.scrollTop = el.scrollHeight; return; } - const { scrollTop, scrollHeight, clientHeight } = container; - if (scrollHeight - scrollTop - clientHeight < 300) { - container.scrollTop = container.scrollHeight; + if (force) { + // Direct DOM scroll is more reliable than scrollToIndex for user-sent messages + 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) { + virtuosoRef.current.scrollToIndex({ + index: 'LAST', + align: 'end', + behavior: 'smooth', + }); } }, []); - 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; + // Virtuoso: startReached replaces IntersectionObserver + const handleStartReached = useCallback(() => { + if (statusRef.current === 'CanLoadMore') { + loadMoreRef.current(50); } + }, []); - if (userSentMessageRef.current || isInitialLoadRef.current) { - container.scrollTop = container.scrollHeight; + // Virtuoso: firstItemIndex management for prepend without jitter + useEffect(() => { + const prevCount = prevMessageCountRef.current; + const newCount = decryptedMessages.length; + if (newCount > prevCount && prevCount > 0) { + if (prevFirstMsgIdRef.current && decryptedMessages[0]?.id !== prevFirstMsgIdRef.current) { + const prependedCount = newCount - prevCount; + setFirstItemIndex(prev => prev - prependedCount); + } + } + prevMessageCountRef.current = newCount; + prevFirstMsgIdRef.current = decryptedMessages[0]?.id || null; + }, [decryptedMessages]); + + // Virtuoso: followOutput auto-scrolls on new messages and handles initial load + const followOutput = useCallback((isAtBottom) => { + console.log('[Virtuoso] followOutput:', { isAtBottom, jumpTo: jumpToMessageIdRef.current, userSent: userSentMessageRef.current }); + if (jumpToMessageIdRef.current) return false; + + // If user sent a message, ALWAYS scroll to bottom aggressively + if (userSentMessageRef.current) { userSentMessageRef.current = false; - isInitialLoadRef.current = false; - return; + return 'auto'; } - - // 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; + + // During initial load, disable followOutput so it doesn't conflict with manual scrollToIndex calls + if (isInitialLoadRef.current) { + return false; } - }, [decryptedMessages, rawMessages?.length]); + + // Use 'smooth' again to see if 'auto' was causing the jump + return isAtBottom ? 'smooth' : false; + }, []); - 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(); + // Virtuoso: atBottomStateChange replaces manual scroll listener for read state + const handleAtBottomStateChange = useCallback((atBottom) => { + console.log('[Virtuoso] atBottomStateChange:', atBottom); + 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; + } + }, 1500); } - }; - container.addEventListener('scroll', handleScroll); - return () => container.removeEventListener('scroll', handleScroll); + } else if (isInitialLoadRef.current && decryptionDoneRef.current) { + // Content resize pushed us off bottom during initial load — snap back + const el = scrollerElRef.current; + if (el) el.scrollTop = el.scrollHeight; + } }, [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 () => { @@ -980,6 +1084,80 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u }; }, []); + // 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), force stay at bottom + if (previousDistanceFromBottom < 50) { + console.log('[LayoutEffect] Sync scroll adjustment', { heightDiff, previousDistanceFromBottom }); + 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; + + console.log('[ResizeObserver] Input resize:', { + newHeight, + heightDiff, + previousDistanceFromBottom + }); + + if (previousDistanceFromBottom < 50) { + console.log('[ResizeObserver] Forcing scroll to bottom'); + scroller.scrollTop = scrollHeight; + } + } + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); + const saveSelection = () => { const sel = window.getSelection(); if (sel.rangeCount > 0) savedRangeRef.current = sel.getRangeAt(0).cloneRange(); @@ -1048,6 +1226,134 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u } }; + 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(); @@ -1167,8 +1473,30 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u } 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); @@ -1182,6 +1510,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u setReplyingTo(null); setMentionQuery(null); markChannelAsRead(); + setTimeout(() => scrollToBottom(true), 100); } catch (err) { console.error("Error sending message/files:", err); alert("Failed to send message/files"); @@ -1215,6 +1544,12 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u }; 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; } @@ -1287,14 +1622,19 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u 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); + 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) => { @@ -1304,6 +1644,181 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u 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; + // 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' && ( + <> + {[ + { name: 80, lines: [260, 180] }, + { name: 60, lines: [310] }, + { name: 100, lines: [240, 140] }, + { name: 70, lines: [290] }, + { name: 90, lines: [200, 260] }, + { name: 55, lines: [330] }, + ].map((s, i) => ( +
+ {isDM + ? `This is the beginning of your direct message history with ${channelName}.` + : `This is the start of the #${channelName} channel.` + } +
+- {isDM - ? `This is the beginning of your direct message history with ${channelName}.` - : `This is the start of the #${channelName} channel.` - } -
-