Improve scroll anchor (#275)

This commit is contained in:
Jeremy 2024-03-20 04:37:08 +03:00 committed by GitHub
parent b6cab643ef
commit 43c7cbb21e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 169 additions and 101 deletions

View file

@ -8,9 +8,7 @@ export default async function ChatLayout({ children }: ChatLayoutProps) {
return ( return (
<div className="relative flex h-[calc(100vh_-_theme(spacing.16))] overflow-hidden"> <div className="relative flex h-[calc(100vh_-_theme(spacing.16))] overflow-hidden">
<SidebarDesktop /> <SidebarDesktop />
<div className="group w-full overflow-auto pl-0 animate-in duration-300 ease-in-out peer-[[data-state=open]]:lg:pl-[250px] peer-[[data-state=open]]:xl:pl-[300px]"> {children}
{children}
</div>
</div> </div>
) )
} }

View file

@ -3,13 +3,20 @@
import * as React from 'react' import * as React from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useAtBottom } from '@/lib/hooks/use-at-bottom'
import { Button, type ButtonProps } from '@/components/ui/button' import { Button, type ButtonProps } from '@/components/ui/button'
import { IconArrowDown } from '@/components/ui/icons' import { IconArrowDown } from '@/components/ui/icons'
export function ButtonScrollToBottom({ className, ...props }: ButtonProps) { interface ButtonScrollToBottomProps extends ButtonProps {
const isAtBottom = useAtBottom() isAtBottom: boolean
scrollToBottom: () => void
}
export function ButtonScrollToBottom({
className,
isAtBottom,
scrollToBottom,
...props
}: ButtonScrollToBottomProps) {
return ( return (
<Button <Button
variant="outline" variant="outline"
@ -19,12 +26,7 @@ export function ButtonScrollToBottom({ className, ...props }: ButtonProps) {
isAtBottom ? 'opacity-0' : 'opacity-100', isAtBottom ? 'opacity-0' : 'opacity-100',
className className
)} )}
onClick={() => onClick={() => scrollToBottom()}
window.scrollTo({
top: document.body.offsetHeight,
behavior: 'smooth'
})
}
{...props} {...props}
> >
<IconArrowDown /> <IconArrowDown />

View file

@ -2,6 +2,7 @@ import { Separator } from '@/components/ui/separator'
import { UIState } from '@/lib/chat/actions' import { UIState } from '@/lib/chat/actions'
import { Session } from '@/lib/types' import { Session } from '@/lib/types'
import Link from 'next/link' import Link from 'next/link'
import { ExclamationTriangleIcon } from '@radix-ui/react-icons'
export interface ChatList { export interface ChatList {
messages: UIState messages: UIState
@ -17,19 +18,27 @@ export function ChatList({ messages, session, isShared }: ChatList) {
return ( return (
<div className="relative mx-auto max-w-2xl px-4"> <div className="relative mx-auto max-w-2xl px-4">
{!isShared && !session ? ( {!isShared && !session ? (
<div className="mb-8 rounded-lg border bg-white p-4 dark:bg-zinc-950"> <>
<p className="text-muted-foreground leading-normal"> <div className="group relative mb-4 flex items-start md:-ml-12">
Please{' '} <div className="bg-background flex size-[25px] shrink-0 select-none items-center justify-center rounded-md border shadow-sm">
<Link href="/login" className="underline"> <ExclamationTriangleIcon />
log in </div>
</Link>{' '} <div className="ml-4 flex-1 space-y-2 overflow-hidden px-1">
or{' '} <p className="text-muted-foreground leading-normal">
<Link href="/signup" className="underline"> Please{' '}
sign up <Link href="/login" className="underline">
</Link>{' '} log in
to save and revisit your chat history! </Link>{' '}
</p> or{' '}
</div> <Link href="/signup" className="underline">
sign up
</Link>{' '}
to save and revisit your chat history!
</p>
</div>
</div>
<Separator className="my-4" />
</>
) : null} ) : null}
{messages.map((message, index) => ( {messages.map((message, index) => (

View file

@ -17,9 +17,18 @@ export interface ChatPanelProps {
title?: string title?: string
input: string input: string
setInput: (value: string) => void setInput: (value: string) => void
isAtBottom: boolean
scrollToBottom: () => void
} }
export function ChatPanel({ id, title, input, setInput }: ChatPanelProps) { export function ChatPanel({
id,
title,
input,
setInput,
isAtBottom,
scrollToBottom
}: ChatPanelProps) {
const [aiState] = useAIState() const [aiState] = useAIState()
const [messages, setMessages] = useUIState<typeof AI>() const [messages, setMessages] = useUIState<typeof AI>()
const { submitUserMessage } = useActions() const { submitUserMessage } = useActions()
@ -33,24 +42,27 @@ export function ChatPanel({ id, title, input, setInput }: ChatPanelProps) {
}, },
{ {
heading: 'What is the price of', heading: 'What is the price of',
subheading: 'DOGE in the stock market?', subheading: '$DOGE right now?',
message: 'What is the price of DOGE in the stock market?' message: 'What is the price of $DOGE right now?'
}, },
{ {
heading: 'I would like to buy', heading: 'I would like to buy',
subheading: '42 DOGE coins', subheading: '42 $DOGE',
message: `I would like to buy 42 DOGE coins` message: `I would like to buy 42 $DOGE`
}, },
{ {
heading: 'What are some', heading: 'What are some',
subheading: `recent events about DOGE?`, subheading: `recent events about $DOGE?`,
message: `What are some recent events about DOGE?` message: `What are some recent events about $DOGE?`
} }
] ]
return ( return (
<div className="fixed inset-x-0 bottom-0 w-full bg-gradient-to-b from-muted/30 from-0% to-muted/30 to-50% duration-300 ease-in-out animate-in dark:from-background/10 dark:from-10% dark:to-background/80 peer-[[data-state=open]]:group-[]:lg:pl-[250px] peer-[[data-state=open]]:group-[]:xl:pl-[300px]"> <div className="fixed inset-x-0 bottom-0 w-full bg-gradient-to-b from-muted/30 from-0% to-muted/30 to-50% duration-300 ease-in-out animate-in dark:from-background/10 dark:from-10% dark:to-background/80 peer-[[data-state=open]]:group-[]:lg:pl-[250px] peer-[[data-state=open]]:group-[]:xl:pl-[300px]">
<ButtonScrollToBottom /> <ButtonScrollToBottom
isAtBottom={isAtBottom}
scrollToBottom={scrollToBottom}
/>
<div className="mx-auto sm:max-w-2xl sm:px-4"> <div className="mx-auto sm:max-w-2xl sm:px-4">
<div className="mb-4 grid grid-cols-2 gap-2 px-4 sm:px-0"> <div className="mb-4 grid grid-cols-2 gap-2 px-4 sm:px-0">

View file

@ -1,28 +0,0 @@
'use client'
import * as React from 'react'
import { useInView } from 'react-intersection-observer'
import { useAtBottom } from '@/lib/hooks/use-at-bottom'
interface ChatScrollAnchorProps {
trackVisibility?: boolean
}
export function ChatScrollAnchor({ trackVisibility }: ChatScrollAnchorProps) {
const isAtBottom = useAtBottom()
const { ref, entry, inView } = useInView({
trackVisibility,
delay: 100,
rootMargin: '0px 0px -125px 0px'
})
React.useEffect(() => {
if (isAtBottom && trackVisibility && !inView) {
entry?.target.scrollIntoView({
block: 'start'
})
}
}, [inView, entry, isAtBottom, trackVisibility])
return <div ref={ref} className="h-px w-full" />
}

View file

@ -4,13 +4,13 @@ import { cn } from '@/lib/utils'
import { ChatList } from '@/components/chat-list' import { ChatList } from '@/components/chat-list'
import { ChatPanel } from '@/components/chat-panel' import { ChatPanel } from '@/components/chat-panel'
import { EmptyScreen } from '@/components/empty-screen' import { EmptyScreen } from '@/components/empty-screen'
import { ChatScrollAnchor } from '@/components/chat-scroll-anchor'
import { useLocalStorage } from '@/lib/hooks/use-local-storage' import { useLocalStorage } from '@/lib/hooks/use-local-storage'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useUIState, useAIState } from 'ai/rsc' import { useUIState, useAIState } from 'ai/rsc'
import { Session } from '@/lib/types' import { Session } from '@/lib/types'
import { usePathname, useRouter } from 'next/navigation' import { usePathname, useRouter } from 'next/navigation'
import { Message } from '@/lib/chat/actions' import { Message } from '@/lib/chat/actions'
import { useScrollAnchor } from '@/lib/hooks/use-scroll-anchor'
import { toast } from 'sonner' import { toast } from 'sonner'
export interface ChatProps extends React.ComponentProps<'div'> { export interface ChatProps extends React.ComponentProps<'div'> {
@ -26,7 +26,6 @@ export function Chat({ id, className, session, missingKeys }: ChatProps) {
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [messages] = useUIState() const [messages] = useUIState()
const [aiState] = useAIState() const [aiState] = useAIState()
const isLoading = true
const [_, setNewChatId] = useLocalStorage('newChatId', id) const [_, setNewChatId] = useLocalStorage('newChatId', id)
@ -55,19 +54,32 @@ export function Chat({ id, className, session, missingKeys }: ChatProps) {
}) })
}, [missingKeys]) }, [missingKeys])
const { messagesRef, scrollRef, visibilityRef, isAtBottom, scrollToBottom } =
useScrollAnchor()
return ( return (
<> <div
<div className={cn('pb-[200px] pt-4 md:pt-10', className)}> className="group w-full overflow-auto pl-0 peer-[[data-state=open]]:lg:pl-[250px] peer-[[data-state=open]]:xl:pl-[300px]"
ref={scrollRef}
>
<div
className={cn('pb-[200px] pt-4 md:pt-10', className)}
ref={messagesRef}
>
{messages.length ? ( {messages.length ? (
<> <ChatList messages={messages} isShared={false} session={session} />
<ChatList messages={messages} isShared={false} session={session} />
<ChatScrollAnchor trackVisibility={isLoading} />
</>
) : ( ) : (
<EmptyScreen setInput={setInput} /> <EmptyScreen />
)} )}
<div className="h-px w-full" ref={visibilityRef} />
</div> </div>
<ChatPanel id={id} input={input} setInput={setInput} /> <ChatPanel
</> id={id}
input={input}
setInput={setInput}
isAtBottom={isAtBottom}
scrollToBottom={scrollToBottom}
/>
</div>
) )
} }

View file

@ -19,7 +19,7 @@ const exampleMessages = [
} }
] ]
export function EmptyScreen({ setInput }: Pick<UseChatHelpers, 'setInput'>) { export function EmptyScreen() {
return ( return (
<div className="mx-auto max-w-2xl px-4"> <div className="mx-auto max-w-2xl px-4">
<div className="flex flex-col gap-2 rounded-lg border bg-background p-8"> <div className="flex flex-col gap-2 rounded-lg border bg-background p-8">

View file

@ -60,12 +60,12 @@ export function SidebarItem({ index, chat, children }: SidebarItemProps) {
tabIndex={-1} tabIndex={-1}
className="focus:bg-muted focus:ring-1 focus:ring-ring" className="focus:bg-muted focus:ring-1 focus:ring-ring"
> >
<IconUsers className="mr-2" /> <IconUsers className="mr-2 mt-1 text-zinc-500" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>This is a shared chat.</TooltipContent> <TooltipContent>This is a shared chat.</TooltipContent>
</Tooltip> </Tooltip>
) : ( ) : (
<IconMessage className="mr-2" /> <IconMessage className="mr-2 mt-1 text-zinc-500" />
)} )}
</div> </div>
<Link <Link

View file

@ -18,7 +18,7 @@ export function UserMessage({ children }: { children: React.ReactNode }) {
<div className="flex size-[25px] shrink-0 select-none items-center justify-center rounded-md border bg-background shadow-sm"> <div className="flex size-[25px] shrink-0 select-none items-center justify-center rounded-md border bg-background shadow-sm">
<IconUser /> <IconUser />
</div> </div>
<div className="ml-4 flex-1 space-y-2 overflow-hidden px-1"> <div className="ml-4 flex-1 space-y-2 overflow-hidden pl-2">
{children} {children}
</div> </div>
</div> </div>
@ -103,7 +103,7 @@ export function BotCard({
> >
<IconOpenAI /> <IconOpenAI />
</div> </div>
<div className="ml-4 flex-1 px-1">{children}</div> <div className="ml-4 flex-1 pl-2">{children}</div>
</div> </div>
) )
} }

View file

@ -118,7 +118,7 @@ export function Purchase({
</div> </div>
<button <button
className="mt-6 w-full rounded-lg bg-green-500 px-4 py-2 font-bold text-zinc-900 hover:bg-green-600" className="mt-6 w-full rounded-lg bg-green-400 px-4 py-2 font-bold text-zinc-900 hover:bg-green-500"
onClick={async () => { onClick={async () => {
const response = await confirmPurchase(symbol, price, value) const response = await confirmPurchase(symbol, price, value)
setPurchasingUI(response.purchasingUI) setPurchasingUI(response.purchasingUI)

View file

@ -1,23 +0,0 @@
import * as React from 'react'
export function useAtBottom(offset = 0) {
const [isAtBottom, setIsAtBottom] = React.useState(false)
React.useEffect(() => {
const handleScroll = () => {
setIsAtBottom(
window.innerHeight + window.scrollY >=
document.body.offsetHeight - offset
)
}
window.addEventListener('scroll', handleScroll, { passive: true })
handleScroll()
return () => {
window.removeEventListener('scroll', handleScroll)
}
}, [offset])
return isAtBottom
}

View file

@ -0,0 +1,86 @@
import { useCallback, useEffect, useRef, useState } from 'react'
export const useScrollAnchor = () => {
const messagesRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const visibilityRef = useRef<HTMLDivElement>(null)
const [isAtBottom, setIsAtBottom] = useState(true)
const [isVisible, setIsVisible] = useState(false)
const scrollToBottom = useCallback(() => {
if (messagesRef.current) {
messagesRef.current.scrollIntoView({
block: 'end',
behavior: 'smooth'
})
}
}, [])
useEffect(() => {
if (messagesRef.current) {
if (isAtBottom && !isVisible) {
messagesRef.current.scrollIntoView({
block: 'end'
})
}
}
}, [isAtBottom, isVisible])
useEffect(() => {
const { current } = scrollRef
if (current) {
const handleScroll = (event: Event) => {
const target = event.target as HTMLDivElement
const offset = 25
const isAtBottom =
target.scrollTop + target.clientHeight >= target.scrollHeight - offset
setIsAtBottom(isAtBottom)
}
current.addEventListener('scroll', handleScroll, {
passive: true
})
return () => {
current.removeEventListener('scroll', handleScroll)
}
}
}, [])
useEffect(() => {
if (visibilityRef.current) {
let observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
setIsVisible(true)
} else {
setIsVisible(false)
}
})
},
{
rootMargin: '0px 0px -150px 0px'
}
)
observer.observe(visibilityRef.current)
return () => {
observer.disconnect()
}
}
})
return {
messagesRef,
scrollRef,
visibilityRef,
scrollToBottom,
isAtBottom,
isVisible
}
}