From bebf0bf989291afdee75bfecd386d59864f41c71 Mon Sep 17 00:00:00 2001 From: Bryan1029384756 <23323626+Bryan1029384756@users.noreply.github.com> Date: Tue, 17 Feb 2026 10:12:38 -0600 Subject: [PATCH] Better Chat --- .claude/settings.local.json | 7 +- TODO.md | 2 - apps/electron/package.json | 2 +- convex/messages.ts | 20 + package-lock.json | 15 +- packages/shared/package.json | 3 +- packages/shared/src/components/ChatArea.jsx | 814 ++++++++++++++---- .../src/components/SlashCommandMenu.jsx | 51 ++ packages/shared/src/index.css | 208 ++++- packages/shared/src/pages/Chat.jsx | 18 +- packages/shared/src/utils/floodMessages.js | 203 +++++ 11 files changed, 1142 insertions(+), 201 deletions(-) create mode 100644 packages/shared/src/components/SlashCommandMenu.jsx create mode 100644 packages/shared/src/utils/floodMessages.js 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) => ( +
+
+
+
+ {s.lines.map((w, j) => ( +
+ ))} +
+
+ ))} + + )} + {status === 'Exhausted' && (decryptedMessages.length > 0 || rawMessages.length === 0) && ( +
+
{isDM ? '@' : '#'}
+

+ {isDM ? `${channelName}` : `Welcome to #${channelName}`} +

+

+ {isDM + ? `This is the beginning of your direct message history with ${channelName}.` + : `This is the start of the #${channelName} channel.` + } +

+
+ )} + + ); + }, [status, decryptedMessages.length, rawMessages.length, isDM, channelName]); + + // Render individual message item for Virtuoso + const renderMessageItem = useCallback((item, arrayIndex) => { + // Handle ephemeral messages (they come after decryptedMessages in allDisplayMessages) + if (item.type === 'ephemeral') { + const emsg = item; + return ( +
+
+
+
+ + + +
+ System + {emsg.username} used {emsg.command} +
+
+
+ + + +
+
+
+
+ System + BOT + {new Date(emsg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +
+
+ {emsg.content} +
+
+ + + + Only you can see this + · + setEphemeralMessages(prev => prev.filter(m => m.id !== emsg.id))} + > + Dismiss message + +
+
+
+ ); + } + + // Regular message + const msg = item; + const idx = arrayIndex; + const currentDate = new Date(msg.created_at); + const previousDate = idx > 0 ? new Date(decryptedMessages[idx - 1]?.created_at) : null; + const isMentioned = isMentionedInContent(msg.content); + const isOwner = msg.username === username; + const canDelete = isOwner || !!myPermissions?.manage_messages; + + const prevMsg = idx > 0 ? decryptedMessages[idx - 1] : null; + const isGrouped = prevMsg + && prevMsg.username === msg.username + && !isNewDay(currentDate, previousDate) + && (currentDate - new Date(prevMsg.created_at)) < 60000 + && !msg.replyToId; + + const showDateDivider = isNewDay(currentDate, previousDate); + const dateLabel = showDateDivider ? currentDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) : ''; + + const showUnreadDivider = unreadDividerTimestamp != null + && msg.created_at > unreadDividerTimestamp + && (idx === 0 || decryptedMessages[idx - 1]?.created_at <= unreadDividerTimestamp); + + return ( + setHoveredMessageId(msg.id)} + onLeave={() => setHoveredMessageId(null)} + onContextMenu={(e) => { e.preventDefault(); window.dispatchEvent(new Event('close-context-menus')); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner, canDelete }); }} + onAddReaction={(emoji) => { if (emoji) { addReaction({ messageId: msg.id, userId: currentUserId, emoji }); } else { setReactionPickerMsgId(reactionPickerMsgId === msg.id ? null : msg.id); } }} + onEdit={() => { setEditingMessage({ id: msg.id, content: msg.content }); setEditInput(msg.content); }} + onReply={() => setReplyingTo({ messageId: msg.id, username: msg.username, content: msg.content?.substring(0, 100) })} + onMore={(e) => { const rect = e.target.getBoundingClientRect(); setContextMenu({ x: rect.left, y: rect.bottom, messageId: msg.id, isOwner, canDelete }); }} + onEditInputChange={(e) => setEditInput(e.target.value)} + onEditKeyDown={handleEditKeyDown} + onEditSave={handleEditSave} + onEditCancel={() => { setEditingMessage(null); setEditInput(''); }} + onReactionClick={handleReactionClick} + onScrollToMessage={scrollToMessage} + onProfilePopup={handleProfilePopup} + onImageClick={setZoomedImage} + scrollToBottom={scrollToBottom} + Attachment={Attachment} + LinkPreview={LinkPreview} + DirectVideo={DirectVideo} + /> + ); + }, [decryptedMessages, username, myPermissions, isMentionedInContent, unreadDividerTimestamp, editingMessage, hoveredMessageId, editInput, roles, customEmojis, reactionPickerMsgId, currentUserId, addReaction, handleEditKeyDown, handleEditSave, handleReactionClick, scrollToMessage, handleProfilePopup, scrollToBottom]); + return (
{isDragging && } @@ -1325,95 +1840,33 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u onImageClick={setZoomedImage} /> -
-
-
- {status === 'LoadingMore' && ( -
-
-
- )} - {status === 'Exhausted' && (decryptedMessages.length > 0 || rawMessages.length === 0) && ( -
-
{isDM ? '@' : '#'}
-

- {isDM ? `${channelName}` : `Welcome to #${channelName}`} -

-

- {isDM - ? `This is the beginning of your direct message history with ${channelName}.` - : `This is the start of the #${channelName} channel.` - } -

-
- )} - {status === 'LoadingFirstPage' && ( -
-
-
- )} - {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 ( - setHoveredMessageId(msg.id)} - onLeave={() => setHoveredMessageId(null)} - onContextMenu={(e) => { e.preventDefault(); window.dispatchEvent(new Event('close-context-menus')); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner, canDelete }); }} - onAddReaction={(emoji) => { if (emoji) { addReaction({ messageId: msg.id, userId: currentUserId, emoji }); } else { setReactionPickerMsgId(reactionPickerMsgId === msg.id ? null : msg.id); } }} - onEdit={() => { setEditingMessage({ id: msg.id, content: msg.content }); setEditInput(msg.content); }} - onReply={() => setReplyingTo({ messageId: msg.id, username: msg.username, content: msg.content?.substring(0, 100) })} - onMore={(e) => { const rect = e.target.getBoundingClientRect(); setContextMenu({ x: rect.left, y: rect.bottom, messageId: msg.id, isOwner, canDelete }); }} - onEditInputChange={(e) => setEditInput(e.target.value)} - onEditKeyDown={handleEditKeyDown} - onEditSave={handleEditSave} - onEditCancel={() => { setEditingMessage(null); setEditInput(''); }} - onReactionClick={handleReactionClick} - onScrollToMessage={scrollToMessage} - onProfilePopup={handleProfilePopup} - onImageClick={setZoomedImage} - scrollToBottom={scrollToBottom} - Attachment={Attachment} - LinkPreview={LinkPreview} - DirectVideo={DirectVideo} - /> - ); - })} -
-
+
+ {status === 'LoadingFirstPage' ? ( +
+
+
+ ) : ( + { scrollerElRef.current = el; }} + firstItemIndex={firstItemIndex} + initialTopMostItemIndex={{ index: 'LAST', align: 'end' }} + alignToBottom={true} + atBottomThreshold={20} + data={allDisplayMessages} + startReached={handleStartReached} + followOutput={followOutput} + atBottomStateChange={handleAtBottomStateChange} + increaseViewportBy={{ top: 400, bottom: 400 }} + defaultItemHeight={60} + computeItemKey={(index, item) => item.id || `idx-${index}`} + components={{ + Header: () => renderListHeader(), + Footer: () =>
, + }} + itemContent={(index, item) => renderMessageItem(item, index - firstItemIndex)} + /> + )}
{contextMenu && setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />} {reactionPickerMsgId && ( @@ -1465,7 +1918,15 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u } catch {} }} />} -
+ + {slashQuery !== null && filteredSlashCommands.length > 0 && ( + + )} {mentionQuery !== null && mentionItems.length > 0 && ( 2000 && currentUserId && channelId) { startTypingMutation({ channelId, userId: currentUserId, username }).catch(() => {}); diff --git a/packages/shared/src/components/SlashCommandMenu.jsx b/packages/shared/src/components/SlashCommandMenu.jsx new file mode 100644 index 0000000..b4a5bf8 --- /dev/null +++ b/packages/shared/src/components/SlashCommandMenu.jsx @@ -0,0 +1,51 @@ +import React, { useEffect, useRef } from 'react'; + +const SlashCommandMenu = ({ commands, selectedIndex, onSelect, onHover }) => { + const scrollerRef = useRef(null); + + useEffect(() => { + if (!scrollerRef.current) return; + const selected = scrollerRef.current.querySelector('.slash-command-row.selected'); + if (selected) selected.scrollIntoView({ block: 'nearest' }); + }, [selectedIndex]); + + if (!commands || commands.length === 0) return null; + + const grouped = {}; + for (const cmd of commands) { + const cat = cmd.category || 'Built-In'; + if (!grouped[cat]) grouped[cat] = []; + grouped[cat].push(cmd); + } + + let globalIndex = 0; + + return ( +
+
+ {Object.entries(grouped).map(([category, cmds]) => ( + +
{category}
+ {cmds.map((cmd) => { + const idx = globalIndex++; + return ( +
e.preventDefault()} + onClick={() => onSelect(cmd)} + onMouseEnter={() => onHover(idx)} + > + /{cmd.name} + {cmd.description} +
+ ); + })} +
+ ))} +
+
+ ); +}; + +export default SlashCommandMenu; diff --git a/packages/shared/src/index.css b/packages/shared/src/index.css index c9bc786..24d0cee 100644 --- a/packages/shared/src/index.css +++ b/packages/shared/src/index.css @@ -189,6 +189,16 @@ body { background-color: var(--bg-secondary); } +.channel-list::-webkit-scrollbar { + width: 8px; + background-color: var(--bg-primary); +} + +.channel-list::-webkit-scrollbar-thumb { + background-color: #666770; + border-radius: 4px; +} + .channel-header { padding: 0 8px; margin-bottom: 16px; @@ -251,11 +261,8 @@ body { .messages-list { flex: 1; - overflow-x: hidden; - overflow-y: auto; - padding: 20px 0 0 0; - display: flex; - flex-direction: column-reverse; + overflow: hidden; + position: relative; } .messages-list::-webkit-scrollbar { @@ -535,10 +542,17 @@ body { text-decoration: underline; } -/* .messages-content-wrapper */ -.messages-content-wrapper { - display: flex; - flex-direction: column; +/* Virtuoso scroller scrollbar styles */ +.messages-list [data-virtuoso-scroller] { + overflow-y: auto !important; +} +.messages-list [data-virtuoso-scroller]::-webkit-scrollbar { + width: 8px; + background-color: var(--bg-primary); +} +.messages-list [data-virtuoso-scroller]::-webkit-scrollbar-thumb { + background-color: #666770; + border-radius: 4px; } /* ... existing styles ... */ @@ -719,6 +733,45 @@ body { animation: spin 0.8s linear infinite; } +/* Skeleton message loading placeholders */ +@keyframes skeleton-pulse { + 0%, 100% { opacity: 0.06; } + 50% { opacity: 0.12; } +} + +.skeleton-message { + display: flex; + padding: 4px 48px 4px 16px; + gap: 16px; + align-items: flex-start; +} + +.skeleton-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + flex-shrink: 0; + background: var(--text-normal); + animation: skeleton-pulse 1.5s ease-in-out infinite; +} + +.skeleton-name { + height: 16px; + border-radius: 8px; + margin-bottom: 8px; + margin-top: 4px; + background: var(--text-normal); + animation: skeleton-pulse 1.5s ease-in-out infinite; +} + +.skeleton-line { + height: 16px; + border-radius: 8px; + margin-bottom: 4px; + background: var(--text-normal); + animation: skeleton-pulse 1.5s ease-in-out infinite; +} + /* Channel beginning indicator */ .channel-beginning { padding: 16px 16px 8px; @@ -3898,4 +3951,141 @@ img.search-dropdown-avatar { @keyframes voiceConnectingPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } +} + +/* Slash Command Menu */ +.slash-command-menu { + position: absolute; + bottom: calc(100% + 4px); + left: 0; + right: 0; + background-color: var(--background-surface-high, var(--embed-background)); + border-radius: 8px; + box-shadow: 0 0 0 1px rgba(0,0,0,.1), 0 8px 16px rgba(0,0,0,.24); + overflow: hidden; + z-index: 100; +} + +.slash-command-scroller { + max-height: 490px; + overflow-y: auto; + padding-bottom: 8px; +} + +.slash-command-scroller::-webkit-scrollbar { + width: 6px; +} + +.slash-command-scroller::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-auto-thumb, var(--bg-tertiary)); + border-radius: 3px; +} + +.slash-command-section-header { + padding: 8px 12px 4px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + color: var(--header-secondary); +} + +.slash-command-row { + display: flex; + align-items: center; + padding: 8px 12px; + gap: 10px; + cursor: pointer; +} + +.slash-command-row:hover, +.slash-command-row.selected { + background-color: var(--interactive-background-hover, rgba(255,255,255,0.06)); +} + +.slash-command-name { + font-size: 14px; + font-weight: 600; + color: var(--text-normal); + flex-shrink: 0; +} + +.slash-command-description { + font-size: 13px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Ephemeral Messages */ +.ephemeral-message { + background: hsla(235, 86%, 65%, 0.05); +} + +.ephemeral-reply-context { + cursor: default; +} + +.ephemeral-reply-avatar { + width: 16px; + height: 16px; + border-radius: 50%; + background-color: #5865f2; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: 20px; + color: white; +} + +.ephemeral-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background-color: #5865f2; + display: flex; + align-items: center; + justify-content: center; + color: white; +} + +.ephemeral-bot-badge { + font-size: 10px; + font-weight: 600; + background-color: #5865f2; + color: white; + padding: 1px 4px; + border-radius: 3px; + text-transform: uppercase; + line-height: 14px; + margin-left: 4px; + margin-right: 4px; +} + +.ephemeral-message-footer { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--text-muted); + margin-top: 4px; +} + +.ephemeral-message-footer-text { + opacity: 0.6; +} + +.ephemeral-message-footer-sep { + opacity: 0.4; + margin: 0 2px; +} + +.ephemeral-message-dismiss { + color: var(--text-link); + cursor: pointer; +} + +.ephemeral-message-dismiss:hover { + text-decoration: underline; } \ No newline at end of file diff --git a/packages/shared/src/pages/Chat.jsx b/packages/shared/src/pages/Chat.jsx index 67e9b67..be44171 100644 --- a/packages/shared/src/pages/Chat.jsx +++ b/packages/shared/src/pages/Chat.jsx @@ -38,6 +38,10 @@ const Chat = () => { const [showPinned, setShowPinned] = useState(false); const [mobileView, setMobileView] = useState('sidebar'); + // Jump-to-message state (for search result clicks) + const [jumpToMessageId, setJumpToMessageId] = useState(null); + const clearJumpToMessage = useCallback(() => setJumpToMessageId(null), []); + // Search state const [searchQuery, setSearchQuery] = useState(''); const [showSearchDropdown, setShowSearchDropdown] = useState(false); @@ -453,15 +457,7 @@ const Chat = () => { } setShowSearchResults(false); setSearchQuery(''); - // Give time for channel to render then scroll - setTimeout(() => { - 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); - } - }, 300); + setJumpToMessageId(messageId); }, [dmChannels]); // Shared search props for ChatHeader @@ -540,6 +536,8 @@ const Chat = () => { onOpenDM={openDM} showPinned={showPinned} onTogglePinned={() => setShowPinned(false)} + jumpToMessageId={jumpToMessageId} + onClearJumpToMessage={clearJumpToMessage} /> { onOpenDM={openDM} showPinned={showPinned} onTogglePinned={() => setShowPinned(false)} + jumpToMessageId={jumpToMessageId} + onClearJumpToMessage={clearJumpToMessage} /> ...)`", + "use `arr.reduce((a, b) => a + b, 0)` for sum", + "`?.` optional chaining saved my life so many times", + "just use `structuredClone(obj)` for deep copies now", + "`Promise.all()` is way faster than awaiting in a loop", + "you can do `const { data } = await fetch(url).then(r => r.json())`", + "`Array.from({ length: n }, (_, i) => i)` for range", + "use `Set` for unique values: `[...new Set(arr)]`", + "template literals are so much cleaner than string concat", + "`console.table()` is underrated for debugging arrays", + "destructuring assignment makes everything cleaner", + "`??` nullish coalescing is better than `||` for defaults", + "use `at(-1)` instead of `arr[arr.length - 1]`", + "async generators are pretty neat for streaming data", +]; + +const gaming = [ + "anyone want to play some valorant later?", + "just hit diamond finally let's gooo", + "that game was so close", + "i keep getting destroyed in ranked", + "new update is actually fire", + "the servers are lagging again", + "who's online tonight?", + "gg wp everyone", + "i need to touch grass honestly", + "my aim is so bad today", + "anyone else hyped for the new season?", + "that clutch was insane", + "i'm taking a break from competitive", + "the new character is broken lmao", + "how do you have so many hours in that game", + "just one more game... said 3 hours ago", + "the matchmaking is so bad", + "rage quit incoming", + "carried the whole team ngl", + "that was the worst game of my life", +]; + +const observations = [ + "i can't believe it's already february", + "why does time go so fast", + "the weather is terrible today", + "i should really go to sleep", + "my cat just knocked everything off my desk", + "i've been coding for 8 hours straight", + "i forgot to eat lunch again", + "my internet is so slow today", + "i really need a second monitor", + "it's so quiet in here", + "does anyone else stay up way too late", + "i just realized i've been in the wrong channel", + "my keyboard is so loud my roommate hates me", + "i need more coffee", + "the sun is already setting what", + "i should probably go outside at some point", + "how is it midnight already", + "my phone is at 2% as usual", + "i've had this tab open for 3 days", + "just spent 2 hours debugging a typo", +]; + +const humor = [ + "my code worked first try, something is definitely wrong", + "git commit -m 'fixed it (for real this time)'", + "99 bugs in the code, take one down patch it around, 127 bugs in the code", + "it works on my machine ¯\\_(ツ)_/¯", + "the real bug was the friends we made along the way", + "who needs sleep when you have bugs to fix", + "stackoverflow is my copilot", + "me: writes 10 lines of code. debugger: and i took that personally", + "css: where everything is made up and the points don't matter", + "there are 10 types of people: those who understand binary and those who don't", + "a sql query walks into a bar, sees two tables, and asks: can i join you?", + "!false — it's funny because it's true", + "i don't always test my code but when i do i do it in production", + "my code doesn't have bugs, it has surprise features", + "the best error message is the one that never shows up", +]; + +const emoji = [ + "let's gooooo 🎉", "pain 😭", "this is fine 🔥", "vibes ✨", + "crying rn 😂", "big brain move 🧠", "W take 👑", "sadge 😔", + "sheeeesh 🥶", "lowkey scary 😳", "that's a vibe 💯", "mood 😴", + "nailed it 💪", "oof 😬", "poggies 🐸", +]; + +const allMessages = [ + ...casual, ...reactions, ...short, ...techQuestions, ...techAnswers, + ...codeSnippets, ...gaming, ...observations, ...humor, ...emoji, +]; + +const prefixes = ["oh ", "honestly ", "ngl ", "lowkey ", "wait ", "yo ", "bro ", "dude ", "hmm ", "ah "]; +const suffixes = [" tbh", " lol", " lmao", " fr", " honestly", " though", " ngl", " imo", " haha", " bruh"]; + +export function generateUniqueMessage(index) { + if (index < allMessages.length) { + return allMessages[index]; + } + + const baseIndex = index % allMessages.length; + const variation = Math.floor(index / allMessages.length); + let msg = allMessages[baseIndex]; + + if (variation % 3 === 1) { + msg = prefixes[variation % prefixes.length] + msg; + } else if (variation % 3 === 2) { + msg = msg + suffixes[variation % suffixes.length]; + } else { + msg = prefixes[(variation + 3) % prefixes.length] + msg + suffixes[(variation + 5) % suffixes.length]; + } + + return msg; +}