refactor: 重构聊天显示部分以提升大量消息时web响应速度和在低性能设备上的表现 (#297)
* refactor: optimize message feed rendering and state management - Removed unnecessary initialization state in VirtualMessageFeed. - Updated message data subscription to only track visible messages, improving performance. - Simplified props in Layout component for VirtualMessageFeed and Send components. * refactor: 针对低性能设备进行更多优化,包括消息隔离等 - Added a script to suppress ResizeObserver errors in index.html. - Introduced memoized selectors for efficient message retrieval in message selectors. - Optimized message insertion logic in message slices to reduce unnecessary sorting. - Improved memoization in Message component for better rendering performance. - Enhanced MessageInput to reset editor state on chat changes. - Updated VirtualMessageFeed to manage visible messages more efficiently and reduce initial load for better performance on low-end devices. * chore: bump version to v0.9.50
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vocechat-web",
|
"name": "vocechat-web",
|
||||||
"version": "0.9.49",
|
"version": "0.9.50",
|
||||||
"homepage": "https://voce.chat",
|
"homepage": "https://voce.chat",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@metamask/onboarding": "^1.0.1",
|
"@metamask/onboarding": "^1.0.1",
|
||||||
|
|||||||
@@ -8,6 +8,55 @@
|
|||||||
/>
|
/>
|
||||||
<meta name="theme-color" content="#527ff1" />
|
<meta name="theme-color" content="#527ff1" />
|
||||||
<meta name="description" content="Your private chat APP" />
|
<meta name="description" content="Your private chat APP" />
|
||||||
|
|
||||||
|
<!-- Suppress ResizeObserver errors - must be first script -->
|
||||||
|
<script>
|
||||||
|
// Suppress benign ResizeObserver errors from virtualized lists
|
||||||
|
// This is a known browser issue and doesn't affect functionality
|
||||||
|
// Reference: https://github.com/WICG/resize-observer/issues/38
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
function isResizeObserverError(message) {
|
||||||
|
return message && (
|
||||||
|
message.includes('ResizeObserver loop') ||
|
||||||
|
message.includes('ResizeObserver loop completed') ||
|
||||||
|
message.includes('ResizeObserver loop limit exceeded')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intercept window.onerror
|
||||||
|
const originalOnError = window.onerror;
|
||||||
|
window.onerror = function(message, source, lineno, colno, error) {
|
||||||
|
if (typeof message === 'string' && isResizeObserverError(message)) {
|
||||||
|
return true; // Suppress ResizeObserver errors
|
||||||
|
}
|
||||||
|
if (originalOnError) {
|
||||||
|
return originalOnError.apply(this, arguments);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Intercept error events (capture phase to catch early)
|
||||||
|
window.addEventListener('error', function(e) {
|
||||||
|
if (e.message && isResizeObserverError(e.message)) {
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// Intercept console.error
|
||||||
|
const originalConsoleError = console.error;
|
||||||
|
console.error = function() {
|
||||||
|
const args = Array.from(arguments);
|
||||||
|
if (args.length > 0 && typeof args[0] === 'string' && isResizeObserverError(args[0])) {
|
||||||
|
return; // Suppress
|
||||||
|
}
|
||||||
|
originalConsoleError.apply(console, args);
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/apple-touch-icon.png" />
|
||||||
<!-- splash -->
|
<!-- splash -->
|
||||||
<link
|
<link
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { createSelector } from '@reduxjs/toolkit';
|
||||||
|
import { RootState } from '../store';
|
||||||
|
import { ChatContext } from '@/types/common';
|
||||||
|
|
||||||
|
// Base selectors - these extract raw data from the store
|
||||||
|
const selectMessageStore = (state: RootState) => state.message;
|
||||||
|
const selectChannelMessages = (state: RootState) => state.channelMessage;
|
||||||
|
const selectUserMessages = (state: RootState) => state.userMessage;
|
||||||
|
|
||||||
|
// Create a memoized selector for visible messages
|
||||||
|
// This selector will only recompute when the specific messages change, not when any message in the store changes
|
||||||
|
export const makeSelectVisibleMessages = () => {
|
||||||
|
return createSelector(
|
||||||
|
[
|
||||||
|
selectMessageStore,
|
||||||
|
(_: RootState, mids: number[]) => mids
|
||||||
|
],
|
||||||
|
(messageStore, mids) => {
|
||||||
|
// Create a stable object that only changes when the actual message content changes
|
||||||
|
const messages: Record<number, any> = {};
|
||||||
|
mids.forEach(mid => {
|
||||||
|
if (messageStore[mid]) {
|
||||||
|
messages[mid] = messageStore[mid];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return messages;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Use custom equality check to prevent unnecessary recomputation
|
||||||
|
memoizeOptions: {
|
||||||
|
resultEqualityCheck: (a, b) => {
|
||||||
|
// Check if the message objects are the same
|
||||||
|
const aKeys = Object.keys(a);
|
||||||
|
const bKeys = Object.keys(b);
|
||||||
|
if (aKeys.length !== bKeys.length) return false;
|
||||||
|
|
||||||
|
for (const key of aKeys) {
|
||||||
|
if (a[key] !== b[key]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a memoized selector for message IDs
|
||||||
|
export const makeSelectMessageIds = () => {
|
||||||
|
return createSelector(
|
||||||
|
[
|
||||||
|
selectChannelMessages,
|
||||||
|
selectUserMessages,
|
||||||
|
(_: RootState, context: ChatContext, id: number) => ({ context, id })
|
||||||
|
],
|
||||||
|
(channelMessages, userMessages, { context, id }) => {
|
||||||
|
return context === 'dm'
|
||||||
|
? userMessages.byId[id] ?? []
|
||||||
|
: channelMessages[id] ?? [];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -26,9 +26,17 @@ const channelMsgSlice = createSlice({
|
|||||||
const midExisted = state[id]!.findIndex((id) => id == mid) > -1;
|
const midExisted = state[id]!.findIndex((id) => id == mid) > -1;
|
||||||
const localMsgExisted = state[id]!.findIndex((id) => id == local_id) > -1;
|
const localMsgExisted = state[id]!.findIndex((id) => id == local_id) > -1;
|
||||||
if (midExisted || localMsgExisted) return;
|
if (midExisted || localMsgExisted) return;
|
||||||
// 每次入库,都排序
|
|
||||||
const newArr = [...(state[id] as number[]), +mid].sort((a, b) => a - b);
|
// Optimize: check if new message should be at the end (most common case)
|
||||||
state[id] = newArr;
|
const lastMid = state[id]![state[id]!.length - 1];
|
||||||
|
if (+mid > lastMid) {
|
||||||
|
// New message is newer, just push to end - no need to sort
|
||||||
|
state[id]!.push(+mid);
|
||||||
|
} else {
|
||||||
|
// Need to insert in sorted position
|
||||||
|
const newArr = [...(state[id] as number[]), +mid].sort((a, b) => a - b);
|
||||||
|
state[id] = newArr;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
state[id] = [+mid];
|
state[id] = [+mid];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,18 @@ const userMsgSlice = createSlice({
|
|||||||
const midExisted = state.byId[id].findIndex((id: number) => id == mid) > -1;
|
const midExisted = state.byId[id].findIndex((id: number) => id == mid) > -1;
|
||||||
const localMsgExisted = state.byId[id].findIndex((id: number) => id == local_id) > -1;
|
const localMsgExisted = state.byId[id].findIndex((id: number) => id == local_id) > -1;
|
||||||
if (midExisted || localMsgExisted) return;
|
if (midExisted || localMsgExisted) return;
|
||||||
// 每次入库,都排序
|
|
||||||
const newArr = [...state.byId[id], +mid].sort((a, b) => a - b);
|
// Optimize: check if new message should be at the end (most common case)
|
||||||
state.byId[id] = newArr;
|
const lastMid = state.byId[id][state.byId[id].length - 1];
|
||||||
|
if (+mid > lastMid) {
|
||||||
|
// New message is newer, just push to end - no need to sort
|
||||||
|
state.byId[id].push(+mid);
|
||||||
|
} else {
|
||||||
|
// Need to insert in sorted position
|
||||||
|
const newArr = [...state.byId[id], +mid].sort((a, b) => a - b);
|
||||||
|
state.byId[id] = newArr;
|
||||||
|
}
|
||||||
|
|
||||||
// 只要有新消息,就检查下
|
// 只要有新消息,就检查下
|
||||||
if (state.ids.findIndex((uid) => uid == id) == -1) {
|
if (state.ids.findIndex((uid) => uid == id) == -1) {
|
||||||
state.ids.push(+id);
|
state.ids.push(+id);
|
||||||
|
|||||||
@@ -242,5 +242,12 @@ const Message: FC<IProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default React.memo(Message, (prevs, nexts) => {
|
export default React.memo(Message, (prevs, nexts) => {
|
||||||
return prevs.mid == nexts.mid;
|
// More precise memo comparison for better performance
|
||||||
|
return (
|
||||||
|
prevs.mid === nexts.mid &&
|
||||||
|
prevs.readOnly === nexts.readOnly &&
|
||||||
|
prevs.read === nexts.read &&
|
||||||
|
prevs.contextId === nexts.contextId &&
|
||||||
|
prevs.context === nexts.context
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ export default function MessageInput({
|
|||||||
const handleSendMessage = () => {
|
const handleSendMessage = () => {
|
||||||
sendMessage();
|
sendMessage();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Reset editor when chat changes without remounting
|
||||||
|
useEffect(() => {
|
||||||
|
if (editorRef.current) {
|
||||||
|
editorRef.current.reset();
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const text = getMessageFromPlateValues(input as ParagraphInput[]);
|
const text = getMessageFromPlateValues(input as ParagraphInput[]);
|
||||||
updateMessage(text);
|
updateMessage(text);
|
||||||
@@ -89,7 +97,6 @@ export default function MessageInput({
|
|||||||
<Plate
|
<Plate
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
editorRef={editorRef}
|
editorRef={editorRef}
|
||||||
key={id}
|
|
||||||
id={id}
|
id={id}
|
||||||
onChange={(values) => {
|
onChange={(values) => {
|
||||||
setInput(values);
|
setInput(values);
|
||||||
@@ -100,7 +107,6 @@ export default function MessageInput({
|
|||||||
<Editor
|
<Editor
|
||||||
sendMessage={handleSendMessage}
|
sendMessage={handleSendMessage}
|
||||||
// className="px-2 py-3"
|
// className="px-2 py-3"
|
||||||
autoFocus
|
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
/>
|
/>
|
||||||
<MentionCombobox items={items} />
|
<MentionCombobox items={items} />
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
const CustomList = ({ style, ref, ...props }: any) => {
|
const CustomList = ({ style, ref, ...props }: any) => {
|
||||||
return <div style={{ ...style, width: `calc(100% - 2rem)` }} {...props} ref={ref} />;
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
width: `calc(100% - 2rem)`,
|
||||||
|
// CSS performance hint for better scrolling performance
|
||||||
|
// Note: avoid 'contain' as it interferes with ResizeObserver in virtuoso
|
||||||
|
willChange: 'transform'
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CustomList;
|
export default CustomList;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { renderMessageFragment } from "../../utils";
|
|||||||
import NewMessageBottomTip from "../NewMessageBottomTip";
|
import NewMessageBottomTip from "../NewMessageBottomTip";
|
||||||
import CustomHeader from "./CustomHeader";
|
import CustomHeader from "./CustomHeader";
|
||||||
import CustomList from "./CustomList";
|
import CustomList from "./CustomList";
|
||||||
|
import { makeSelectVisibleMessages } from "@/app/selectors/message";
|
||||||
|
import { updateSelectMessages } from "@/app/slices/ui";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
context: ChatContext;
|
context: ChatContext;
|
||||||
@@ -28,13 +30,21 @@ const VirtualMessageFeed = forwardRef<VirtualMessageFeedHandle, Props>(({ contex
|
|||||||
// const { t } = useTranslation("chat");
|
// const { t } = useTranslation("chat");
|
||||||
// const [firstItemIndex, setFirstItemIndex] = useState(firstMsgIndex);
|
// const [firstItemIndex, setFirstItemIndex] = useState(firstMsgIndex);
|
||||||
const [atBottom, setAtBottom] = useState(false);
|
const [atBottom, setAtBottom] = useState(false);
|
||||||
const [visibleCount, setVisibleCount] = useState(100);
|
// Reduce initial visible count for better performance on low-end devices
|
||||||
const isInitializing = useRef(false);
|
const [visibleCount, setVisibleCount] = useState(50);
|
||||||
const [loadMoreMessage, { isLoading: loadingMore, isSuccess, data: historyData }] =
|
const [loadMoreMessage, { isLoading: loadingMore, isSuccess, data: historyData }] =
|
||||||
useLazyLoadMoreMessagesQuery();
|
useLazyLoadMoreMessagesQuery();
|
||||||
const vList = useRef<VirtuosoHandle | null>(null);
|
const vList = useRef<VirtuosoHandle | null>(null);
|
||||||
const [updateReadIndex] = useReadMessageMutation();
|
const [updateReadIndex] = useReadMessageMutation();
|
||||||
const updateReadDebounced = useDebounce(updateReadIndex, 300);
|
// 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(
|
const historyMid = useAppSelector(
|
||||||
(store) =>
|
(store) =>
|
||||||
context == "dm"
|
context == "dm"
|
||||||
@@ -47,35 +57,61 @@ const VirtualMessageFeed = forwardRef<VirtualMessageFeedHandle, Props>(({ contex
|
|||||||
context == "dm" ? store.userMessage.byId[id] ?? [] : store.channelMessage[id] ?? [],
|
context == "dm" ? store.userMessage.byId[id] ?? [] : store.channelMessage[id] ?? [],
|
||||||
shallowEqual
|
shallowEqual
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Stabilize mids array reference - only create new array if content actually changed
|
||||||
const mids = useMemo(() => {
|
const mids = useMemo(() => {
|
||||||
if (allMids.length <= visibleCount) return allMids;
|
if (allMids.length <= visibleCount) return allMids;
|
||||||
return allMids.slice(-visibleCount);
|
return allMids.slice(-visibleCount);
|
||||||
}, [allMids, visibleCount]);
|
}, [allMids, visibleCount]);
|
||||||
|
|
||||||
|
// Use ref to track previous mids for comparison
|
||||||
|
const prevMidsRef = useRef<number[]>([]);
|
||||||
|
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++) {
|
||||||
|
if (prevMidsRef.current[i] !== mids[i]) {
|
||||||
|
same = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (same) return prevMidsRef.current;
|
||||||
|
}
|
||||||
|
prevMidsRef.current = mids;
|
||||||
|
return mids;
|
||||||
|
}, [mids]);
|
||||||
const selects = useAppSelector(
|
const selects = useAppSelector(
|
||||||
(store) => store.ui.selectMessages[`${context}_${id}`],
|
(store) => store.ui.selectMessages[`${context}_${id}`],
|
||||||
shallowEqual
|
shallowEqual
|
||||||
);
|
);
|
||||||
const loginUid = useAppSelector((store) => store.authData.user?.uid, shallowEqual);
|
const loginUid = useAppSelector((store) => store.authData.user?.uid, shallowEqual);
|
||||||
const messageData = useAppSelector((store) => store.message, 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]);
|
||||||
|
|
||||||
|
// 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 readChannels = useAppSelector((store) => store.footprint.readChannels, shallowEqual);
|
||||||
const readUsers = useAppSelector((store) => store.footprint.readUsers, shallowEqual);
|
const readUsers = useAppSelector((store) => store.footprint.readUsers, shallowEqual);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isInitializing.current = true;
|
// Reset visible count when switching chats
|
||||||
setVisibleCount(100);
|
setVisibleCount(50);
|
||||||
setTimeout(() => {
|
setAtBottom(false);
|
||||||
isInitializing.current = false;
|
|
||||||
}, 500);
|
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const feedId = `VOCECHAT_FEED_${context}_${id}`;
|
const feedId = `VOCECHAT_FEED_${context}_${id}`;
|
||||||
const feedEle = document.getElementById(feedId);
|
const feedEle = document.getElementById(feedId);
|
||||||
|
|
||||||
const handleScrollToMessage = (evt: CustomEvent) => {
|
const handleScrollToMessage = (evt: CustomEvent) => {
|
||||||
const { mid } = evt.detail;
|
const { mid } = evt.detail;
|
||||||
const index = mids.findIndex((m) => m === mid);
|
const index = stableMids.findIndex((m) => m === mid);
|
||||||
if (index !== -1 && vList.current) {
|
if (index !== -1 && vList.current) {
|
||||||
vList.current.scrollToIndex({ index, align: "center", behavior: "smooth" });
|
vList.current.scrollToIndex({ index, align: "center", behavior: "smooth" });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -114,12 +150,12 @@ const VirtualMessageFeed = forwardRef<VirtualMessageFeedHandle, Props>(({ contex
|
|||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
feedEle?.addEventListener('scrollToMessage', handleScrollToMessage as EventListener);
|
feedEle?.addEventListener('scrollToMessage', handleScrollToMessage as EventListener);
|
||||||
return () => {
|
return () => {
|
||||||
feedEle?.removeEventListener('scrollToMessage', handleScrollToMessage as EventListener);
|
feedEle?.removeEventListener('scrollToMessage', handleScrollToMessage as EventListener);
|
||||||
};
|
};
|
||||||
}, [context, id, mids, allMids]);
|
}, [context, id, stableMids, allMids]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSuccess && historyData) {
|
if (isSuccess && historyData) {
|
||||||
@@ -132,7 +168,7 @@ const VirtualMessageFeed = forwardRef<VirtualMessageFeedHandle, Props>(({ contex
|
|||||||
dispatch(updateHistoryMark({ type: context, id, mid: `${mid}` }));
|
dispatch(updateHistoryMark({ type: context, id, mid: `${mid}` }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isSuccess, historyData, mids, context, id]);
|
}, [isSuccess, historyData, stableMids, context, id]);
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
// console.log("diff mids", prevMids, mids);
|
// console.log("diff mids", prevMids, mids);
|
||||||
// const newCount = mids.length - prevMids.length;
|
// const newCount = mids.length - prevMids.length;
|
||||||
@@ -142,9 +178,10 @@ const VirtualMessageFeed = forwardRef<VirtualMessageFeedHandle, Props>(({ contex
|
|||||||
// 加载更多
|
// 加载更多
|
||||||
const handleTopStateChange = (isTop: boolean) => {
|
const handleTopStateChange = (isTop: boolean) => {
|
||||||
console.log("reach top ", isTop);
|
console.log("reach top ", isTop);
|
||||||
if (isTop && !isInitializing.current) {
|
if (isTop) {
|
||||||
if (allMids.length > visibleCount) {
|
if (allMids.length > visibleCount) {
|
||||||
setVisibleCount(prev => Math.min(prev + 100, allMids.length));
|
// Load 50 messages at a time for better performance
|
||||||
|
setVisibleCount(prev => Math.min(prev + 50, allMids.length));
|
||||||
} else {
|
} else {
|
||||||
if (historyMid === "reached") return;
|
if (historyMid === "reached") return;
|
||||||
let lastMid = allMids.slice(0, 1)[0];
|
let lastMid = allMids.slice(0, 1)[0];
|
||||||
@@ -157,7 +194,7 @@ const VirtualMessageFeed = forwardRef<VirtualMessageFeedHandle, Props>(({ contex
|
|||||||
};
|
};
|
||||||
// 自动跟随
|
// 自动跟随
|
||||||
const handleFollowOutput = (isAtBottom: boolean) => {
|
const handleFollowOutput = (isAtBottom: boolean) => {
|
||||||
const [lastMid] = mids ? mids.slice(-1) : [0];
|
const [lastMid] = stableMids ? stableMids.slice(-1) : [0];
|
||||||
const ts = new Date().getTime();
|
const ts = new Date().getTime();
|
||||||
// tricky
|
// tricky
|
||||||
const isSentByMyself = ts - lastMid < 1000;
|
const isSentByMyself = ts - lastMid < 1000;
|
||||||
@@ -180,7 +217,7 @@ const VirtualMessageFeed = forwardRef<VirtualMessageFeedHandle, Props>(({ contex
|
|||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
scrollToMessage: (mid: number) => {
|
scrollToMessage: (mid: number) => {
|
||||||
const index = mids.findIndex((m) => m === mid);
|
const index = stableMids.findIndex((m) => m === mid);
|
||||||
if (index !== -1 && vList.current) {
|
if (index !== -1 && vList.current) {
|
||||||
vList.current.scrollToIndex({ index, align: "center", behavior: "smooth" });
|
vList.current.scrollToIndex({ index, align: "center", behavior: "smooth" });
|
||||||
} else if (allMids.includes(mid)) {
|
} else if (allMids.includes(mid)) {
|
||||||
@@ -196,31 +233,54 @@ const VirtualMessageFeed = forwardRef<VirtualMessageFeedHandle, Props>(({ contex
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const readIndex = context == "channel" ? readChannels[id] : readUsers[id];
|
const readIndex = context == "channel" ? readChannels[id] : readUsers[id];
|
||||||
|
|
||||||
// 缓存itemContent函数,避免每次都创建新函数
|
// Store frequently changing values in refs to avoid recreating itemContent
|
||||||
|
const messageDataRef = useRef(messageData);
|
||||||
|
const midsRef = useRef(stableMids);
|
||||||
|
const readIndexRef = useRef(readIndex);
|
||||||
|
const loginUidRef = useRef(loginUid);
|
||||||
|
const selectsRef = useRef(selects);
|
||||||
|
const toggleSelectRef = useRef(toggleSelect);
|
||||||
|
|
||||||
|
messageDataRef.current = messageData;
|
||||||
|
midsRef.current = stableMids;
|
||||||
|
readIndexRef.current = readIndex;
|
||||||
|
loginUidRef.current = loginUid;
|
||||||
|
selectsRef.current = selects;
|
||||||
|
toggleSelectRef.current = toggleSelect;
|
||||||
|
|
||||||
|
// Stable itemContent function - only recreate when context or id changes
|
||||||
const itemContent = useCallback((idx: number, mid: number) => {
|
const itemContent = useCallback((idx: number, mid: number) => {
|
||||||
const curr = messageData[mid];
|
const curr = messageDataRef.current[mid];
|
||||||
if (!curr) return <div className="w-full h-[1px] invisible"></div>;
|
if (!curr) return <div className="w-full h-[1px] invisible"></div>;
|
||||||
const isFirst = idx == 0;
|
const isFirst = idx == 0;
|
||||||
const prev = isFirst ? null : messageData[mids[idx - 1]];
|
const prev = isFirst ? null : messageDataRef.current[midsRef.current[idx - 1]];
|
||||||
const read = curr?.from_uid == loginUid || mid <= readIndex;
|
// 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({
|
return renderMessageFragment({
|
||||||
selectMode: !!selects,
|
selectMode: !!selectsRef.current,
|
||||||
updateReadIndex: updateReadDebounced,
|
updateReadIndex: updateReadDebouncedRef.current,
|
||||||
read,
|
read,
|
||||||
prev,
|
prev,
|
||||||
curr,
|
curr,
|
||||||
contextId: id,
|
contextId: id,
|
||||||
context
|
context,
|
||||||
|
selected,
|
||||||
|
toggleSelect: handleToggleSelect
|
||||||
});
|
});
|
||||||
}, [messageData, mids, loginUid, readIndex, selects, updateReadDebounced, id, context]);
|
}, [id, context]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
// logLevel={LogLevel.DEBUG}
|
// logLevel={LogLevel.DEBUG}
|
||||||
overscan={50}
|
// Reduce overscan for better performance on low-end devices
|
||||||
increaseViewportBy={{ top: 0, bottom: 400 }}
|
overscan={20}
|
||||||
|
// Reduce viewport extension for better performance
|
||||||
|
increaseViewportBy={{ top: 0, bottom: 200 }}
|
||||||
context={{ loadingMore, id, isChannel: context == "channel" }}
|
context={{ loadingMore, id, isChannel: context == "channel" }}
|
||||||
id={`VOCECHAT_FEED_${context}_${id}`}
|
id={`VOCECHAT_FEED_${context}_${id}`}
|
||||||
className="px-1 md:px-4 py-4.5 overflow-x-hidden overflow-y-scroll"
|
className="px-1 md:px-4 py-4.5 overflow-x-hidden overflow-y-scroll"
|
||||||
@@ -230,10 +290,10 @@ const VirtualMessageFeed = forwardRef<VirtualMessageFeedHandle, Props>(({ contex
|
|||||||
Header: CustomHeader
|
Header: CustomHeader
|
||||||
}}
|
}}
|
||||||
// firstItemIndex={firstItemIndex}
|
// firstItemIndex={firstItemIndex}
|
||||||
initialTopMostItemIndex={mids.length - 1}
|
initialTopMostItemIndex={stableMids.length - 1}
|
||||||
alignToBottom
|
alignToBottom
|
||||||
// startReached={handleLoadMore}
|
// startReached={handleLoadMore}
|
||||||
data={mids}
|
data={stableMids}
|
||||||
atTopThreshold={context == "channel" ? 160 : 0}
|
atTopThreshold={context == "channel" ? 160 : 0}
|
||||||
atTopStateChange={handleTopStateChange}
|
atTopStateChange={handleTopStateChange}
|
||||||
atBottomStateChange={handleBottomStateChange}
|
atBottomStateChange={handleBottomStateChange}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ const Layout: FC<Props> = ({
|
|||||||
{context == "dm" && <DMVoice uid={to} />}
|
{context == "dm" && <DMVoice uid={to} />}
|
||||||
{context == "dm" && <AddContactTip uid={to} />}
|
{context == "dm" && <AddContactTip uid={to} />}
|
||||||
{/* 消息流 */}
|
{/* 消息流 */}
|
||||||
<VirtualMessageFeed ref={feedRef} key={`${context}_${to}`} context={context} id={to} />
|
<VirtualMessageFeed ref={feedRef} context={context} id={to} />
|
||||||
{/* 发送框 */}
|
{/* 发送框 */}
|
||||||
<div className={`px-2 py-0 md:p-4 ${selects ? "selecting" : ""}`}>
|
<div className={`px-2 py-0 md:p-4 ${selects ? "selecting" : ""}`}>
|
||||||
{readonly ? (
|
{readonly ? (
|
||||||
@@ -112,7 +112,7 @@ const Layout: FC<Props> = ({
|
|||||||
<LicenseUpgradeTip />
|
<LicenseUpgradeTip />
|
||||||
) : (
|
) : (
|
||||||
<div className={clsx(`flex justify-center`, selects && "hidden")}>
|
<div className={clsx(`flex justify-center`, selects && "hidden")}>
|
||||||
<Send key={to} id={to} context={context} />
|
<Send id={to} context={context} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selects && <Operations context={context} id={to} />}
|
{selects && <Operations context={context} id={to} />}
|
||||||
|
|||||||
+24
-14
@@ -1,4 +1,5 @@
|
|||||||
// import React from "react";
|
// import React from "react";
|
||||||
|
import React from "react";
|
||||||
import { shallowEqual, useDispatch } from "react-redux";
|
import { shallowEqual, useDispatch } from "react-redux";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
@@ -94,18 +95,7 @@ export const renderPreviewMessage = (message = null) => {
|
|||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MessageWrapper = ({ selectMode = false, context, id, mid, divider, children, ...rest }) => {
|
const MessageWrapper = React.memo(({ selectMode = false, context, id, mid, divider, selected = false, toggleSelect, children, ...rest }) => {
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
const selects = useAppSelector(
|
|
||||||
(store) => store.ui.selectMessages[`${context}_${id}`],
|
|
||||||
shallowEqual
|
|
||||||
);
|
|
||||||
const selected = !!(selects && selects.find((s) => s == mid));
|
|
||||||
const toggleSelect = () => {
|
|
||||||
const operation = selected ? "remove" : "add";
|
|
||||||
dispatch(updateSelectMessages({ context, id, operation, data: mid }));
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<div className={`group flex flex-col items-start gap-2 relative w-full `} {...rest}>
|
<div className={`group flex flex-col items-start gap-2 relative w-full `} {...rest}>
|
||||||
{divider}
|
{divider}
|
||||||
@@ -125,7 +115,21 @@ const MessageWrapper = ({ selectMode = false, context, id, mid, divider, childre
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}, (prevProps, nextProps) => {
|
||||||
|
// Optimize re-renders by comparing only necessary props
|
||||||
|
// Compare divider by value, not reference
|
||||||
|
const dividerSame = prevProps.divider === nextProps.divider ||
|
||||||
|
(prevProps.divider?.props?.content === nextProps.divider?.props?.content);
|
||||||
|
|
||||||
|
return (
|
||||||
|
prevProps.mid === nextProps.mid &&
|
||||||
|
prevProps.selectMode === nextProps.selectMode &&
|
||||||
|
prevProps.selected === nextProps.selected &&
|
||||||
|
prevProps.context === nextProps.context &&
|
||||||
|
prevProps.id === nextProps.id &&
|
||||||
|
dividerSame
|
||||||
|
);
|
||||||
|
});
|
||||||
type Params = {
|
type Params = {
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
selectMode: boolean;
|
selectMode: boolean;
|
||||||
@@ -135,6 +139,8 @@ type Params = {
|
|||||||
curr: object | null;
|
curr: object | null;
|
||||||
contextId: number;
|
contextId: number;
|
||||||
context: ChatContext;
|
context: ChatContext;
|
||||||
|
selected?: boolean;
|
||||||
|
toggleSelect?: () => void;
|
||||||
};
|
};
|
||||||
export const renderMessageFragment = ({
|
export const renderMessageFragment = ({
|
||||||
readonly = false,
|
readonly = false,
|
||||||
@@ -144,7 +150,9 @@ export const renderMessageFragment = ({
|
|||||||
prev,
|
prev,
|
||||||
curr = null,
|
curr = null,
|
||||||
contextId = 0,
|
contextId = 0,
|
||||||
context = "dm"
|
context = "dm",
|
||||||
|
selected = false,
|
||||||
|
toggleSelect
|
||||||
}: Params) => {
|
}: Params) => {
|
||||||
if (!curr) return <div className="w-full h-[1px] invisible"></div>;
|
if (!curr) return <div className="w-full h-[1px] invisible"></div>;
|
||||||
let { created_at, mid } = curr;
|
let { created_at, mid } = curr;
|
||||||
@@ -169,6 +177,8 @@ export const renderMessageFragment = ({
|
|||||||
id={contextId}
|
id={contextId}
|
||||||
mid={mid}
|
mid={mid}
|
||||||
selectMode={selectMode}
|
selectMode={selectMode}
|
||||||
|
selected={selected}
|
||||||
|
toggleSelect={toggleSelect}
|
||||||
divider={divider ? <Divider className="w-full" content={divider}></Divider> : null}
|
divider={divider ? <Divider className="w-full" content={divider}></Divider> : null}
|
||||||
>
|
>
|
||||||
<Message
|
<Message
|
||||||
|
|||||||
Reference in New Issue
Block a user