From 5bc5a553783290ab3e45ad733975c277f9ddf7f7 Mon Sep 17 00:00:00 2001 From: Clement Date: Mon, 25 Sep 2023 15:31:25 +0800 Subject: [PATCH] feat: virtual-list --- package.json | 1 + src/api/sse/sse.tsx | 4 +- .../chat-window/message-list/message-list.tsx | 184 ++++++++++-------- src/util/util.tsx | 19 +- src/worker/subscribe-sending-message.tsx | 5 +- yarn.lock | 12 ++ 6 files changed, 121 insertions(+), 104 deletions(-) diff --git a/package.json b/package.json index 36d14d6..a9c9e2b 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@headlessui/react": "^1.7.17", "@microsoft/fetch-event-source": "^2.0.1", + "@tanstack/react-virtual": "^3.0.0-beta.60", "axios": "^1.5.0", "crypto-js": "^4.1.1", "date-fns": "^2.30.0", diff --git a/src/api/sse/sse.tsx b/src/api/sse/sse.tsx index 4e2bc41..e067fb0 100644 --- a/src/api/sse/sse.tsx +++ b/src/api/sse/sse.tsx @@ -16,7 +16,7 @@ import { SSEMsgMeta, SSEMsgText } from "./event.ts" -import {base64ToBlob, generateUudioId, randomHash32Char} from "../../util/util.tsx" +import {base64ToBlob, generateAudioId, randomHash32Char} from "../../util/util.tsx" import {audioDb} from "../../state/db.ts" import {audioPlayerMimeType, SSEEndpoint} from "../../config.ts" import {adjustOption} from "../../data-structure/client-option.tsx" @@ -93,7 +93,7 @@ export const SSE = () => { const msg = findMessage2(audio.chatId, audio.messageID, true) if (msg) { const blob = base64ToBlob(audio.audio, audioPlayerMimeType) - const audioId = generateUudioId("synthesis") + const audioId = generateAudioId("synthesis") audioDb.setItem(audioId, blob, () => { onAudio(msg, {id: audioId, durationMs: audio.durationMs}) }).then(() => true) diff --git a/src/home/chat-window/message-list/message-list.tsx b/src/home/chat-window/message-list/message-list.tsx index 5a71aba..438cf54 100644 --- a/src/home/chat-window/message-list/message-list.tsx +++ b/src/home/chat-window/message-list/message-list.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useRef, useState} from "react" +import React, {useEffect, useRef, useState} from "react" import {appState, Chat} from "../../../state/app-state.ts" import {isInHistory, Message} from "../../../data-structure/message.tsx" import ErrorBoundary from "../compnent/error-boundary.tsx" @@ -12,6 +12,8 @@ import {subscribeKey} from "valtio/utils"; import {subscribe} from "valtio"; import {layoutState} from "../../../state/layout-state.ts"; import {clearMessageState, setMState} from "../../../state/message-state.ts"; +import {useVirtualizer} from "@tanstack/react-virtual"; +import {throttle} from "lodash"; type MLProps = { chatProxy: Chat @@ -20,15 +22,20 @@ type MLProps = { export const MessageList: React.FC = ({chatProxy}) => { // console.info("MessageList rendered", new Date().toLocaleString()) const [messages, setMessages] = useState([]) - const scrollEndRef = useRef(null) const containerRef = useRef(null) + const scrollEndRef = useRef(null) - const lastState = useRef<{ id: string, updatedAt: number }>({id: "", updatedAt: 0}) + const lastState = useRef<{ id: string, updatedAt: number, audioDuration: number }>({ + id: "", + updatedAt: 0, + audioDuration: 0 + }) useEffect(() => { const callBack = () => { - console.info("length changed, should rerender", new Date().toLocaleString()) - setMessages(chatProxy.messages.slice()) + setMessages(chatProxy.messages + .filter(it => it.status !== 'deleted') + ) } const un = subscribeKey(chatProxy.messages, "length", callBack) callBack() @@ -36,58 +43,28 @@ export const MessageList: React.FC = ({chatProxy}) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const scrollToBottom = useCallback((behavior?: 'instant' | 'smooth') => { - if (scrollEndRef.current) { - scrollEndRef.current.scrollIntoView({behavior: behavior ?? "instant"}) - } - }, []) - - useEffect(() => { - setTimeout(() => { - console.info( - "message length when scrolling:", chatProxy.messages.length) - - scrollToBottom() - }, 1) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - - useEffect(() => { - const observer = new MutationObserver(() => { - if (containerRef.current) { - layoutState.isMessageListOverflow = containerRef.current.scrollHeight > containerRef.current.clientHeight - } - }) - - if (containerRef.current) { - observer.observe(containerRef.current, { - attributes: true, - childList: true, - subtree: true, - attributeOldValue: true, - attributeFilter: ['style', 'class'], - }) - } - return () => observer.disconnect() - }, []) - useEffect(() => { - const container = containerRef.current - - const handleScroll = () => { - if (container) { - const {scrollTop, scrollHeight, clientHeight} = container - layoutState.isMessageListAtBottom = scrollTop + clientHeight >= scrollHeight - 200 + const count = messages.length + const virtualizer = useVirtualizer({ + count: messages.length, + overscan: 20, + onChange: (v) => { + if (v.scrollElement) { + layoutState.isMessageListOverflow = v.scrollElement.scrollHeight > v.scrollElement.clientHeight + const {scrollTop, scrollHeight, clientHeight} = v.scrollElement + layoutState.isMessageListAtBottom = scrollTop + clientHeight >= scrollHeight - 100 } - } + }, + getScrollElement: () => containerRef.current, + estimateSize: () => 100, + }) + const items = virtualizer.getVirtualItems() - container?.addEventListener('scroll', handleScroll) - - return () => { - container?.removeEventListener('scroll', handleScroll) + const scrollToBottom = throttle((behavior?: 'instant' | 'smooth') => { + if (scrollEndRef.current) { + scrollEndRef.current.scrollIntoView({behavior: behavior ?? "instant"}) } - }, []) + }, 500) useEffect(() => { subscribe(chatProxy.messages, () => { @@ -96,16 +73,29 @@ export const MessageList: React.FC = ({chatProxy}) => { const msg = chatProxy.messages[len - 1] if (lastState.current.id !== "") { if (msg.id !== lastState.current.id || - msg.lastUpdatedAt > lastState.current.updatedAt) { - if (scrollEndRef.current && layoutState.isMessageListAtBottom) { - scrollEndRef.current.scrollIntoView({behavior: 'instant'}) - } - if (msg.audio?.id && msg.status === "received") { - addToPlayList(msg.audio.id) + msg.lastUpdatedAt > lastState.current.updatedAt || + (msg.audio?.durationMs ?? 0) > lastState.current.audioDuration + ) { + if (layoutState.isMessageListAtBottom) { + // Avoid using virtualizer.scrollToIndex for scrolling, as it doesn't perform optimally in dynamic mode + scrollToBottom("smooth") } } + // Limitation: In the event of multiple simultaneous audio arrivals, only the most recent audio may + // have an opportunity to be added to the playlist + if (msg.id === lastState.current.id && + msg.lastUpdatedAt > lastState.current.updatedAt && + msg.audio && + (msg.audio.durationMs ?? 0) > lastState.current.audioDuration + ) { + addToPlayList(msg.audio.id) + } } - lastState.current = ({id: msg.id, updatedAt: msg.lastUpdatedAt}) + lastState.current = ({ + id: msg.id, + updatedAt: msg.lastUpdatedAt, + audioDuration: msg.audio?.durationMs ?? 0 + }) } }) // eslint-disable-next-line react-hooks/exhaustive-deps @@ -157,28 +147,54 @@ export const MessageList: React.FC = ({chatProxy}) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - const buttonScrollAction = useCallback(() => { - scrollToBottom("smooth") - }, [scrollToBottom]); + useEffect(() => { + // only scroll to bottom once + if (count > 0 && lastState.current.id === "") { + // It is recommended to use virtualizer.scrollToIndex instead of scrollToBottom as the latter may not be + // accurate until the virtualizer has fully initialized. + virtualizer.scrollToIndex(count - 1, {align: "end"}) + } + }, [count, virtualizer]) return (
-
- {/*crucial; don't merge the 2 divs above*/} - {messages.map((msg) => - msg.status !== 'deleted' && - - - - )} -
- + style={{ + height: virtualizer.getTotalSize(), + width: '100%', + position: 'relative', + }} + > +
+ {items.map((virtualRow) => ( +
+ + + +
+ ))} +
+
+
+ + virtualizer.scrollToIndex(count - 1, {align: "end", behavior: "smooth"}) + }/>
) } @@ -189,11 +205,13 @@ type TBProps = { const ToBottomButton: React.FC = ({action}) => { const {isMessageListOverflow, isMessageListAtBottom} = useSnapshot(layoutState) - return
- + return
+
+ +
} \ No newline at end of file diff --git a/src/util/util.tsx b/src/util/util.tsx index 079d64d..2e97c05 100644 --- a/src/util/util.tsx +++ b/src/util/util.tsx @@ -5,7 +5,7 @@ import {RecordingMimeType} from "../config.ts" import {floor} from "lodash" export const base64ToBlob = (base64String: string, mimeType: string): Blob => { - console.debug("decoding base64(truncated to 100 chars)", base64String.slice(0, 100)) + console.debug("decoding base64(truncated to 20 chars)", base64String.slice(0, 20)) const byteCharacters = atob(base64String) const byteNumbers: number[] = [] @@ -17,20 +17,6 @@ export const base64ToBlob = (base64String: string, mimeType: string): Blob => { return new Blob([byteArray], {type: mimeType}) } -export function blobToBase64(blob: Blob): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onloadend = () => { - let base64Data = reader.result as string - // Remove MIME type - base64Data = base64Data.split(",")[1] - resolve(base64Data) - } - reader.onerror = reject - reader.readAsDataURL(blob) - }) -} - // duration is in ms export const timeElapsedMMSS = (duration: number): string => { let seconds = Math.floor(duration / 1000) @@ -99,7 +85,7 @@ export const formatAudioDuration = (duration?: number): string => { } -export const generateUudioId = (action: "recording" | "synthesis"): string => { +export const generateAudioId = (action: "recording" | "synthesis"): string => { return action + "-" + formatNow() + "-" + randomHash16Char() } @@ -161,6 +147,7 @@ export const randomHash32Char = (): string => { return SHA256(str).toString().slice(0, 32) } +// noinspection SpellCheckingInspection const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" function randomString(length: number): string { diff --git a/src/worker/subscribe-sending-message.tsx b/src/worker/subscribe-sending-message.tsx index e784faf..02ac5f8 100644 --- a/src/worker/subscribe-sending-message.tsx +++ b/src/worker/subscribe-sending-message.tsx @@ -7,7 +7,7 @@ import {findChatProxy, findMessage} from "../state/app-state.ts" import {historyMessages} from "../api/restful/util.ts" import {newSending, onAudio, onError, onSent} from "../data-structure/message.tsx" import {postAudioChat, postChat} from "../api/restful/api.ts" -import {generateUudioId} from "../util/util.tsx" +import {generateAudioId} from "../util/util.tsx" import {audioDb} from "../state/db.ts" import {LLMMessage} from "../shared-types.ts" import {minSpeakTimeMillis} from "../config.ts" @@ -68,7 +68,6 @@ export const SubscribeSendingMessage: React.FC = () => { messages.push({role: "user", content: sm.text}) nonProxyMessage.text = sm.text chatProxy.messages.push(nonProxyMessage) - console.debug("sending chat, chatId,messages: ", chatProxy.id, messages) postPromise = postChat({ chatId: chatProxy.id, ticketId: nonProxyMessage.ticketId, @@ -100,7 +99,7 @@ export const SubscribeSendingMessage: React.FC = () => { }) if (sm.audioBlob) { - const audioId = generateUudioId("recording") + const audioId = generateAudioId("recording") audioDb.setItem(audioId, sm.audioBlob as Blob, (err, value) => { if (err || !value) { console.debug("failed to save audio blob, audioId:", audioId, err) diff --git a/yarn.lock b/yarn.lock index 25bf906..92ced62 100644 --- a/yarn.lock +++ b/yarn.lock @@ -585,6 +585,18 @@ lodash.merge "^4.6.2" postcss-selector-parser "6.0.10" +"@tanstack/react-virtual@^3.0.0-beta.60": + version "3.0.0-beta.60" + resolved "https://registry.npmmirror.com/@tanstack/react-virtual/-/react-virtual-3.0.0-beta.60.tgz#2b37c0d72997a54f7927f6b159a77311429fec1e" + integrity sha512-F0wL9+byp7lf/tH6U5LW0ZjBqs+hrMXJrj5xcIGcklI0pggvjzMNW9DdIBcyltPNr6hmHQ0wt8FDGe1n1ZAThA== + dependencies: + "@tanstack/virtual-core" "3.0.0-beta.60" + +"@tanstack/virtual-core@3.0.0-beta.60": + version "3.0.0-beta.60" + resolved "https://registry.npmmirror.com/@tanstack/virtual-core/-/virtual-core-3.0.0-beta.60.tgz#fcac07cb182d41929208899062de8c9510cf42ed" + integrity sha512-QlCdhsV1+JIf0c0U6ge6SQmpwsyAT0oQaOSZk50AtEeAyQl9tQrd6qCHAslxQpgphrfe945abvKG8uYvw3hIGA== + "@types/crypto-js@^4.1.1": version "4.1.2" resolved "https://registry.npmmirror.com/@types/crypto-js/-/crypto-js-4.1.2.tgz#fb56b34f397d9ae2335611e416f15e7d65e276e6"