Skip to content

Commit

Permalink
fix(webchat): inconsistent scrolling behavior (#550)
Browse files Browse the repository at this point in the history
* fix(webchat): inconsistent scrolling behavior

* don't use full lodash
  • Loading branch information
davidvitora committed Oct 31, 2022
1 parent 5a68ee6 commit 799d051
Show file tree
Hide file tree
Showing 5 changed files with 268 additions and 102 deletions.
11 changes: 9 additions & 2 deletions packages/components/src/css/botpress-default.css
Original file line number Diff line number Diff line change
Expand Up @@ -258,16 +258,23 @@ body {
min-height: 1px;
}

.bpw-msg-list {
.bpw-msg-list-scroll-container {
-webkit-box-flex: 1;
-ms-flex: 1;
flex: 1;
overflow-y: auto;
overflow-y: hidden;
-ms-flex-positive: 1;
flex-grow: 1;
}

.bpw-msg-list {
padding: 0 0.5rem 0.5rem 0.5rem;
}

.bpw-msg-list-follow {
display: none;
}

.bpw-date-container {
color: #666;
text-align: center;
Expand Down
1 change: 1 addition & 0 deletions packages/webchat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"react-dom": "^17.0.2",
"react-ga": "^2.7.0",
"react-intl": "^3.12.1",
"react-scroll-to-bottom": "^4.2.0",
"snarkdown": "^2.0.0",
"uuid": "^8.3.2"
}
Expand Down
160 changes: 63 additions & 97 deletions packages/webchat/src/components/messages/MessageList.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { ResizeObserver } from '@juggle/resize-observer'
import differenceInMinutes from 'date-fns/difference_in_minutes'
import debounce from 'lodash/debounce'
import last from 'lodash/last'
import { observe } from 'mobx'
import { inject, observer } from 'mobx-react'
import React from 'react'
import React, { useEffect, useState } from 'react'
import { WrappedComponentProps, injectIntl } from 'react-intl'
import ScrollToBottom, { useScrollToBottom, useSticky } from 'react-scroll-to-bottom'

import constants from '../../core/constants'
import { RootStore, StoreDef } from '../../store'
Expand All @@ -14,76 +14,63 @@ import Avatar from '../common/Avatar'
import MessageGroup from './MessageGroup'

interface State {
manualScroll: boolean
showNewMessageIndicator: boolean
messagesLength: number | undefined
}

class MessageList extends React.Component<MessageListProps, State> {
private messagesDiv!: HTMLElement
private divSizeObserver!: ResizeObserver
state: State = { showNewMessageIndicator: false, manualScroll: false }

componentDidMount() {
this.tryScrollToBottom(true)

observe(this.props.focusedArea!, (focus) => {
focus.newValue === 'convo' && this.messagesDiv.focus()
})

if (this.props.currentMessages) {
observe(this.props.currentMessages, (messages) => {
if (this.state.manualScroll) {
if (!this.state.showNewMessageIndicator) {
this.setState({ showNewMessageIndicator: true })
}
return
}
this.tryScrollToBottom()
})
}

// this should account for keyboard rendering as it triggers a resize of the messagesDiv
this.divSizeObserver = new ResizeObserver(
debounce(
(_divResizeEntry) => {
// we don't need to do anything with the resize entry
this.tryScrollToBottom()
},
200,
{ trailing: true }
)
)
this.divSizeObserver.observe(this.messagesDiv)
}

componentWillUnmount() {
this.divSizeObserver.disconnect()
render() {
return (
<ScrollToBottom
mode={'bottom'}
initialScrollBehavior={'auto'}
tabIndex={0}
className={'bpw-msg-list-scroll-container'}
scrollViewClassName={'bpw-msg-list'}
ref={(m: any) => {
this.messagesDiv = m
}}
followButtonClassName={'bpw-msg-list-follow'}
>
<Content {...this.props} />
</ScrollToBottom>
)
}
}

componentDidUpdate() {
if (this.state.manualScroll) {
return
const Content = observer((props: MessageListProps) => {
const [state, setState] = useState<State>({
showNewMessageIndicator: false,
messagesLength: undefined
})
const scrollToBottom = useScrollToBottom()
const [sticky] = useSticky()

useEffect(() => {
const stateUpdate = { ...state, messagesLength: props?.currentMessages?.length }
if (!sticky && state.messagesLength !== props?.currentMessages?.length) {
setState({ ...stateUpdate, showNewMessageIndicator: true })
} else {
setState({ ...stateUpdate, showNewMessageIndicator: false })
}
this.tryScrollToBottom()
}
}, [props?.currentMessages?.length, sticky])

tryScrollToBottom(delayed?: boolean) {
setTimeout(
() => {
try {
this.messagesDiv.scrollTop = this.messagesDiv.scrollHeight
} catch (err) {
// Discard the error
}
},
delayed ? 250 : 0
)
const shouldDisplayMessage = (m: Message): boolean => {
return m.payload.type !== 'postback'
}

renderDate(date: Date) {
const renderDate = (date: Date) => {
return (
<div className={'bpw-date-container'}>
{new Intl.DateTimeFormat(this.props.intl.locale || 'en', {
{new Intl.DateTimeFormat(props.intl.locale || 'en', {
month: 'short',
day: 'numeric',
hour: 'numeric',
Expand All @@ -94,20 +81,20 @@ class MessageList extends React.Component<MessageListProps, State> {
)
}

renderAvatar(name: string, url: string) {
const renderAvatar = (name: string, url: string) => {
const avatarSize = 40
return <Avatar name={name} avatarUrl={url} height={avatarSize} width={avatarSize} />
}

renderMessageGroups() {
const messages = (this.props.currentMessages || []).filter((m) => this.shouldDisplayMessage(m))
const renderMessageGroups = () => {
const messages = (props.currentMessages || []).filter((m: any) => shouldDisplayMessage(m))
const groups: Message[][] = []

let lastSpeaker: string | undefined = undefined
let lastDate: Date | undefined = undefined
let currentGroup: Message[] | undefined = undefined

messages.forEach((m) => {
messages.forEach((m: any) => {
const speaker = m.authorId
const date = m.sentOn

Expand All @@ -130,7 +117,7 @@ class MessageList extends React.Component<MessageListProps, State> {
lastDate = date
})

if (this.props.isBotTyping!.get()) {
if (props?.isBotTyping?.get()) {
if (lastSpeaker !== 'bot') {
currentGroup = []
groups.push(currentGroup)
Expand All @@ -152,13 +139,13 @@ class MessageList extends React.Component<MessageListProps, State> {
!groups[i - 1] ||
differenceInMinutes(new Date(groupDate), new Date(lastDate)) > constants.TIME_BETWEEN_DATES

const [{ authorId }] = group
const { authorId } = last(group) as Message

const avatar = !authorId && this.renderAvatar(this.props.botName!, this.props.botAvatarUrl!)
const avatar = !authorId && renderAvatar(props.botName!, props.botAvatarUrl!)

return (
<div key={i}>
{isDateNeeded && this.renderDate(group[0].sentOn)}
{isDateNeeded && renderDate(group[0].sentOn)}
<MessageGroup
isBot={!authorId}
avatar={avatar as JSX.Element}
Expand All @@ -173,42 +160,21 @@ class MessageList extends React.Component<MessageListProps, State> {
)
}

shouldDisplayMessage = (m: Message): boolean => {
return m.payload.type !== 'postback'
}

handleScroll = debounce((e) => {
const scroll = this.messagesDiv.scrollHeight - this.messagesDiv.scrollTop - this.messagesDiv.clientHeight
const manualScroll = scroll >= 150
const showNewMessageIndicator = this.state.showNewMessageIndicator && manualScroll

this.setState({ manualScroll, showNewMessageIndicator })
}, 50)

render() {
return (
<div
tabIndex={0}
className={'bpw-msg-list'}
ref={(m) => {
this.messagesDiv = m!
}}
onScroll={this.handleScroll}
>
{this.state.showNewMessageIndicator && (
<div className="bpw-new-messages-indicator" onClick={(e) => this.tryScrollToBottom()}>
<span>
{this.props.intl.formatMessage({
id: `messages.newMessage${this.props.currentMessages!.length === 1 ? '' : 's'}`
})}
</span>
</div>
)}
{this.renderMessageGroups()}
</div>
)
}
}
return (
<>
{state.showNewMessageIndicator && (
<div className="bpw-new-messages-indicator" onClick={(e) => scrollToBottom()}>
<span>
{props.intl.formatMessage({
id: `messages.newMessage${props?.currentMessages?.length === 1 ? '' : 's'}`
})}
</span>
</div>
)}
{renderMessageGroups()}
</>
)
})

export default inject(({ store }: { store: RootStore }) => ({
intl: store.intl,
Expand Down
1 change: 1 addition & 0 deletions packages/webchat/src/declaration.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
declare module '*.scss'
declare module 'react-scroll-to-bottom'

0 comments on commit 799d051

Please sign in to comment.