From 06589779505c5602becf17ce7310c455f3dc27d0 Mon Sep 17 00:00:00 2001 From: haorwen Date: Sun, 5 Apr 2026 21:55:29 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=88=97=E8=A1=A8=E7=9A=84=E5=AE=9E=E7=8E=B0=E4=BB=A5?= =?UTF-8?q?=E8=A7=A3=E5=86=B3=E6=B6=88=E6=81=AF=E5=88=97=E8=A1=A8=E6=8A=96?= =?UTF-8?q?=E5=8A=A8=20(#325)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 重构消息列表的实现以解决消息列表抖动 * chore: bump version to v0.9.72 --- .gitignore | 1 + package.json | 3 +- pnpm-lock.yaml | 28 ++ src/components/Message/ContextMenu.tsx | 1 + src/components/Message/index.tsx | 1 + .../VirtualMessageFeed/CustomHeader.tsx | 16 +- .../Layout/VirtualMessageFeed/CustomList.tsx | 17 - .../chat/Layout/VirtualMessageFeed/index.tsx | 318 +++++++++--------- 8 files changed, 200 insertions(+), 185 deletions(-) delete mode 100644 src/routes/chat/Layout/VirtualMessageFeed/CustomList.tsx diff --git a/.gitignore b/.gitignore index 40db7a33..6181bf68 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ # production /build +/data /src-tauri/target # misc diff --git a/package.json b/package.json index bd654101..a9b1ce35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vocechat-web", - "version": "0.9.71", + "version": "0.9.72", "homepage": "https://voce.chat", "dependencies": { "@metamask/onboarding": "^1.0.1", @@ -80,6 +80,7 @@ "tailwind-merge": "2.5.4", "terser-webpack-plugin": "^5.3.14", "tippy.js": "^6.3.7", + "virtua": "^0.49.0", "wavesurfer.js": "^7.10.1", "webpack": "^5.101.0", "webpack-dev-server": "^4.15.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59f26feb..4ce8a5f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -247,6 +247,9 @@ importers: tippy.js: specifier: ^6.3.7 version: 6.3.7 + virtua: + specifier: ^0.49.0 + version: 0.49.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1) wavesurfer.js: specifier: ^7.10.1 version: 7.10.1 @@ -5932,6 +5935,26 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + virtua@0.49.0: + resolution: {integrity: sha512-yra9TIPqkVIn+LOjCZO8e6C6UsCiCIHJXt3yfnCP02r2sLpgr6mlispn3SHtWZv2aK2tuZLo6Sp2v6qmSrSVfw==} + peerDependencies: + react: '>=16.14.0' + react-dom: '>=16.14.0' + solid-js: '>=1.0' + svelte: '>=5.0' + vue: '>=3.2' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vue: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -12600,6 +12623,11 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + virtua@0.49.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + optionalDependencies: + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + void-elements@3.1.0: {} w3c-keyname@2.2.8: {} diff --git a/src/components/Message/ContextMenu.tsx b/src/components/Message/ContextMenu.tsx index 4d945d69..f89de06c 100644 --- a/src/components/Message/ContextMenu.tsx +++ b/src/components/Message/ContextMenu.tsx @@ -112,6 +112,7 @@ const MessageContextMenu: FC = ({ interactive placement="right-start" popperOptions={{ strategy: "fixed" }} + appendTo={() => document.body} onClickOutside={hide} key={mid} content={} diff --git a/src/components/Message/index.tsx b/src/components/Message/index.tsx index 2edc744a..38996a76 100644 --- a/src/components/Message/index.tsx +++ b/src/components/Message/index.tsx @@ -131,6 +131,7 @@ const Message: FC = ({ interactive placement="right" trigger="click" + appendTo={() => document.body} content={} >
diff --git a/src/routes/chat/Layout/VirtualMessageFeed/CustomHeader.tsx b/src/routes/chat/Layout/VirtualMessageFeed/CustomHeader.tsx index 9dc7d86b..59223cd6 100644 --- a/src/routes/chat/Layout/VirtualMessageFeed/CustomHeader.tsx +++ b/src/routes/chat/Layout/VirtualMessageFeed/CustomHeader.tsx @@ -1,7 +1,5 @@ import { useTranslation } from "react-i18next"; import { NavLink, useLocation } from "react-router-dom"; -import { Waveform } from "@uiball/loaders"; -import clsx from "clsx"; import { useAppSelector } from "@/app/store"; import EditIcon from "@/assets/icons/edit.svg"; @@ -42,23 +40,15 @@ const ChannelHeader = ({ cid }: ChannelHeaderProps) => { }; type Props = { - context?: { + context: { id: number; isChannel: boolean; loadingMore: boolean; }; }; const CustomHeader = ({ context }: Props) => { - if (!context) return null; - const { id, isChannel, loadingMore } = context; - return ( - <> - {isChannel ? : null} -
- -
- - ); + const { id, isChannel } = context; + return isChannel ? : null; }; export default CustomHeader; diff --git a/src/routes/chat/Layout/VirtualMessageFeed/CustomList.tsx b/src/routes/chat/Layout/VirtualMessageFeed/CustomList.tsx deleted file mode 100644 index 16af67a3..00000000 --- a/src/routes/chat/Layout/VirtualMessageFeed/CustomList.tsx +++ /dev/null @@ -1,17 +0,0 @@ -const CustomList = ({ style, ref, ...props }: any) => { - return ( -
- ); -}; - -export default CustomList; diff --git a/src/routes/chat/Layout/VirtualMessageFeed/index.tsx b/src/routes/chat/Layout/VirtualMessageFeed/index.tsx index ada45c1e..7aba16be 100644 --- a/src/routes/chat/Layout/VirtualMessageFeed/index.tsx +++ b/src/routes/chat/Layout/VirtualMessageFeed/index.tsx @@ -1,7 +1,17 @@ -import { useCallback, useEffect, useRef, useState, forwardRef, useImperativeHandle, useMemo } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, + forwardRef, + useImperativeHandle, + useMemo, + useLayoutEffect +} from "react"; import { shallowEqual, useDispatch } from "react-redux"; -import { Virtuoso, VirtuosoHandle } from "react-virtuoso"; +import { Virtualizer, type VirtualizerHandle } from "virtua"; import { useDebounce } from "rooks"; +import { Waveform } from "@uiball/loaders"; import { useLazyLoadMoreMessagesQuery, useReadMessageMutation } from "@/app/services/message"; import { updateHistoryMark } from "@/app/slices/footprint"; @@ -10,7 +20,6 @@ import { ChatContext } from "@/types/common"; import { renderMessageFragment } from "../../utils"; import NewMessageBottomTip from "../NewMessageBottomTip"; import CustomHeader from "./CustomHeader"; -import CustomList from "./CustomList"; import { makeSelectVisibleMessages } from "@/app/selectors/message"; import { updateSelectMessages } from "@/app/slices/ui"; @@ -24,29 +33,21 @@ export interface VirtualMessageFeedHandle { notifyFileSending: () => void; } -// const firstMsgIndex = 10000; -// let prevMids: number[] = []; const VirtualMessageFeed = forwardRef(({ context, id }, ref) => { const dispatch = useDispatch(); - // const { t } = useTranslation("chat"); - // const [firstItemIndex, setFirstItemIndex] = useState(firstMsgIndex); - const [atBottom, setAtBottom] = useState(false); - // Reduce initial visible count for better performance on low-end devices + const [atBottom, setAtBottom] = useState(true); + const stickToBottomRef = useRef(true); const [visibleCount, setVisibleCount] = useState(50); - // Track when files are being sent to force scroll - const shouldScrollForFileRef = useRef(false); - const pendingScrollToBottomRef = useRef(false); + // Track prepend operations so virtua's shift prop can compensate scroll position + const isPrependRef = useRef(false); const [loadMoreMessage, { isLoading: loadingMore, isSuccess, data: historyData }] = useLazyLoadMoreMessagesQuery(); - const vList = useRef(null); + const vRef = useRef(null); const [updateReadIndex] = useReadMessageMutation(); - // Increase debounce time for better performance on low-end devices const updateReadDebounced = useDebounce(updateReadIndex, 500); - // Store debounced function in ref to avoid recreating itemContent const updateReadDebouncedRef = useRef(updateReadDebounced); updateReadDebouncedRef.current = updateReadDebounced; - // Create memoized selector instance for this component const selectVisibleMessages = useMemo(() => makeSelectVisibleMessages(), []); const historyMid = useAppSelector( @@ -62,16 +63,14 @@ const VirtualMessageFeed = forwardRef(({ contex shallowEqual ); - // Stabilize mids array reference - only create new array if content actually changed const mids = useMemo(() => { if (allMids.length <= visibleCount) return allMids; return allMids.slice(-visibleCount); }, [allMids, visibleCount]); - // Use ref to track previous mids for comparison const prevMidsRef = useRef([]); + const prevMidsLenRef = useRef(0); const stableMids = useMemo(() => { - // Check if mids actually changed if (prevMidsRef.current.length === mids.length) { let same = true; for (let i = 0; i < mids.length; i++) { @@ -85,42 +84,80 @@ const VirtualMessageFeed = forwardRef(({ contex prevMidsRef.current = mids; return mids; }, [mids]); + + // Detect whether the data change is a prepend (items added at start) + // or an append (items added at end). When not at bottom and new messages + // arrive, allMids grows but slice(-visibleCount) slides the window, + // removing items from the start. We need to grow visibleCount to prevent this. + useEffect(() => { + if (!stickToBottomRef.current && allMids.length > prevMidsLenRef.current) { + // New messages arrived while scrolled up — grow visibleCount so the + // slice window doesn't slide and remove items from the top + const growth = allMids.length - prevMidsLenRef.current; + setVisibleCount((prev) => prev + growth); + } + prevMidsLenRef.current = allMids.length; + }, [allMids.length]); + const selects = useAppSelector( (store) => store.ui.selectMessages[`${context}_${id}`], shallowEqual ); const loginUid = useAppSelector((store) => store.authData.user?.uid, shallowEqual); - // Create stable toggleSelect function - const toggleSelect = useCallback((mid: number, selected: boolean) => { - const operation = selected ? "remove" : "add"; - dispatch(updateSelectMessages({ context, id, operation, data: mid })); - }, [context, id, dispatch]); + const toggleSelect = useCallback( + (mid: number, selected: boolean) => { + const operation = selected ? "remove" : "add"; + dispatch(updateSelectMessages({ context, id, operation, data: mid })); + }, + [context, id, dispatch] + ); - // Use memoized selector to get message data - this will only recompute when the specific messages change const messageData = useAppSelector((store) => selectVisibleMessages(store, stableMids)); const readChannels = useAppSelector((store) => store.footprint.readChannels, shallowEqual); const readUsers = useAppSelector((store) => store.footprint.readUsers, shallowEqual); - useEffect(() => { - // Reset visible count when switching chats - setVisibleCount(50); - setAtBottom(false); + // Count extra virtual items before messages (header, loading spinner) + const extraItemCount = (context === "channel" ? 1 : 0) + (loadingMore ? 1 : 0); + const extraItemCountRef = useRef(extraItemCount); + extraItemCountRef.current = extraItemCount; - // Scroll to bottom when switching conversations - // Use setTimeout to ensure the new messages are rendered first - setTimeout(() => { - if (vList.current && stableMids.length > 0) { - vList.current.scrollToIndex({ - index: stableMids.length - 1, - align: "end", - behavior: "auto" - }); - } - }, 0); + const scrollToBottom = useCallback(() => { + if (vRef.current) { + vRef.current.scrollTo(vRef.current.scrollSize); + } + }, []); + + // Scroll to bottom when sticking and new messages arrive + useEffect(() => { + if (stickToBottomRef.current && stableMids.length > 0) { + scrollToBottom(); + } + }, [stableMids, scrollToBottom]); + + // Reset on conversation switch + useEffect(() => { + setVisibleCount(50); + stickToBottomRef.current = true; + setAtBottom(true); + isPrependRef.current = false; }, [id]); + // Scroll to bottom after conversation switch (after render) + useEffect(() => { + const timer = setTimeout(() => { + scrollToBottom(); + }, 0); + return () => clearTimeout(timer); + }, [id, scrollToBottom]); + + // Reset isPrepend after layout + useLayoutEffect(() => { + isPrependRef.current = false; + }); + + // Handle scrollToMessage custom event useEffect(() => { const feedId = `VOCECHAT_FEED_${context}_${id}`; const feedEle = document.getElementById(feedId); @@ -128,8 +165,8 @@ const VirtualMessageFeed = forwardRef(({ contex const handleScrollToMessage = (evt: CustomEvent) => { const { mid } = evt.detail; const index = stableMids.findIndex((m) => m === mid); - if (index !== -1 && vList.current) { - vList.current.scrollToIndex({ index, align: "center", behavior: "smooth" }); + if (index !== -1 && vRef.current) { + vRef.current.scrollToIndex(extraItemCountRef.current + index, { align: "center", smooth: true }); setTimeout(() => { const msgEle = document.querySelector(`[data-msg-mid='${mid}']`); if (msgEle) { @@ -147,8 +184,8 @@ const VirtualMessageFeed = forwardRef(({ contex setVisibleCount(allMids.length); setTimeout(() => { const idx = allMids.findIndex((m) => m === mid); - if (idx !== -1 && vList.current) { - vList.current.scrollToIndex({ index: idx, align: "center", behavior: "smooth" }); + if (idx !== -1 && vRef.current) { + vRef.current.scrollToIndex(extraItemCountRef.current + idx, { align: "center", smooth: true }); setTimeout(() => { const msgEle = document.querySelector(`[data-msg-mid='${mid}']`); if (msgEle) { @@ -167,110 +204,79 @@ const VirtualMessageFeed = forwardRef(({ contex } }; - feedEle?.addEventListener('scrollToMessage', handleScrollToMessage as EventListener); + feedEle?.addEventListener("scrollToMessage", handleScrollToMessage as EventListener); return () => { - feedEle?.removeEventListener('scrollToMessage', handleScrollToMessage as EventListener); + feedEle?.removeEventListener("scrollToMessage", handleScrollToMessage as EventListener); }; }, [context, id, stableMids, allMids]); useEffect(() => { if (isSuccess && historyData) { if (historyData.length == 0) { - // 到顶了 dispatch(updateHistoryMark({ type: context, id, mid: "reached" })); } else { - // 记录最新的 mid const [{ mid }] = historyData; dispatch(updateHistoryMark({ type: context, id, mid: `${mid}` })); } } }, [isSuccess, historyData, stableMids, context, id]); - // useEffect(() => { - // console.log("diff mids", prevMids, mids); - // const newCount = mids.length - prevMids.length; - // setFirstItemIndex((prev) => prev - newCount); - // }, [mids]); - // 加载更多 - const handleTopStateChange = (isTop: boolean) => { - console.log("reach top ", isTop); - if (isTop) { + // onScroll: detect atBottom state + const handleScroll = useCallback((offset: number) => { + if (!vRef.current) return; + const handle = vRef.current; + const isAtBottom = offset - handle.scrollSize + handle.viewportSize >= -50; + stickToBottomRef.current = isAtBottom; + setAtBottom(isAtBottom); + + // Load more when near top + if (offset < 100) { if (allMids.length > visibleCount) { - // Load 50 messages at a time for better performance - setVisibleCount(prev => Math.min(prev + 50, allMids.length)); + isPrependRef.current = true; + setVisibleCount((prev) => Math.min(prev + 50, allMids.length)); } else { if (historyMid === "reached") return; let lastMid = allMids.slice(0, 1)[0]; if (historyMid) { lastMid = +historyMid; } + isPrependRef.current = true; loadMoreMessage({ context, id, mid: lastMid }); } } - }; - // 自动跟随 - const handleFollowOutput = (isAtBottom: boolean) => { - // Check if this is a file send that should force scroll - if (shouldScrollForFileRef.current) { - shouldScrollForFileRef.current = false; // Reset flag - return "smooth"; - } + }, [allMids, visibleCount, historyMid, context, id, loadMoreMessage]); - // For all other cases (text messages, received messages), only scroll if at bottom - return isAtBottom ? "smooth" : false; - }; - // 滚动到底部 const handleScrollBottom = useCallback(() => { - const vl = vList!.current; - if (vl) { - vl.scrollToIndex(allMids.length - 1); - } - }, [allMids]); - const handleBottomStateChange = (bottom: boolean) => { - setAtBottom(bottom); - }; - - const handleTotalListHeightChanged = useCallback((_height: number) => { - if (pendingScrollToBottomRef.current) { - pendingScrollToBottomRef.current = false; - // Use requestAnimationFrame to wait for the layout shift to complete - // (UploadFileList unmounting and Send box shrinking) - requestAnimationFrame(() => { - if (vList.current && stableMids.length > 0) { - vList.current.scrollToIndex({ - index: stableMids.length - 1, - align: "end", - behavior: "auto" - }); - } - }); - } - }, [stableMids.length]); + stickToBottomRef.current = true; + scrollToBottom(); + }, [scrollToBottom]); useImperativeHandle(ref, () => ({ scrollToMessage: (mid: number) => { const index = stableMids.findIndex((m) => m === mid); - if (index !== -1 && vList.current) { - vList.current.scrollToIndex({ index, align: "center", behavior: "smooth" }); + if (index !== -1 && vRef.current) { + vRef.current.scrollToIndex(extraItemCountRef.current + index, { align: "center", smooth: true }); } else if (allMids.includes(mid)) { setVisibleCount(allMids.length); setTimeout(() => { const idx = allMids.findIndex((m) => m === mid); - if (idx !== -1 && vList.current) { - vList.current.scrollToIndex({ index: idx, align: "center", behavior: "smooth" }); + if (idx !== -1 && vRef.current) { + vRef.current.scrollToIndex(extraItemCountRef.current + idx, { align: "center", smooth: true }); } }, 100); } }, notifyFileSending: () => { - shouldScrollForFileRef.current = true; - pendingScrollToBottomRef.current = true; // Arm the height-change listener + stickToBottomRef.current = true; + requestAnimationFrame(() => { + scrollToBottom(); + }); } })); - + const readIndex = context == "channel" ? readChannels[id] : readUsers[id]; - // Store frequently changing values in refs to avoid recreating itemContent + // Store frequently changing values in refs to avoid recreating render function const messageDataRef = useRef(messageData); const midsRef = useRef(stableMids); const readIndexRef = useRef(readIndex); @@ -285,59 +291,63 @@ const VirtualMessageFeed = forwardRef(({ contex selectsRef.current = selects; toggleSelectRef.current = toggleSelect; - // Stable itemContent function - only recreate when context or id changes - const itemContent = useCallback((idx: number, mid: number) => { - const curr = messageDataRef.current[mid]; - if (!curr) return
; - const isFirst = idx == 0; - const prev = isFirst ? null : messageDataRef.current[midsRef.current[idx - 1]]; - // Optimize read calculation: once a message is read, it stays read - // This prevents unnecessary re-renders when readIndex updates - const read = curr?.from_uid == loginUidRef.current || mid <= readIndexRef.current; - const selected = !!(selectsRef.current && selectsRef.current.find((s: number) => s == mid)); - const handleToggleSelect = () => toggleSelectRef.current(mid, selected); - return renderMessageFragment({ - selectMode: !!selectsRef.current, - updateReadIndex: updateReadDebouncedRef.current, - read, - prev, - curr, - contextId: id, - context, - selected, - toggleSelect: handleToggleSelect - }); - }, [id, context]); - + const renderItem = useCallback( + (mid: number, idx: number) => { + const curr = messageDataRef.current[mid]; + if (!curr) return
; + const isFirst = idx == 0; + const prev = isFirst ? null : messageDataRef.current[midsRef.current[idx - 1]]; + const read = curr?.from_uid == loginUidRef.current || mid <= readIndexRef.current; + const selected = !!( + selectsRef.current && selectsRef.current.find((s: number) => s == mid) + ); + const handleToggleSelect = () => toggleSelectRef.current(mid, selected); + return ( +
+ {renderMessageFragment({ + selectMode: !!selectsRef.current, + updateReadIndex: updateReadDebouncedRef.current, + read, + prev, + curr, + contextId: id, + context, + selected, + toggleSelect: handleToggleSelect + })} +
+ ); + }, + [id, context] + ); + return ( <> - + className="px-1 md:px-4 py-4.5 overflow-x-hidden overflow-y-scroll flex-1" + > + + {/* Channel header as first virtual item */} + {context === "channel" && ( +
+ +
+ )} + {loadingMore && ( +
+ +
+ )} + {stableMids.map((mid, idx) => renderItem(mid, idx))} +
+
{!atBottom && ( )}