From c712c1a997ee8482c38bc784418649d25afb5fcb Mon Sep 17 00:00:00 2001 From: BP602 Date: Thu, 2 Oct 2025 05:27:02 +0200 Subject: [PATCH] feat(chat): add scroll placeholder sensitivity control and optimize Message component - Add configurable scroll placeholder sensitivity slider in settings (0-2000, default 300) - Allow users to disable placeholders entirely by setting sensitivity to 0 - Optimize Message component with React.memo to prevent unnecessary re-renders - Convert event handlers to useCallback for stable references - Memoize userStyle, isSupportEvent, and shouldHighlightMessage calculations - Move badge calculations (kickTalkBadges, donatorBadges) to MessagesHandler for better performance - Reorganize imports and simplify conditional expressions - Add DEFAULT_SCROLL_SEEK_VELOCITY constant --- .../Dialogs/Settings/Sections/General.jsx | 60 ++++- .../src/components/Messages/Message.jsx | 217 +++++++++--------- .../components/Messages/MessagesHandler.jsx | 164 +++++++++++-- utils/constants.js | 1 + 4 files changed, 304 insertions(+), 138 deletions(-) diff --git a/src/renderer/src/components/Dialogs/Settings/Sections/General.jsx b/src/renderer/src/components/Dialogs/Settings/Sections/General.jsx index 8b52dae..d82d2e6 100644 --- a/src/renderer/src/components/Dialogs/Settings/Sections/General.jsx +++ b/src/renderer/src/components/Dialogs/Settings/Sections/General.jsx @@ -7,7 +7,7 @@ import { InfoIcon, CaretDownIcon, FolderOpen } from "@phosphor-icons/react"; import ColorPicker from "../../../Shared/ColorPicker"; import NotificationFilePicker from "../../../Shared/NotificationFilePicker"; import clsx from "clsx"; -import { DEFAULT_CHAT_HISTORY_LENGTH } from "@utils/constants"; +import { DEFAULT_CHAT_HISTORY_LENGTH, DEFAULT_SCROLL_SEEK_VELOCITY } from "@utils/constants"; const GeneralSection = ({ settingsData, onChange }) => { return ( @@ -314,6 +314,9 @@ const GeneralSection = ({ settingsData, onChange }) => { }; const ChatroomSection = ({ settingsData, onChange }) => { + const scrollSeekVelocity = + settingsData?.chatrooms?.scrollSeekVelocityThreshold ?? DEFAULT_SCROLL_SEEK_VELOCITY; + return (
@@ -389,6 +392,61 @@ const ChatroomSection = ({ settingsData, onChange }) => { />
+
+
+
+ + Scroll Placeholder Sensitivity + + {scrollSeekVelocity === 0 ? "Off" : `${scrollSeekVelocity}`} + + + + + + + +

+ Adjust how quickly chat switches to lightweight placeholders while scrolling. Set to 0 to + disable placeholders entirely. +

+
+
+
+ + { + if (!value.length) return; + + const nextValue = Number(value[0]); + if (!Number.isFinite(nextValue)) return; + + const clampedValue = Math.max(0, Math.min(Math.round(nextValue), 2000)); + + onChange("chatrooms", { + ...settingsData?.chatrooms, + scrollSeekVelocityThreshold: clampedValue, + }); + }} + /> +
+
{ const messageRef = useRef(null); const getDeleteMessage = useChatStore(useShallow((state) => state.getDeleteMessage)); + const getUserStyle = useCosmeticsStore(useShallow((state) => state.getUserStyle)); const [rightClickedEmote, setRightClickedEmote] = useState(null); - let userStyle; + const userStyle = useMemo(() => { + if (!message?.sender || type === "replyThread") { + return undefined; + } - if (message?.sender && type !== "replyThread") { if (type === "dialog") { - userStyle = dialogUserStyle; - } else { - userStyle = useCosmeticsStore(useShallow((state) => state.getUserStyle(message?.sender?.username))); + return dialogUserStyle; } - } - // CheckIcon if user can moderate + if (!getUserStyle) { + return undefined; + } + + return getUserStyle(message?.sender?.username); + }, [dialogUserStyle, getUserStyle, message?.sender, type]); + const canModerate = useMemo( () => userChatroomInfo?.is_broadcaster || userChatroomInfo?.is_moderator || userChatroomInfo?.is_super_admin, [userChatroomInfo], ); const handleOpenUserDialog = useCallback( - async (e, username) => { + async (e, lookupUsername) => { e.preventDefault(); - if (username) { - const user = await window.app.kick.getUserChatroomInfo(chatroomName, username); + if (lookupUsername) { + const user = await window.app.kick.getUserChatroomInfo(chatroomName, lookupUsername); if (!user?.data?.id) return; @@ -94,7 +102,7 @@ const Message = ({ }); } }, - [message?.sender, userChatroomInfo, chatroomId, userStyle, subscriberBadges, allStvEmotes, username], + [allStvEmotes, chatroomId, chatroomName, message?.sender, subscriberBadges, userChatroomInfo, userStyle, username], ); const rgbaObjectToString = (rgba) => { @@ -118,75 +126,73 @@ const Message = ({ }, [message?.metadata]); const eventType = message?.type === "metadata" ? parsedMetadata?.type : message?.type; - const isSupportEvent = [ - "subscription", - "donation", - "reward", - "stream_live", - "stream_end", - "moderation", - "host", - "raid", - "goal_progress", - "kick_gift", - ].includes(eventType); - - // Remove useCallback for these since message changes constantly - const handleCopyMessage = () => { + const isSupportEvent = useMemo( + () => + [ + "subscription", + "donation", + "reward", + "stream_live", + "stream_end", + "moderation", + "host", + "raid", + "goal_progress", + "kick_gift", + ].includes(eventType), + [eventType], + ); + + const handleCopyMessage = useCallback(() => { if (message?.content) { navigator.clipboard.writeText(message.content); } - }; + }, [message?.content]); - const handleReply = () => { + const handleReply = useCallback(() => { window.app.reply.open(message); - }; + }, [message]); - const handlePinMessage = () => { + const handlePinMessage = useCallback(() => { const data = { chatroom_id: message.chatroom_id, content: message.content, id: message.id, sender: message.sender, - chatroomName: chatroomName, + chatroomName, }; window.app.kick.getPinMessage(data); - }; + }, [chatroomName, message]); - const handleDeleteMessage = () => { + const handleDeleteMessage = useCallback(() => { getDeleteMessage(chatroomId, message.id); - }; + }, [chatroomId, getDeleteMessage, message.id]); - const handleViewProfile = () => { + const handleViewProfile = useCallback(() => { if (message?.sender?.username) { const profileSlug = message.sender.slug || message.sender.username; window.open(`https://kick.com/${profileSlug}`, "_blank"); } - }; + }, [message?.sender]); - - const handleOpenEmoteLink = () => { + const handleOpenEmoteLink = useCallback(() => { if (rightClickedEmote) { - let emoteUrl = ""; - if (rightClickedEmote.type === "stv") { - emoteUrl = `https://7tv.app/emotes/${rightClickedEmote.id}`; - } else { - emoteUrl = `https://files.kick.com/emotes/${rightClickedEmote.id}/fullsize`; - } + const emoteUrl = + rightClickedEmote.type === "stv" + ? `https://7tv.app/emotes/${rightClickedEmote.id}` + : `https://files.kick.com/emotes/${rightClickedEmote.id}/fullsize`; window.open(emoteUrl, "_blank"); } - }; + }, [rightClickedEmote]); const handleOpen7TVEmoteLink = useCallback( (resolution) => { if (rightClickedEmote && rightClickedEmote.type === "stv") { - let emoteUrl = ""; - if (resolution === "page") { - emoteUrl = `https://7tv.app/emotes/${rightClickedEmote.id}`; - } else { - emoteUrl = `https://cdn.7tv.app/emote/${rightClickedEmote.id}/${resolution}.webp`; - } + const emoteUrl = + resolution === "page" + ? `https://7tv.app/emotes/${rightClickedEmote.id}` + : `https://cdn.7tv.app/emote/${rightClickedEmote.id}/${resolution}.webp`; window.open(emoteUrl, "_blank"); } @@ -194,10 +200,10 @@ const Message = ({ [rightClickedEmote], ); - // Handle context menu on the message to detect emote right-clicks const handleMessageContextMenu = useCallback((e) => { setRightClickedEmote(null); let emoteImg = null; + if (e.target.tagName === "IMG" && e.target.className.includes("emote")) { emoteImg = e.target; } else if (e.target.className.includes("chatroomEmote")) { @@ -208,56 +214,25 @@ const Message = ({ const alt = emoteImg.getAttribute("alt"); const src = emoteImg.getAttribute("src"); - let emoteData = null; if (src.includes("7tv.app")) { const match = src.match(/\/emote\/([^/]+)\//); if (match) { - emoteData = { - id: match[1], - name: alt, - type: "stv", - }; + setRightClickedEmote({ id: match[1], name: alt, type: "stv" }); } } else if (src.includes("kick.com/emotes")) { const match = src.match(/\/emotes\/([^/]+)/); if (match) { - emoteData = { - id: match[1], - name: alt, - type: "kick", - }; + setRightClickedEmote({ id: match[1], name: alt, type: "kick" }); } } - - if (emoteData) { - setRightClickedEmote(emoteData); - } } }, []); - // Get existing KickTalk badges (Founder, Beta Tester, etc.) - const kickTalkBadges = - existingKickTalkBadges?.find((badge) => badge.username.toLowerCase() === message?.sender?.username?.toLowerCase())?.badges || - []; - - // CheckIcon if user is a donator - const donatorBadges = useMemo(() => { - if (!message?.sender?.username) return []; - - const donator = donators?.find((d) => d.message?.toLowerCase() === message?.sender?.username?.toLowerCase()); - if (donator) { - return [ - { - type: "Donator", - title: "KickTalk Donator", - }, - ]; - } - return []; - }, [message?.sender?.username, donators]); - const showContextMenu = - !message?.deleted && message?.type !== "system" && message?.type !== "stvEmoteSetUpdate" && message?.type !== "mod_action"; + !message?.deleted && + message?.type !== "system" && + message?.type !== "stvEmoteSetUpdate" && + message?.type !== "mod_action"; const handleOpenReplyThread = useCallback( async (chatStoreMessageThread) => { @@ -284,33 +259,26 @@ const Message = ({ settings, }); }, - [chatroomId, message, userChatroomInfo, chatroomName, allStvEmotes, subscriberBadges, settings, username], + [allStvEmotes, chatroomId, chatroomName, message, settings, subscriberBadges, userChatroomInfo, username], ); - // [Highlights]: Memoized mention regex for current user const mentionRegex = useMemo(() => createMentionRegex(username), [username]); - // [Highlights]: Handles highlighting message phrases const shouldHighlightMessage = useMemo(() => { - // Only skip in dialog view; background toggle controls whether we apply highlight style if (type === "dialog") { return false; } - // Don't highlight your own messages (including replies) if (message?.sender?.slug === username) { return false; } - // CheckIcon for self-mention in replies if (settings?.notifications?.background) { if (message?.metadata?.original_sender?.id == userId && message?.sender?.id != userId) { return true; } } - // CheckIcon for direct @mention of current user (case-insensitive), - // treating '-' and '_' as interchangeable and enforcing boundaries if (settings?.notifications?.background && mentionRegex) { const content = message?.content?.toLowerCase() || ""; if (mentionRegex.test(content)) { @@ -318,23 +286,24 @@ const Message = ({ } } - // CheckIcon for highlight phrases (only if configured and background highlighting enabled) if (settings?.notifications?.background && settings?.notifications?.phrases?.length) { return settings.notifications.phrases.some((phrase) => message?.content?.toLowerCase().includes(phrase.toLowerCase()), ); } + return false; }, [ - settings?.notifications?.background, - settings?.notifications?.phrases, + mentionRegex, message?.content, - message?.sender?.slug, - message?.sender?.id, message?.metadata?.original_sender?.id, + message?.sender?.id, + message?.sender?.slug, + settings?.notifications?.background, + settings?.notifications?.phrases, type, - username, userId, + username, ]); const messageContent = ( @@ -415,7 +384,9 @@ const Message = ({ /> )} - {isSupportEvent && } + {isSupportEvent && ( + + )}
); @@ -497,4 +468,22 @@ const Message = ({ return messageContent; }; -export default Message; +const areEqual = (prevProps, nextProps) => { + if (prevProps.message !== nextProps.message) return false; + if (prevProps.chatroomId !== nextProps.chatroomId) return false; + if (prevProps.chatroomName !== nextProps.chatroomName) return false; + if (prevProps.subscriberBadges !== nextProps.subscriberBadges) return false; + if (prevProps.allStvEmotes !== nextProps.allStvEmotes) return false; + if (prevProps.kickTalkBadges !== nextProps.kickTalkBadges) return false; + if (prevProps.donatorBadges !== nextProps.donatorBadges) return false; + if (prevProps.settings !== nextProps.settings) return false; + if (prevProps.userChatroomInfo !== nextProps.userChatroomInfo) return false; + if (prevProps.username !== nextProps.username) return false; + if (prevProps.userId !== nextProps.userId) return false; + if (prevProps.type !== nextProps.type) return false; + if (prevProps.dialogUserStyle !== nextProps.dialogUserStyle) return false; + + return true; +}; + +export default memo(MessageComponent, areEqual); diff --git a/src/renderer/src/components/Messages/MessagesHandler.jsx b/src/renderer/src/components/Messages/MessagesHandler.jsx index 40704ec..9cd64a1 100644 --- a/src/renderer/src/components/Messages/MessagesHandler.jsx +++ b/src/renderer/src/components/Messages/MessagesHandler.jsx @@ -3,6 +3,55 @@ import { Virtuoso } from "react-virtuoso"; import useChatStore from "../../providers/ChatProvider"; import Message from "./Message"; import { MouseScroll } from "@phosphor-icons/react"; +import { DEFAULT_SCROLL_SEEK_VELOCITY } from "@utils/constants"; + +const DONATOR_BADGE = Object.freeze([ + { + type: "Donator", + title: "KickTalk Donator", + }, +]); + +const ScrollSeekPlaceholder = ({ height = 48 }) => ( +
+
+
+
+
+
+
+); const MessagesHandler = memo( ({ @@ -96,6 +145,58 @@ const MessagesHandler = memo( }); }, [messages, chatroomId, silencedUserIds, eventVisibility, showModActions]); + const kickTalkBadgeMap = useMemo(() => { + if (!Array.isArray(kickTalkBadges)) return new Map(); + + const map = new Map(); + kickTalkBadges.forEach((entry) => { + if (!entry?.username) return; + map.set(entry.username.toLowerCase(), entry?.badges || []); + }); + + return map; + }, [kickTalkBadges]); + + const donatorBadgeMap = useMemo(() => { + if (!Array.isArray(donators)) return new Map(); + + const map = new Map(); + donators.forEach((entry) => { + const label = entry?.message; + if (!label) return; + map.set(label.toLowerCase(), DONATOR_BADGE); + }); + + return map; + }, [donators]); + + const scrollSeekVelocityThreshold = settings?.chatrooms?.scrollSeekVelocityThreshold; + + const resolvedScrollSeekVelocity = useMemo(() => { + if (typeof scrollSeekVelocityThreshold === "number") { + return Math.max(0, Math.min(scrollSeekVelocityThreshold, 2000)); + } + return DEFAULT_SCROLL_SEEK_VELOCITY; + }, [scrollSeekVelocityThreshold]); + + const scrollSeekConfiguration = useMemo(() => { + if (!resolvedScrollSeekVelocity) { + return null; + } + + const exitThreshold = Math.max(Math.floor(resolvedScrollSeekVelocity * 0.6), 60); + + return { + enter: (velocity) => Math.abs(velocity) > resolvedScrollSeekVelocity, + exit: (velocity) => Math.abs(velocity) < exitThreshold, + }; + }, [resolvedScrollSeekVelocity]); + + const virtuosoComponents = useMemo( + () => (scrollSeekConfiguration ? { ScrollSeekPlaceholder } : {}), + [scrollSeekConfiguration], + ); + useEffect(() => { if (filteredMessages.length > 0 && !isPaused) { virtuosoRef.current?.scrollToIndex({ @@ -150,24 +251,41 @@ const MessagesHandler = memo( }; const itemContent = useCallback( - (index, message) => ( - - ), - [chatroomId, slug, subscriberBadges, allStvEmotes, kickTalkBadges, settings, userChatroomInfo, username, userId, donators], + (index, message) => { + const usernameKey = message?.sender?.username?.toLowerCase(); + const messageKickTalkBadges = usernameKey ? kickTalkBadgeMap.get(usernameKey) : undefined; + const messageDonatorBadges = usernameKey ? donatorBadgeMap.get(usernameKey) : undefined; + + return ( + + ); + }, + [ + chatroomId, + slug, + subscriberBadges, + allStvEmotes, + settings, + userChatroomInfo, + username, + userId, + kickTalkBadgeMap, + donatorBadgeMap, + ], ); useEffect(() => { @@ -211,13 +329,13 @@ const MessagesHandler = memo( itemContent={itemContent} computeItemKey={computeItemKey} onScroll={handleScroll} - // followOutput={"auto"} + followOutput="smooth" initialTopMostItemIndex={filteredMessages?.length - 1} - // alignToBottom={true} - // atBottomStateChange={setAtBottom} atBottomThreshold={100} - overscan={20} - increaseViewportBy={200} + overscan={8} + increaseViewportBy={{ top: 0, bottom: 120 }} + scrollSeekConfiguration={scrollSeekConfiguration || undefined} + components={virtuosoComponents} defaultItemHeight={45} style={{ height: "100%", diff --git a/utils/constants.js b/utils/constants.js index e5ea0ef..80f4a1f 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -7,6 +7,7 @@ export const kickClipRegex = /^https?:\/\/(www\.)?kick\.com\/.*\/clips\/.*/i; // Chat settings export const DEFAULT_CHAT_HISTORY_LENGTH = 400; +export const DEFAULT_SCROLL_SEEK_VELOCITY = 700; const kickTalkCDN = "https://cdn.kicktalk.app";