feat: message list implements virtual scrolling

This commit is contained in:
molvqingtai 2024-09-19 22:52:19 +08:00
parent 89e20a65db
commit c9388c744e
8 changed files with 45 additions and 29 deletions

View file

@ -69,6 +69,7 @@
"react-hook-form": "^7.51.0", "react-hook-form": "^7.51.0",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-use": "^17.5.0", "react-use": "^17.5.0",
"react-virtuoso": "^4.10.4",
"remark-breaks": "^4.0.0", "remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remesh": "^4.2.2", "remesh": "^4.2.2",

View file

@ -83,6 +83,9 @@ importers:
react-use: react-use:
specifier: ^17.5.0 specifier: ^17.5.0
version: 17.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 17.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-virtuoso:
specifier: ^4.10.4
version: 4.10.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
remark-breaks: remark-breaks:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.0.0 version: 4.0.0
@ -4796,6 +4799,13 @@ packages:
react: '*' react: '*'
react-dom: '*' react-dom: '*'
react-virtuoso@4.10.4:
resolution: {integrity: sha512-G/gprhTbK+lzMxoo/iStcZxVEGph/cIhc3WANEpt92RuMw+LiCZOmBfKoeoZOHlm/iyftTrDJhGaTCpxyucnkQ==}
engines: {node: '>=10'}
peerDependencies:
react: '>=16 || >=17 || >= 18'
react-dom: '>=16 || >=17 || >= 18'
react@18.3.1: react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -11333,6 +11343,11 @@ snapshots:
ts-easing: 0.2.0 ts-easing: 0.2.0
tslib: 2.7.0 tslib: 2.7.0
react-virtuoso@4.10.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react@18.3.1: react@18.3.1:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0

View file

@ -24,10 +24,7 @@ const MessageItem: FC<MessageItemProps> = (props) => {
props.onHateChange?.(checked) props.onHateChange?.(checked)
} }
return ( return (
<div <div data-index={props.index} className="box-border grid grid-cols-[auto_1fr] gap-x-2 px-4 first:pt-4 last:pb-4">
data-index={props.index}
className="box-border grid grid-cols-[auto_1fr] gap-x-2 px-4 [content-visibility:auto] first:pt-4 last:pb-4"
>
<Avatar> <Avatar>
<AvatarImage src={props.data.userAvatar} alt="avatar" /> <AvatarImage src={props.data.userAvatar} alt="avatar" />
<AvatarFallback>{props.data.username.at(0)}</AvatarFallback> <AvatarFallback>{props.data.username.at(0)}</AvatarFallback>

View file

@ -1,20 +1,26 @@
import { type ReactElement } from 'react' import { FC, useRef, type ReactElement } from 'react'
import React from 'react'
import { type MessageItemProps } from './MessageItem' import { type MessageItemProps } from './MessageItem'
import { ScrollArea } from '@/components/ui/ScrollArea' import { ScrollArea } from '@/components/ui/ScrollArea'
import { Virtuoso } from 'react-virtuoso'
export interface MessageListProps { export interface MessageListProps {
children?: Array<ReactElement<MessageItemProps>> children?: Array<ReactElement<MessageItemProps>>
} }
// [&>div>div]:!block fix word-break: break-word; const MessageList: FC<MessageListProps> = ({ children }) => {
const MessageList = React.forwardRef<HTMLDivElement, MessageListProps>(({ children }, ref) => { const scrollParentRef = useRef<HTMLDivElement | null>(null)
return ( return (
<ScrollArea ref={ref} className="[&>div>div]:!block"> <ScrollArea ref={scrollParentRef}>
{children} <Virtuoso
followOutput={(isAtBottom: boolean) => (isAtBottom ? 'smooth' : 'auto')}
initialTopMostItemIndex={{ index: 'LAST', align: 'end' }}
data={children}
customScrollParent={scrollParentRef.current!}
itemContent={(_: any, item: ReactElement<MessageItemProps>) => item}
/>
</ScrollArea> </ScrollArea>
) )
}) }
MessageList.displayName = 'MessageList' MessageList.displayName = 'MessageList'

View file

@ -17,9 +17,6 @@ const Main: FC = () => {
like: message.likeUsers.some((likeUser) => likeUser.userId === userInfo?.id), like: message.likeUsers.some((likeUser) => likeUser.userId === userInfo?.id),
hate: message.hateUsers.some((hateUser) => hateUser.userId === userInfo?.id) hate: message.hateUsers.some((hateUser) => hateUser.userId === userInfo?.id)
})) }))
const messageListRef = useRef<HTMLDivElement>(null)
const isUpdate = useRef(false)
const handleLikeChange = (messageId: string) => { const handleLikeChange = (messageId: string) => {
send(roomDomain.command.SendLikeMessageCommand(messageId)) send(roomDomain.command.SendLikeMessageCommand(messageId))
@ -29,20 +26,20 @@ const Main: FC = () => {
send(roomDomain.command.SendHateMessageCommand(messageId)) send(roomDomain.command.SendHateMessageCommand(messageId))
} }
useEffect(() => { // useEffect(() => {
const lastMessageRef = messageListRef.current?.querySelector('[data-index]:last-child') // const lastMessageRef = messageListRef.current?.querySelector('[data-index]:last-child')
const timerId = setTimeout(() => { // const timerId = setTimeout(() => {
requestAnimationFrame(() => { // requestAnimationFrame(() => {
lastMessageRef?.scrollIntoView({ behavior: isUpdate.current ? 'smooth' : 'instant', block: 'end' }) // lastMessageRef?.scrollIntoView({ behavior: isUpdate.current ? 'smooth' : 'instant', block: 'end' })
isUpdate.current = true // isUpdate.current = true
}) // })
}, 0) // }, 0)
return () => clearTimeout(timerId) // return () => clearTimeout(timerId)
}, [messageList.length]) // }, [messageList.length])
return ( return (
<MessageList ref={messageListRef}> <MessageList>
{messageList.map((message, index) => ( {messageList.map((message, index) => (
<MessageItem <MessageItem
key={message.id} key={message.id}

View file

@ -50,7 +50,7 @@ const AvatarSelect = React.forwardRef<HTMLInputElement, AvatarSelectProps>(
<Avatar <Avatar
tabIndex={disabled ? -1 : 1} tabIndex={disabled ? -1 : 1}
className={cn( className={cn(
'group h-20 w-20 cursor-pointer border-4 border-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background', 'group h-24 w-24 cursor-pointer border-4 border-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
{ {
'cursor-not-allowed': disabled, 'cursor-not-allowed': disabled,
'opacity-50': disabled 'opacity-50': disabled

View file

@ -134,7 +134,7 @@ const ProfileForm = () => {
onClick={handleRandomAvatar} onClick={handleRandomAvatar}
> >
<RefreshCcwIcon size={14} /> <RefreshCcwIcon size={14} />
Random Avatar Ugly Avatar
</Button> </Button>
</div> </div>
</FormControl> </FormControl>

View file

@ -7,8 +7,8 @@ const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>, React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}> <ScrollAreaPrimitive.Root className={cn('relative overflow-hidden', className)} {...props}>
<ScrollAreaPrimitive.Viewport className="size-full overscroll-none rounded-[inherit]"> <ScrollAreaPrimitive.Viewport ref={ref} className="size-full overscroll-none rounded-[inherit]">
{children} {children}
</ScrollAreaPrimitive.Viewport> </ScrollAreaPrimitive.Viewport>
<ScrollBar /> <ScrollBar />