Skip to content

Commit

Permalink
feat: virtual-list
Browse files Browse the repository at this point in the history
  • Loading branch information
clement2026 committed Sep 25, 2023
1 parent dada4de commit 5bc5a55
Show file tree
Hide file tree
Showing 6 changed files with 121 additions and 104 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/api/sse/sse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
184 changes: 101 additions & 83 deletions src/home/chat-window/message-list/message-list.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand All @@ -20,74 +22,49 @@ type MLProps = {
export const MessageList: React.FC<MLProps> = ({chatProxy}) => {
// console.info("MessageList rendered", new Date().toLocaleString())
const [messages, setMessages] = useState<Message[]>([])
const scrollEndRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const scrollEndRef = useRef<HTMLDivElement>(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()
return un
// 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, () => {
Expand All @@ -96,16 +73,29 @@ export const MessageList: React.FC<MLProps> = ({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
Expand Down Expand Up @@ -157,28 +147,54 @@ export const MessageList: React.FC<MLProps> = ({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 (
<div ref={containerRef}
className="w-full overflow-y-auto pr-1 scrollbar-hidden hover:scrollbar-visible">
<div className="flex w-full select-text flex-col justify-end gap-3 rounded-2xl">
{/*crucial; don't merge the 2 divs above*/}
{messages.map((msg) =>
msg.status !== 'deleted' &&
<ErrorBoundary key={msg.id}>
<Row chatId={chatProxy.id}
messageProxy={msg}
/>
</ErrorBoundary>
)}
</div>
<div
ref={scrollEndRef}
className="h-10 select-none bg-transparent text-transparent" data-pseudo-content="ninja"/>
<ToBottomButton action={buttonScrollAction}/>
style={{
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${items[0]?.start ?? 0}px)`,
}}
>
{items.map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className="py-1.5"
>
<ErrorBoundary>
<Row chatId={chatProxy.id}
messageProxy={messages[virtualRow.index]}
/>
</ErrorBoundary>
</div>
))}
</div>
</div>
<div ref={scrollEndRef}></div>
<ToBottomButton action={() =>
virtualizer.scrollToIndex(count - 1, {align: "end", behavior: "smooth"})
}/>
</div>
)
}
Expand All @@ -189,11 +205,13 @@ type TBProps = {
const ToBottomButton: React.FC<TBProps> = ({action}) => {
const {isMessageListOverflow, isMessageListAtBottom} = useSnapshot(layoutState)

return <div className="sticky bottom-1 flex justify-end pr-3 z-40">
<HiOutlineChevronDown
className={cx("h-8 w-8 p-1.5 bg-neutral-100 rounded-full",
isMessageListOverflow && !isMessageListAtBottom ? "" : "hidden")}
onClick={action}
/>
return <div className="sticky bottom-1 flex justify-end pr-3">
<div className="relative">
<HiOutlineChevronDown
className={cx("absolute right-0 bottom-0 h-8 w-8 p-1.5 bg-neutral-100 rounded-full",
isMessageListOverflow && !isMessageListAtBottom ? "" : "hidden")}
onClick={action}
/>
</div>
</div>
}
19 changes: 3 additions & 16 deletions src/util/util.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = []

Expand All @@ -17,20 +17,6 @@ export const base64ToBlob = (base64String: string, mimeType: string): Blob => {
return new Blob([byteArray], {type: mimeType})
}

export function blobToBase64(blob: Blob): Promise<string> {
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)
Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 2 additions & 3 deletions src/worker/subscribe-sending-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -100,7 +99,7 @@ export const SubscribeSendingMessage: React.FC = () => {
})

if (sm.audioBlob) {
const audioId = generateUudioId("recording")
const audioId = generateAudioId("recording")
audioDb.setItem<Blob>(audioId, sm.audioBlob as Blob, (err, value) => {
if (err || !value) {
console.debug("failed to save audio blob, audioId:", audioId, err)
Expand Down
12 changes: 12 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected]":
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"
Expand Down

0 comments on commit 5bc5a55

Please sign in to comment.