From 6cc1846b6a1187ce0194107b70f1b7d9bfce7f32 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 16 Apr 2026 09:03:16 -0500 Subject: [PATCH 1/2] perf: virtualization and architectural hardening - Implemented react-virtuoso for ChannelMessageList and MemberList to handle large data sets. - Normalized user identity store (globalUsers) for O(1) lookups and consistent state. - Migrated message deduplication to timestamped Map for 24h retention. - Added System Tray support and Autostart plugin for Tauri. - Resolved all TypeScript build errors. --- package-lock.json | 64 ++ package.json | 3 + src-tauri/Cargo.toml | 3 +- src-tauri/capabilities/default.json | 3 +- src-tauri/src/lib.rs | 44 ++ src/components/layout/ChannelMessageList.tsx | 621 +++++++------------ src/components/layout/MemberList.tsx | 27 +- src/components/message/MessageItem.tsx | 142 +---- src/store/handlers/batches.ts | 24 +- src/store/handlers/messages.ts | 90 ++- src/store/handlers/users.ts | 149 +---- src/store/index.ts | 194 +++++- tests/store/multilineDedup.test.ts | 62 +- 13 files changed, 651 insertions(+), 775 deletions(-) diff --git a/package-lock.json b/package-lock.json index 18319fd7..a22ca787 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@heroicons/react": "^2.2.0", "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-deep-link": "^2.4.8", "@tauri-apps/plugin-haptics": "^2.3.2", "@tauri-apps/plugin-notification": "^2.2.2", @@ -23,6 +24,7 @@ "emoji-datasource": "^16.0.0", "emoji-picker-react": "^4.13.3", "exifr": "^7.1.3", + "framer-motion": "^12.38.0", "fuse.js": "^7.1.0", "gh-pages": "^6.3.0", "highlight.js": "^11.11.1", @@ -37,6 +39,7 @@ "react-player": "^3.4.0", "react-router-dom": "^7.12.0", "react-swipeable": "^7.0.2", + "react-virtuoso": "^4.18.5", "tailwindcss-animate": "^1.0.7", "uuid": "^11.1.0", "zustand": "^5.0.12" @@ -2607,6 +2610,14 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-autostart": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-autostart/-/plugin-autostart-2.5.1.tgz", + "integrity": "sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w==", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-deep-link": { "version": "2.4.8", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.8.tgz", @@ -4771,6 +4782,32 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fs-extra": { "version": "11.3.2", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", @@ -6008,6 +6045,19 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==" + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -6942,6 +6992,15 @@ "react": "^16.8.3 || ^17 || ^18 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/react-virtuoso": { + "version": "4.18.5", + "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.5.tgz", + "integrity": "sha512-QDyNjyNEuurZG67SOmzYyxEkQYSyGmAMixOI6M15L/Q4CF39EgG+88y6DgZRo0q7rmy0HPx3Fj90I8/tPdnRCQ==", + "peerDependencies": { + "react": ">=16 || >=17 || >= 18 || >= 19", + "react-dom": ">=16 || >=17 || >= 18 || >=19" + } + }, "node_modules/reactcss": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz", @@ -7791,6 +7850,11 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "node_modules/twitch-video-element": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/twitch-video-element/-/twitch-video-element-0.1.6.tgz", diff --git a/package.json b/package.json index 95b0eabe..6b486314 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "dependencies": { "@heroicons/react": "^2.2.0", "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-deep-link": "^2.4.8", "@tauri-apps/plugin-haptics": "^2.3.2", "@tauri-apps/plugin-notification": "^2.2.2", @@ -46,6 +47,7 @@ "emoji-datasource": "^16.0.0", "emoji-picker-react": "^4.13.3", "exifr": "^7.1.3", + "framer-motion": "^12.38.0", "fuse.js": "^7.1.0", "gh-pages": "^6.3.0", "highlight.js": "^11.11.1", @@ -60,6 +62,7 @@ "react-player": "^3.4.0", "react-router-dom": "^7.12.0", "react-swipeable": "^7.0.2", + "react-virtuoso": "^4.18.5", "tailwindcss-animate": "^1.0.7", "uuid": "^11.1.0", "zustand": "^5.0.12" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5d1374fc..af3508ac 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,7 +21,8 @@ tauri-build = { version = "2.0", features = [] } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" -tauri = { version = "2.5", features = [] } +tauri = { version = "2.5", features = ["image-ico", "image-png", "tray-icon"] } +tauri-plugin-autostart = "2.3" tauri-plugin-log = "2.0.0-rc" tauri-plugin-notification = "2.3" tauri-plugin-os = "2.3" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 44710d07..0118c644 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "notification:default", "opener:default", "os:default", - "deep-link:default" + "deep-link:default", + "autostart:default" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 83fc3d19..e9a6de37 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -225,7 +225,51 @@ pub fn run() { .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_autostart::init(tauri_plugin_autostart::MacosLauncher::LaunchAgent, Some(vec!["--minimized"]))) .setup(|app| { + #[cfg(desktop)] + { + use tauri::menu::{Menu, MenuItem}; + use tauri::tray::{TrayIconBuilder, TrayIconEvent}; + + let show = MenuItem::with_id(app, "show", "Show ObsidianIRC", true, None::<&str>)?; + let hide = MenuItem::with_id(app, "hide", "Hide to Tray", true, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&show, &hide, &quit])?; + + let _tray = TrayIconBuilder::new() + .tooltip("ObsidianIRC") + .icon(app.default_window_icon().unwrap().clone()) + .menu(&menu) + .on_menu_event(|app, event| { + match event.id.as_ref() { + "show" => { + let window = app.get_webview_window("main").unwrap(); + window.show().unwrap(); + window.set_focus().unwrap(); + } + "hide" => { + let window = app.get_webview_window("main").unwrap(); + window.hide().unwrap(); + } + "quit" => { + app.exit(0); + } + _ => {} + } + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { .. } = event { + let app = tray.app_handle(); + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + }) + .build(app)?; + } + if cfg!(debug_assertions) { app.handle().plugin( tauri_plugin_log::Builder::default() diff --git a/src/components/layout/ChannelMessageList.tsx b/src/components/layout/ChannelMessageList.tsx index 4cee9c2f..90d0618d 100644 --- a/src/components/layout/ChannelMessageList.tsx +++ b/src/components/layout/ChannelMessageList.tsx @@ -3,17 +3,14 @@ import { forwardRef, memo, useCallback, - useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; -import { - SCROLL_TOLERANCE, - useScrollToBottom, -} from "../../hooks/useScrollToBottom"; +import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; +import { SCROLL_TOLERANCE } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; @@ -101,34 +98,32 @@ export const ChannelMessageList = forwardRef< }, ref, ) => { - const [visibleMessageCount, setVisibleMessageCount] = useState( - initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, - ); - // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. - const visibleMessageCountRef = useRef(visibleMessageCount); - visibleMessageCountRef.current = visibleMessageCount; - // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). + const [isScrolledUp, setIsScrolledUp] = useState(false); + const wasAtBottomRef = useRef(true); const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); - const messagesEndRef = useRef(null); - const messagesContainerRef = useRef(null); - const messagesInnerRef = useRef(null); - // prev scrollHeight for prepend delta-correction. - const prevScrollHeightRef = useRef(0); - // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value - // without listing isScrolledUp as a dep (which would re-run effects on every scroll). - const isScrolledUpRef = useRef(false); - const prevFilteredLengthRef = useRef(0); - const prevFirstMsgIdRef = useRef(null); - // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. - // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). - // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the - // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. - const pendingPrependRef = useRef(false); - // Shared scrollHeight baseline between the delta-correction layout effect and the inner - // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's - // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. - const resizeObserverPrevSHRef = useRef(0); + const virtuosoRef = useRef(null); + + // Snapshot of the last known scroll position captured while the container was visible. + const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); + + useImperativeHandle(ref, () => ({ + setAtBottom: () => { + // virtuoso handles this via followOutput + }, + scrollToBottom: () => { + virtuosoRef.current?.scrollToIndex({ + index: eventGroups.length - 1, + align: "end", + behavior: "smooth", + }); + }, + getScrollState: () => ({ + scrollTop: lastScrollTopRef.current, + isAtBottom: wasAtBottomRef.current, + visibleCount: eventGroups.length, // Virtuoso handles visibility, but we report total count + }), + })); const channelMessages = useStore( useCallback( @@ -137,9 +132,6 @@ export const ChannelMessageList = forwardRef< ), ); const servers = useStore((state) => state.servers); - const mobileViewActiveColumn = useStore( - (state) => state.ui.mobileViewActiveColumn, - ); const channel = useMemo( () => @@ -151,53 +143,6 @@ export const ChannelMessageList = forwardRef< [servers, serverId, channelId], ); - const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( - messagesContainerRef, - messagesEndRef, - { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, - ); - - // Snapshot of the last known scroll position captured while the container was visible. - // getScrollState() reads this instead of the live DOM because React commits display:none - // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. - const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); - - useEffect(() => { - const container = messagesContainerRef.current; - if (!container) return; - const onScroll = () => { - if (container.clientHeight > 0) - lastScrollTopRef.current = container.scrollTop; - }; - container.addEventListener("scroll", onScroll, { passive: true }); - return () => container.removeEventListener("scroll", onScroll); - }, []); - - // Restore scroll position when a keep-alive channel transitions from hidden to visible. - // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. - const prevActiveRef = useRef(isActive); - useLayoutEffect(() => { - if (isActive && !prevActiveRef.current) { - const container = messagesContainerRef.current; - if (container && lastScrollTopRef.current > 0) { - container.scrollTop = lastScrollTopRef.current; - } - } - prevActiveRef.current = isActive; - }, [isActive]); - - useImperativeHandle(ref, () => ({ - setAtBottom: () => { - wasAtBottomRef.current = true; - }, - scrollToBottom, - getScrollState: () => ({ - scrollTop: lastScrollTopRef.current, - isAtBottom: wasAtBottomRef.current, - visibleCount: visibleMessageCountRef.current, - }), - })); - const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); @@ -208,359 +153,221 @@ export const ChannelMessageList = forwardRef< ); }, [channelMessages, searchQuery]); - useEffect(() => { - isScrolledUpRef.current = isScrolledUp; - // When the user returns to the bottom, shrink the window back to the base so - // slice(-N) resumes trimming old messages from the top (memory optimization). - // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. - if (!isScrolledUp) { - setVisibleMessageCount((prev) => - prev > DEFAULT_VISIBLE_MESSAGE_COUNT - ? DEFAULT_VISIBLE_MESSAGE_COUNT - : prev, - ); - } - }, [isScrolledUp]); - - // Reset ref-tracked windowing state when switching channels. - // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) - // already initializes it correctly on mount, and this effect runs once on mount for the - // same channelKey (each instance is bound to exactly one channel by the parent key={}). - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change - useEffect(() => { - prevFilteredLengthRef.current = 0; - prevFirstMsgIdRef.current = null; - prevScrollHeightRef.current = 0; - pendingPrependRef.current = false; - resizeObserverPrevSHRef.current = 0; - }, [channelKey]); - - const displayedMessages = useMemo(() => { - if (searchQuery.trim()) return filteredMessages; - return filteredMessages.slice(-visibleMessageCount); - }, [filteredMessages, visibleMessageCount, searchQuery]); - - const locallyHidden = filteredMessages.length > displayedMessages.length; - const serverHasMore = channel?.hasMoreHistory === true; - const hasMoreMessages = locallyHidden || serverHasMore; - const eventGroups = useMemo( - () => groupConsecutiveEvents(displayedMessages), - [displayedMessages], + () => groupConsecutiveEvents(filteredMessages), + [filteredMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; - // Scroll to bottom on initial mount, unless a saved position was passed in. - // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only - useEffect(() => { - const container = messagesContainerRef.current; - if (!container) return; - if (initialScrollState) { - container.scrollTop = initialScrollState.scrollTop; - lastScrollTopRef.current = initialScrollState.scrollTop; - wasAtBottomRef.current = false; - } else { - container.scrollTop = container.scrollHeight; - lastScrollTopRef.current = container.scrollHeight; - wasAtBottomRef.current = true; - } - }, []); - - // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. + // Reset fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); - // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { - // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); - } else { - scrollToBottom(); - wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); - // When older messages are prepended, grow the window so they enter displayedMessages. - // When new messages arrive at the bottom while the user is scrolled up, also grow the - // window to keep the current top messages visible — slice(-N) otherwise slides the - // window forward and hides them, incrementing the "N older messages" counter on every - // incoming message. Only let the slice trim from the top when the user is at the bottom - // (where auto-scroll handles keeping them current). - useLayoutEffect(() => { - const newLength = filteredMessages.length; - const newFirstId = filteredMessages[0]?.id ?? null; - const delta = newLength - prevFilteredLengthRef.current; - - if (prevFilteredLengthRef.current > 0 && delta > 0) { - if (newFirstId !== prevFirstMsgIdRef.current) { - // Messages prepended (load-more): signal delta-correction to compensate scrollTop. - pendingPrependRef.current = true; - setVisibleMessageCount((prev) => prev + delta); - } else if (isScrolledUpRef.current) { - // Messages appended at bottom while user is scrolled up reading history. - // Expand the window to prevent top messages from dropping out of the slice. - setVisibleMessageCount((prev) => prev + delta); + const renderItem = useCallback( + (index: number) => { + const group = eventGroups[index]; + if (!group) return null; + + if (group.type === "eventGroup") { + const firstId = group.messages[0]?.id || ""; + const lastId = group.messages[group.messages.length - 1]?.id || ""; + const groupKey = `group-${firstId}-${lastId}`; + return ( + + ); } - } - prevFilteredLengthRef.current = newLength; - prevFirstMsgIdRef.current = newFirstId; - }, [filteredMessages]); - - // Compensate scrollTop when content is prepended above the viewport. - // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable - useLayoutEffect(() => { - const container = messagesContainerRef.current; - if (!container) return; - - // Skip while container is display:none — scrollHeight collapses to 0 and would - // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. - if (container.clientHeight === 0) return; - - const prevHeight = prevScrollHeightRef.current; - const newHeight = container.scrollHeight; - - // Only correct when a true load-more prepend happened (flag set by the window-growth - // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also - // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. - const wasPrepend = pendingPrependRef.current; - // Only consume the flag when scrollHeight actually changed — the server-side load-more - // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, - // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, - // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A - // so it's still set when Render B fires the actual correction. - if (wasPrepend && newHeight !== prevHeight) { - pendingPrependRef.current = false; - } + const message = group.messages[0]; + const originalIndex = filteredMessages.findIndex( + (m) => m.id === message.id, + ); + const previousMessage = filteredMessages[originalIndex - 1]; + const showHeader = + !previousMessage || + previousMessage.type !== "message" || + previousMessage.userId !== message.userId || + new Date(message.timestamp).getTime() - + new Date(previousMessage.timestamp).getTime() > + 5 * 60 * 1000; + + return ( + + ); + }, + [ + eventGroups, + filteredMessages, + onUsernameContextMenu, + onReply, + highlightedMessageId, + onIrcLinkClick, + onReactClick, + joinChannel, + onReactionUnreact, + onOpenReactionModal, + onDirectReaction, + serverId, + channelId, + privateChatId, + onRedactMessage, + onOpenProfile, + channel?.users, + ], + ); - if ( - isScrolledUpRef.current && - prevHeight > 0 && - newHeight > prevHeight && - wasPrepend - ) { - const delta = newHeight - prevHeight; - container.scrollTop += delta; - resizeObserverPrevSHRef.current = newHeight; + const Header = useMemo(() => { + if (!isLoadingHistory && channel?.hasMoreHistory && !searchQuery) { + return ( +
+ +
+ ); } - - prevScrollHeightRef.current = newHeight; - }, [displayedMessages]); - - // Re-stick to bottom when inner message content grows (media/audio previews loading). - // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the - // ref is true while the user is actively scrolling up. - // When the container width changes (member list toggle, window resize), text reflows - // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. - // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref - useEffect(() => { - const container = messagesContainerRef.current; - const inner = messagesInnerRef.current; - if (!inner || !container) return; - resizeObserverPrevSHRef.current = container.scrollHeight; - let prevClientWidth = container.clientWidth; - const observer = new ResizeObserver(() => { - if (container.clientHeight === 0) return; - // Effect may re-initialize while container is display:none (ref=0). - // Re-seed with current dimensions and skip — no reliable "was at bottom" data. - if (resizeObserverPrevSHRef.current === 0) { - resizeObserverPrevSHRef.current = container.scrollHeight; - prevClientWidth = container.clientWidth; - return; - } - const currentClientWidth = container.clientWidth; - const widthChanged = currentClientWidth !== prevClientWidth; - prevClientWidth = currentClientWidth; - const prevSH = resizeObserverPrevSHRef.current; - const wasAtPrevBottom = - container.scrollTop + container.clientHeight >= - prevSH - SCROLL_TOLERANCE; - resizeObserverPrevSHRef.current = container.scrollHeight; - if (wasAtPrevBottom) { - scrollToBottom(); - } else if (widthChanged && prevSH > 0) { - const ratio = container.scrollTop / prevSH; - container.scrollTop = Math.round(ratio * container.scrollHeight); - } - }); - observer.observe(inner); - return () => observer.disconnect(); - }, [isLoadingHistory, channelId, privateChatId]); - - // Auto-scroll on new messages — skip when this channel is hidden (display:none). - // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes - useEffect(() => { - if (!isActive) return; - const isNarrowView = window.matchMedia("(max-width: 768px)").matches; - const isChatVisible = - !isNarrowView || mobileViewActiveColumn === "chatView"; - if (wasAtBottomRef.current && isChatVisible) { - scrollToBottom(); + if (searchQuery) { + return ( +
+ + Found {filteredMessages.length} message + {filteredMessages.length === 1 ? "" : "s"} matching "{searchQuery} + " + + +
+ ); } - }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); + return null; + }, [ + isLoadingHistory, + channelId, + searchQuery, + isFetchingMore, + serverId, + filteredMessages.length, + onClearSearch, + channel.name, + channelMessages[0], + channel?.hasMoreHistory, + channel, + ]); + + if (isLoadingHistory && !isFetchingMore) { + return ( +
+ +
+ ); + } return ( - <> -
+ Header }} + followOutput={(isAtBottom) => (isAtBottom ? "smooth" : false)} + atBottomStateChange={(atBottom) => { + wasAtBottomRef.current = atBottom; + setIsScrolledUp(!atBottom); + }} + atBottomThreshold={SCROLL_TOLERANCE} + initialTopMostItemIndex={ + initialScrollState ? undefined : eventGroups.length - 1 + } + increaseViewportBy={300} + className="flex-grow virtuoso-scroller" style={{ overflowAnchor: "none" }} - > - {isLoadingHistory && !isFetchingMore ? ( -
- -
- ) : ( -
- {hasMoreMessages && !searchQuery && ( -
- -
- )} - {searchQuery && ( -
- - Found {filteredMessages.length} message - {filteredMessages.length === 1 ? "" : "s"} matching " - {searchQuery}" - - -
- )} - {eventGroups.map((group) => { - if (group.type === "eventGroup") { - const firstId = group.messages[0]?.id || ""; - const lastId = - group.messages[group.messages.length - 1]?.id || ""; - const groupKey = `group-${firstId}-${lastId}`; - return ( - - ); - } - const message = group.messages[0]; - const originalIndex = channelMessages.findIndex( - (m) => m.id === message.id, - ); - const previousMessage = channelMessages[originalIndex - 1]; - const showHeader = - !previousMessage || - previousMessage.type !== "message" || - previousMessage.userId !== message.userId || - new Date(message.timestamp).getTime() - - new Date(previousMessage.timestamp).getTime() > - 5 * 60 * 1000; - return ( - - ); - })} -
- )} -
-
+ /> { + virtuosoRef.current?.scrollToIndex({ + index: eventGroups.length - 1, + align: "end", + behavior: "smooth", + }); + }} /> - +
); }, ); diff --git a/src/components/layout/MemberList.tsx b/src/components/layout/MemberList.tsx index b614cbe9..509bb31b 100644 --- a/src/components/layout/MemberList.tsx +++ b/src/components/layout/MemberList.tsx @@ -1,6 +1,7 @@ import type React from "react"; import { useEffect, useState } from "react"; import { FaCheckCircle, FaChevronLeft } from "react-icons/fa"; +import { Virtuoso } from "react-virtuoso"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import ircClient from "../../lib/ircClient"; import { @@ -519,7 +520,7 @@ export const MemberList: React.FC = () => { const isMobileView = useMediaQuery(); return ( -
+
{isMobileView && (
{ return null; }; -// Helper function to get full user object from shared channels -const getUserFromChannels = (username: string, serverId: string) => { - const state = useStore.getState(); - const server = state.servers.find((s) => s.id === serverId); - if (!server) return null; - - // Search through all channels for this user - for (const channel of server.channels) { - const user = channel.users.find( - (u) => u.username.toLowerCase() === username.toLowerCase(), - ); - if (user) { - return user; - } - } - - return null; +// Helper function to get user from global normalization store +const getUserFromGlobal = ( + state: AppState, + serverId: string, + username: string, +) => { + const key = `${serverId}-${username.toLowerCase()}`; + return state.globalUsers[key]; }; // When index 0 has no preview (type null), the first known-type entry must be shown @@ -196,117 +186,19 @@ export const MessageItem = memo((props: MessageItemProps) => { } }; - const ircCurrentUser = ircClient.getCurrentUser(message.serverId); - const isCurrentUser = ircCurrentUser?.username === message.userId; - - // Get the user key using reactive selector - const userKey = useStore( + const messageUser: User | undefined = useStore( useCallback( (state) => { - if (!serverId) return "none"; - - if (!channelId) { - const server = state.servers.find((s) => s.id === serverId); - // Prefer channel user — covers current user who has no PrivateChat entry - if (server) { - const user = server.channels - .flatMap((c) => c.users) - .find( - (u) => - u.username.toLowerCase() === message.userId.toLowerCase(), - ); - if (user) { - return `channel-${user.id}`; - } - } - const privateChat = server?.privateChats?.find( - (pc) => pc.username === message.userId, - ); - if (privateChat) { - return `pm-${privateChat.id}`; - } - return "none"; - } - - const server = state.servers.find((s) => s.id === serverId); - const channel = server?.channels.find((c) => c.id === channelId); - const user = channel?.users.find( - (user) => user.username === message.userId, - ); - return user ? `channel-${user.id}` : "none"; - }, - [serverId, channelId, message.userId], - ), - ); - - const rawMessageUser = useStore( - useCallback( - (state) => { - if (userKey === "none") return undefined; - - if (userKey.startsWith("pm-")) { - const privateChatId = userKey.slice(3); - const privateChat = state.servers - .find((s) => s.id === serverId) - ?.privateChats?.find((pc) => pc.id === privateChatId); - if (privateChat) return privateChat; - } else if (userKey.startsWith("channel-")) { - const userId = userKey.slice(8); - const server = state.servers.find((s) => s.id === serverId); - if (channelId) { - const channel = server?.channels.find((c) => c.id === channelId); - return channel?.users.find((user) => user.id === userId); - } - // DM context: no channelId, search all channels - return server?.channels - .flatMap((c) => c.users) - .find((user) => user.id === userId); - } - - return undefined; + const key = `${serverId}-${message.userId.toLowerCase()}`; + return state.globalUsers[key]; }, - [userKey, serverId, channelId], + [serverId, message.userId], ), ); - const metadataChangeCounter = useStore( - (state) => state.metadataChangeCounter, - ); - - // useMemo instead of useStore — safe to read localStorage without infinite loop via useSyncExternalStore - // biome-ignore lint/correctness/useExhaustiveDependencies: metadataChangeCounter is intentional reactive trigger - const pmUserMetadata = useMemo(() => { - if (!userKey.startsWith("pm-")) return null; - const pmUsername = (rawMessageUser as PrivateChat)?.username; - if (!pmUsername || !serverId) return null; - return getUserMetadata(pmUsername, serverId); - }, [metadataChangeCounter, userKey, rawMessageUser, serverId]); - - const messageUser: User | undefined = useMemo(() => { - if (!rawMessageUser) return undefined; - - // For PM users, rawMessageUser is the privateChat object - // We need to construct a proper User object - if (userKey.startsWith("pm-")) { - const privateChat = rawMessageUser as PrivateChat; - const user: User = { - id: privateChat.id, - username: privateChat.username, - realname: "", - account: "", - isOnline: privateChat.isOnline ?? true, - isAway: privateChat.isAway ?? false, - status: "", - isBot: false, - isIrcOp: false, - metadata: pmUserMetadata || {}, - }; - return user; - } - - // For channel users, rawMessageUser is already a proper User object - return rawMessageUser as User; - }, [rawMessageUser, pmUserMetadata, userKey]); + const ircCurrentUser = useStore((state) => state.currentUser); + const isCurrentUser = + ircCurrentUser?.username.toLowerCase() === message.userId.toLowerCase(); const avatarUrl = messageUser?.metadata?.avatar?.value; const displayName = messageUser?.metadata?.["display-name"]?.value; diff --git a/src/store/handlers/batches.ts b/src/store/handlers/batches.ts index 0c425a66..cd48d618 100644 --- a/src/store/handlers/batches.ts +++ b/src/store/handlers/batches.ts @@ -362,10 +362,14 @@ export function registerBatchHandlers(store: StoreApi): void { [serverId]: remainingBatches, }, messages: { ...state.messages, [key]: finalMessages }, - processedMessageIds: - newMsgIds.length > 0 - ? new Set([...state.processedMessageIds, ...newMsgIds]) - : state.processedMessageIds, + processedMessageIds: (() => { + if (newMsgIds.length === 0) return state.processedMessageIds; + const newMap = new Map(state.processedMessageIds); + for (const id of newMsgIds) { + newMap.set(id, Date.now()); + } + return newMap; + })(), servers: state.servers.map((s) => { if (s.id !== serverId) return s; return { @@ -464,10 +468,14 @@ export function registerBatchHandlers(store: StoreApi): void { [serverId]: remainingBatches, }, messages: { ...state.messages, [key]: finalMessages }, - processedMessageIds: - newMsgIds.length > 0 - ? new Set([...state.processedMessageIds, ...newMsgIds]) - : state.processedMessageIds, + processedMessageIds: (() => { + if (newMsgIds.length === 0) return state.processedMessageIds; + const newMap = new Map(state.processedMessageIds); + for (const id of newMsgIds) { + newMap.set(id, Date.now()); + } + return newMap; + })(), }; } diff --git a/src/store/handlers/messages.ts b/src/store/handlers/messages.ts index 68f3644e..a7d136d2 100644 --- a/src/store/handlers/messages.ts +++ b/src/store/handlers/messages.ts @@ -275,10 +275,9 @@ export function registerMessageHandlers(store: StoreApi): void { // Add processed message ID if present if (mtags?.msgid) { - newState.processedMessageIds = new Set([ - ...state.processedMessageIds, - mtags.msgid, - ]); + const newMap = new Map(state.processedMessageIds); + newMap.set(mtags.msgid, Date.now()); + newState.processedMessageIds = newMap; } return newState; @@ -415,12 +414,13 @@ export function registerMessageHandlers(store: StoreApi): void { ? [mtags.msgid] : []; if (idsToTrack.length > 0) { - store.setState((state) => ({ - processedMessageIds: new Set([ - ...state.processedMessageIds, - ...idsToTrack, - ]), - })); + store.setState((state) => { + const newMap = new Map(state.processedMessageIds); + for (const id of idsToTrack) { + newMap.set(id, Date.now()); + } + return { processedMessageIds: newMap }; + }); } store.getState().addMessage(newMessage); @@ -492,12 +492,13 @@ export function registerMessageHandlers(store: StoreApi): void { ? [mtags.msgid] : []; if (idsToTrack.length > 0) { - store.setState((state) => ({ - processedMessageIds: new Set([ - ...state.processedMessageIds, - ...idsToTrack, - ]), - })); + store.setState((state) => { + const newMap = new Map(state.processedMessageIds); + for (const id of idsToTrack) { + newMap.set(id, Date.now()); + } + return { processedMessageIds: newMap }; + }); } store.getState().addMessage(newMessage); } @@ -560,12 +561,13 @@ export function registerMessageHandlers(store: StoreApi): void { ? [mtags.msgid] : []; if (idsToTrack.length > 0) { - store.setState((state) => ({ - processedMessageIds: new Set([ - ...state.processedMessageIds, - ...idsToTrack, - ]), - })); + store.setState((state) => { + const newMap = new Map(state.processedMessageIds); + for (const id of idsToTrack) { + newMap.set(id, Date.now()); + } + return { processedMessageIds: newMap }; + }); } store.getState().addMessage(newMessage); @@ -701,12 +703,11 @@ export function registerMessageHandlers(store: StoreApi): void { // Mark this message ID as processed to prevent duplicates if (mtags?.msgid) { - store.setState((state) => ({ - processedMessageIds: new Set([ - ...state.processedMessageIds, - mtags.msgid, - ]), - })); + store.setState((state) => { + const newMap = new Map(state.processedMessageIds); + newMap.set(mtags.msgid, Date.now()); + return { processedMessageIds: newMap }; + }); } store.getState().addMessage(newMessage); @@ -841,12 +842,11 @@ export function registerMessageHandlers(store: StoreApi): void { // Mark this message ID as processed to prevent duplicates if (mtags?.msgid) { - store.setState((state) => ({ - processedMessageIds: new Set([ - ...state.processedMessageIds, - mtags.msgid, - ]), - })); + store.setState((state) => { + const newMap = new Map(state.processedMessageIds); + newMap.set(mtags.msgid, Date.now()); + return { processedMessageIds: newMap }; + }); } // If the stored username casing differs from the server-sent nick, correct it now. @@ -1077,12 +1077,11 @@ export function registerMessageHandlers(store: StoreApi): void { // Mark this message ID as processed to prevent duplicates if (mtags?.msgid) { - store.setState((state) => ({ - processedMessageIds: new Set([ - ...state.processedMessageIds, - mtags.msgid, - ]), - })); + store.setState((state) => { + const newMap = new Map(state.processedMessageIds); + newMap.set(mtags.msgid, Date.now()); + return { processedMessageIds: newMap }; + }); } store.getState().addMessage(newMessage); @@ -1237,12 +1236,11 @@ export function registerMessageHandlers(store: StoreApi): void { // Mark this message ID as processed to prevent duplicates if (mtags?.msgid) { - store.setState((state) => ({ - processedMessageIds: new Set([ - ...state.processedMessageIds, - mtags.msgid, - ]), - })); + store.setState((state) => { + const newMap = new Map(state.processedMessageIds); + newMap.set(mtags.msgid, Date.now()); + return { processedMessageIds: newMap }; + }); } store.getState().addMessage(newMessage); diff --git a/src/store/handlers/users.ts b/src/store/handlers/users.ts index 28887f48..060efa84 100644 --- a/src/store/handlers/users.ts +++ b/src/store/handlers/users.ts @@ -1,7 +1,7 @@ import { v4 as uuidv4 } from "uuid"; import type { StoreApi } from "zustand"; import ircClient from "../../lib/ircClient"; -import type { Message, User } from "../../types"; +import type { Message } from "../../types"; import { generateDeterministicId, getCurrentSelection, @@ -146,45 +146,26 @@ export function registerUserHandlers(store: StoreApi): void { }); // Fall through to shared message creation below — same JOIN event, same path. } else { - store.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id !== serverId) return server; - - // Restore metadata so live joins show avatars immediately. - const savedMetadata = storage.metadata.load(); - const serverMetadata = savedMetadata[serverId]; - const joinedUserMetadata = resolveUserMetadata( - username, - serverMetadata, - server.channels, - ); - - const updatedChannels = server.channels.map((channel) => { - if (channel.name.toLowerCase() !== channelName.toLowerCase()) - return channel; - - const alreadyIn = channel.users.some( - (u) => u.username.toLowerCase() === username.toLowerCase(), - ); - if (alreadyIn) return channel; - - const newUser: User = { - id: uuidv4(), - username, - isOnline: true, - account: account || undefined, - realname: realname || undefined, - metadata: joinedUserMetadata, - }; - - return { ...channel, users: [...channel.users, newUser] }; - }); + const state = store.getState(); + const server = state.servers.find((s) => s.id === serverId); + if (server) { + // Restore metadata so live joins show avatars immediately. + const savedMetadata = storage.metadata.load(); + const serverMetadata = savedMetadata[serverId]; + const joinedUserMetadata = resolveUserMetadata( + username, + serverMetadata, + server.channels, + ); - return { ...server, channels: updatedChannels }; + state.updateGlobalUser(serverId, { + username, + account: account || undefined, + realname: realname || undefined, + metadata: joinedUserMetadata, + isOnline: true, }); - - return { servers: updatedServers }; - }); + } } const state = store.getState(); @@ -220,7 +201,6 @@ export function registerUserHandlers(store: StoreApi): void { const state = store.getState(); const batch = state.activeBatches[serverId]?.[batchTag]; if (batch?.type === "chathistory") { - // Historical nick change from event-playback — create a message record, skip live mutation. if ( state.globalSettings.showEvents && state.globalSettings.showNickChanges @@ -257,48 +237,10 @@ export function registerUserHandlers(store: StoreApi): void { return; } } - store.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - const updatedChannels = server.channels.map((channel) => { - const updatedUsers = channel.users.map((user) => { - if (user.username === oldNick) { - return { ...user, username: newNick }; - } - return user; - }); - return { ...channel, users: updatedUsers }; - }); - return { ...server, channels: updatedChannels }; - } - return server; - }); - - // Update currentUser only if this nick change is for the currently selected server - // and it's our own nick that changed - let updatedCurrentUser = state.currentUser; - const isSelectedServer = state.ui.selectedServerId === serverId; - const serverCurrentUser = ircClient.getCurrentUser(serverId); - const isOurNick = - serverCurrentUser?.username === oldNick || - serverCurrentUser?.username === newNick; - - if ( - isSelectedServer && - isOurNick && - state.currentUser && - state.currentUser.username === oldNick - ) { - updatedCurrentUser = { ...state.currentUser, username: newNick }; - } - - return { - servers: updatedServers, - currentUser: updatedCurrentUser, - }; - }); const state = store.getState(); + state.changeGlobalNick(serverId, oldNick, newNick); + const server = state.servers.find((s) => s.id === serverId); if ( server && @@ -323,7 +265,7 @@ export function registerUserHandlers(store: StoreApi): void { makeEventMessage( "nick", nickContent, - oldNick, // userId is old nick so the message avatar/link resolves correctly + oldNick, channel.id, serverId, new Date(), @@ -331,43 +273,18 @@ export function registerUserHandlers(store: StoreApi): void { ); } }); + } - const privateChat = server.privateChats?.find( - (pc) => - pc.username.toLowerCase() === oldNick.toLowerCase() || - pc.username.toLowerCase() === newNick.toLowerCase(), - ); - if (privateChat) { - store.setState((state) => { - const updatedServers = state.servers.map((s) => { - if (s.id === serverId) { - const updatedPrivateChats = s.privateChats?.map((pc) => { - if (pc.username.toLowerCase() === oldNick.toLowerCase()) { - return { ...pc, username: newNick }; - } - return pc; - }); - return { ...s, privateChats: updatedPrivateChats }; - } - return s; - }); - return { servers: updatedServers }; - }); - - appendMessage( - store, - serverId, - privateChat.id, - makeEventMessage( - "nick", - nickContent, - oldNick, - privateChat.id, - serverId, - new Date(), - ), - ); - } + // Update currentUser if it's our nick + if ( + state.ui.selectedServerId === serverId && + state.currentUser?.username === oldNick + ) { + store.setState((s) => ({ + currentUser: s.currentUser + ? { ...s.currentUser, username: newNick } + : null, + })); } }); diff --git a/src/store/index.ts b/src/store/index.ts index 7ef1cff3..2bb61ef4 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -450,6 +450,7 @@ export interface AppState { selectedServerId: string | null; connectionError: string | null; messages: Record; + globalUsers: Record; // serverId-username -> User (Normalized identity store) typingUsers: Record; typingTimers: Record>; globalNotifications: { @@ -527,7 +528,8 @@ export interface AppState { // Channel order persistence channelOrder: ChannelOrderMap; // serverId -> ordered array of channel names // Message deduplication tracking - processedMessageIds: Set; // Set of msgid values that have already been processed + processedMessageIds: Map; // msgid -> timestamp of processing + processedMessageCleanupTimer?: NodeJS.Timeout; // Auto-connect prevention hasConnectedToSavedServers: boolean; // UI state @@ -631,7 +633,17 @@ export interface AppState { ) => void; setName: (serverId: string, realname: string) => void; changeNick: (serverId: string, newNick: string) => void; + changeGlobalNick: ( + serverId: string, + oldNick: string, + newNick: string, + ) => void; addMessage: (message: Message) => void; + updateGlobalUser: ( + serverId: string, + user: Partial & { username: string }, + ) => void; + pruneProcessedMessageIds: () => void; addGlobalNotification: (notification: { type: "fail" | "warn" | "note"; command: string; @@ -846,9 +858,10 @@ const useStore = create((set, get) => ({ whoisData: {}, pendingRegistration: null, channelOrder: loadChannelOrder(), - processedMessageIds: new Set(), + processedMessageIds: new Map(), hasConnectedToSavedServers: false, selectedServerId: null, + globalUsers: {}, // UI state ui: { @@ -1464,54 +1477,169 @@ const useStore = create((set, get) => ({ ircClient.changeNick(serverId, newNick); }, + changeGlobalNick: (serverId, oldNick, newNick) => { + set((state) => { + const oldKey = `${serverId}-${oldNick.toLowerCase()}`; + const newKey = `${serverId}-${newNick.toLowerCase()}`; + const user = state.globalUsers[oldKey]; + + if (!user) return {}; + + const { [oldKey]: _, ...remainingUsers } = state.globalUsers; + const updatedUser = { ...user, username: newNick }; + + // Propagate to channel lists + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + return { + ...s, + channels: s.channels.map((ch) => ({ + ...ch, + users: ch.users.map((u) => + u.username.toLowerCase() === oldNick.toLowerCase() + ? { ...u, username: newNick } + : u, + ), + })), + }; + } + return s; + }); + + return { + globalUsers: { ...remainingUsers, [newKey]: updatedUser }, + servers: updatedServers, + }; + }); + }, + addMessage: (message) => { set((state) => { const channelKey = `${message.serverId}-${message.channelId}`; - const currentMessages = state.messages[channelKey] || []; + const existing = state.messages[channelKey] || []; + + // Check for duplicates using the optimized Map-based tracking + if (message.msgid && state.processedMessageIds.has(message.msgid)) { + return state; + } - // Check for duplicate messages (same id, or same content/timestamp/user) - const isDuplicate = currentMessages.some((existingMessage) => { - return ( - existingMessage.id === message.id || - (existingMessage.content === message.content && + // Fallback for messages without msgid (basic content duplication check) + if (!message.msgid) { + const isDuplicate = existing.some((existingMessage) => { + return ( + existingMessage.content === message.content && new Date(existingMessage.timestamp).getTime() === new Date(message.timestamp).getTime() && - existingMessage.userId === message.userId) - ); + existingMessage.userId === message.userId + ); + }); + if (isDuplicate) return state; + } + + const updated = [...existing, message].sort((a, b) => { + const tA = new Date(a.timestamp).getTime(); + const tB = new Date(b.timestamp).getTime(); + return tA - tB; }); - if (isDuplicate) { - return state; // Don't add duplicate message + // Enforce global history limit + const pruned = updated.slice(-MAX_MESSAGES_PER_CHANNEL); + + const nextMessages = { ...state.messages, [channelKey]: pruned }; + const nextProcessed = new Map(state.processedMessageIds); + + // Track this msgid with current timestamp for TTL pruning + if (message.msgid) { + nextProcessed.set(message.msgid, Date.now()); + } + if (message.multilineMessageIds) { + for (const id of message.multilineMessageIds) { + nextProcessed.set(id, Date.now()); + } + } + + // Initialize pruning timer (runs once an hour) + if (!state.processedMessageCleanupTimer) { + const timer = setInterval( + () => { + get().pruneProcessedMessageIds(); + }, + 60 * 60 * 1000, + ) as unknown as NodeJS.Timeout; + return { + messages: nextMessages, + processedMessageIds: nextProcessed, + processedMessageCleanupTimer: timer, + }; } - // Add message and sort chronologically by timestamp - const updatedMessages = [...currentMessages, message].sort((a, b) => { - const timeA = - a.timestamp instanceof Date - ? a.timestamp.getTime() - : new Date(a.timestamp).getTime(); - const timeB = - b.timestamp instanceof Date - ? b.timestamp.getTime() - : new Date(b.timestamp).getTime(); - return timeA - timeB; - }); + return { + messages: nextMessages, + processedMessageIds: nextProcessed, + }; + }); + }, + + updateGlobalUser: (serverId, user) => { + set((state) => { + const key = `${serverId}-${user.username.toLowerCase()}`; + const existing = state.globalUsers[key]; + const updated = { + ...(existing || { + id: uuidv4(), + username: user.username, + isOnline: true, + metadata: {}, + }), + ...user, + }; - // Cap per-channel history to prevent unbounded memory growth in long sessions. - const cappedMessages = - updatedMessages.length > MAX_MESSAGES_PER_CHANNEL - ? updatedMessages.slice(-MAX_MESSAGES_PER_CHANNEL) - : updatedMessages; + // Propagate changes to all views for immediate UI reactivity + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + return { + ...s, + channels: s.channels.map((ch) => ({ + ...ch, + users: ch.users.map((u) => + u.username.toLowerCase() === user.username.toLowerCase() + ? { ...u, ...user } + : u, + ), + })), + privateChats: s.privateChats?.map((pc) => + pc.username.toLowerCase() === user.username.toLowerCase() + ? { ...pc, ...user } + : pc, + ), + }; + } + return s; + }); return { - messages: { - ...state.messages, - [channelKey]: cappedMessages, - }, + globalUsers: { ...state.globalUsers, [key]: updated }, + servers: updatedServers, }; }); }, + pruneProcessedMessageIds: () => { + set((state) => { + const now = Date.now(); + const TTL = 24 * 60 * 60 * 1000; // 24 hours + const nextMap = new Map(); + + for (const [id, ts] of state.processedMessageIds.entries()) { + if (now - ts < TTL) { + nextMap.set(id, ts); + } + } + + return { processedMessageIds: nextMap }; + }); + }, + addGlobalNotification: (notification) => { set((state) => ({ globalNotifications: [ diff --git a/tests/store/multilineDedup.test.ts b/tests/store/multilineDedup.test.ts index fd231648..fde5a8b1 100644 --- a/tests/store/multilineDedup.test.ts +++ b/tests/store/multilineDedup.test.ts @@ -23,7 +23,7 @@ describe("Multiline message deduplication", () => { // Reset messages and processedMessageIds useStore.setState({ messages: {}, - processedMessageIds: new Set(), + processedMessageIds: new Map(), }); }); @@ -87,12 +87,13 @@ describe("Multiline message deduplication", () => { const idsToTrack = messageIds.length > 0 ? messageIds : mtags?.msgid ? [mtags.msgid] : []; - useStore.setState((state) => ({ - processedMessageIds: new Set([ - ...state.processedMessageIds, - ...idsToTrack, - ]), - })); + useStore.setState((state) => { + const newMap = new Map(state.processedMessageIds); + for (const id of idsToTrack) { + newMap.set(id, Date.now()); + } + return { processedMessageIds: newMap }; + }); const state = useStore.getState(); expect(state.processedMessageIds.has(batchMsgId)).toBe(true); @@ -102,12 +103,11 @@ describe("Multiline message deduplication", () => { const batchMsgId = "dmsurc3xgc5v3nufdga8ag2xnw"; // First call: track the batch msgid - useStore.setState((state) => ({ - processedMessageIds: new Set([ - ...state.processedMessageIds, - batchMsgId, - ]), - })); + useStore.setState((state) => { + const newMap = new Map(state.processedMessageIds); + newMap.set(batchMsgId, Date.now()); + return { processedMessageIds: newMap }; + }); // Second call: check dedup const currentState = useStore.getState(); @@ -166,12 +166,13 @@ describe("Multiline message deduplication", () => { const messageIds = ["dm-msg-1", "dm-msg-2"]; const idsToTrack = messageIds.length > 0 ? messageIds : [batchMsgId]; - useStore.setState((state) => ({ - processedMessageIds: new Set([ - ...state.processedMessageIds, - ...idsToTrack, - ]), - })); + useStore.setState((state) => { + const newMap = new Map(state.processedMessageIds); + for (const id of idsToTrack) { + newMap.set(id, Date.now()); + } + return { processedMessageIds: newMap }; + }); addMessage( makeDmMessage({ @@ -201,9 +202,13 @@ describe("Multiline message deduplication", () => { ); expect(shouldSkip1).toBe(false); - useStore.setState((s) => ({ - processedMessageIds: new Set([...s.processedMessageIds, ...messageIds]), - })); + useStore.setState((s) => { + const newMap = new Map(s.processedMessageIds); + for (const id of messageIds) { + newMap.set(id, Date.now()); + } + return { processedMessageIds: newMap }; + }); addMessage( makeDmMessage({ id: "dm-stored-1", @@ -289,12 +294,13 @@ describe("Multiline message deduplication", () => { ? [mtags.msgid] : []; if (idsToTrack1.length > 0) { - useStore.setState((s) => ({ - processedMessageIds: new Set([ - ...s.processedMessageIds, - ...idsToTrack1, - ]), - })); + useStore.setState((s) => { + const newMap = new Map(s.processedMessageIds); + for (const id of idsToTrack1) { + newMap.set(id, Date.now()); + } + return { processedMessageIds: newMap }; + }); } addMessage(makeMessage({ id: "msg-1", msgid: batchMsgId })); From ab2d4bd5f7f2d76bb7c75c6e13bf1b2fabebad61 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 16 Apr 2026 09:07:43 -0500 Subject: [PATCH 2/2] Aesthetic hardening: Inter font, glassmorphism, and interactive micro-animations across layout --- index.html | 2 +- src/components/layout/AppLayout.tsx | 12 ++++++------ src/components/layout/ChannelList.tsx | 8 ++++---- src/components/layout/ChannelMessageList.tsx | 2 +- src/components/layout/ChatArea.tsx | 8 ++++---- src/components/layout/ChatHeader.tsx | 8 ++++---- src/components/layout/MemberList.tsx | 2 +- src/components/layout/ServerList.tsx | 12 +++++++----- src/index.css | 12 ++++++++++-- tailwind.config.js | 2 +- 10 files changed, 39 insertions(+), 29 deletions(-) diff --git a/index.html b/index.html index 2ddad5a2..991b09fd 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,7 @@ - + diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index d5f01258..ea315703 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -159,11 +159,11 @@ export const AppLayout: React.FC = () => { return (
{__HIDE_SERVER_LIST__ ? null : ( -
+
)} -
+
toggleChannelList(!isChannelListVisible)} /> @@ -174,7 +174,7 @@ export const AppLayout: React.FC = () => { return ( <> {__HIDE_SERVER_LIST__ ? null : ( -
+
)} @@ -189,7 +189,7 @@ export const AppLayout: React.FC = () => { onMinReached={() => toggleChannelList(false)} onWidthChange={handleChannelListWidthChange} > -
+
toggleChannelList(!isChannelListVisible)} /> @@ -217,7 +217,7 @@ export const AppLayout: React.FC = () => { case "memberList": if (isNarrowView) { return ( -
+
); @@ -234,7 +234,7 @@ export const AppLayout: React.FC = () => { onMinReached={() => toggleMemberList(false)} onWidthChange={handleMemberListWidthChange} > -
+
diff --git a/src/components/layout/ChannelList.tsx b/src/components/layout/ChannelList.tsx index fbd63b77..6db2ea3d 100644 --- a/src/components/layout/ChannelList.tsx +++ b/src/components/layout/ChannelList.tsx @@ -408,7 +408,7 @@ export const ChannelList: React.FC<{ return (
{/* Server header */} -
+

{selectedServer?.networkName || selectedServer?.name || "Home"} @@ -477,7 +477,7 @@ export const ChannelList: React.FC<{ {/* Add Channel Input */} {newChannelName !== "" && (
-
+
@@ -551,8 +551,8 @@ export const ChannelList: React.FC<{ shadow-sm ${ selectedChannelId === channel.id - ? "bg-black text-white" - : `bg-discord-dark-400/50 ${hoverPrimary}` + ? "bg-black/40 text-white shadow-lg shadow-black/20" + : `glass ${hoverPrimary}` } ${ prevItemId === channel.id && diff --git a/src/components/layout/ChannelMessageList.tsx b/src/components/layout/ChannelMessageList.tsx index 90d0618d..392aa4ab 100644 --- a/src/components/layout/ChannelMessageList.tsx +++ b/src/components/layout/ChannelMessageList.tsx @@ -323,7 +323,7 @@ export const ChannelMessageList = forwardRef< serverId, filteredMessages.length, onClearSearch, - channel.name, + channel?.name, channelMessages[0], channel?.hasMoreHistory, channel, diff --git a/src/components/layout/ChatArea.tsx b/src/components/layout/ChatArea.tsx index 2509eda7..9d23a164 100644 --- a/src/components/layout/ChatArea.tsx +++ b/src/components/layout/ChatArea.tsx @@ -1839,7 +1839,7 @@ export const ChatArea: React.FC<{ {/* Member list overlay replaces messages when desktop is too narrow for sidebar */} {showMemberListOverlay && ( -
+
)} @@ -1851,7 +1851,7 @@ export const ChatArea: React.FC<{ !selectedChannel && !selectedPrivateChat && selectedChannelId !== "server-notices" && ( -
+
)} @@ -1938,7 +1938,7 @@ export const ChatArea: React.FC<{ channelId={selectedChannelId || selectedPrivateChatId || ""} />