Skip to main content

Messaging Interface

The example below is a simplified version of the final result of the tutorial - a messaging user interface. Sending and receiving messages is simulated with buttons. The ChatChannel class is used to simulate server-client communication. The source of the ChatChannel class is available in the first part of the tutorial.

Key Points

  • The custom empty placeholder is used for the loading message.
  • The custom header is used to display a loading message when loading older messages.
  • Sending/receiving messages implements a scroll location so that the list can scroll to the bottom when a new message is sent or received.
  • Optimistic updates are used to display a message before it is delivered.

Live Example

Live Editor
interface MessageListContext {
  loadingNewer: boolean
  channel: ChatChannel
}

type VirtuosoProps = VirtuosoMessageListProps<ChatMessage, MessageListContext>

const ItemContent: VirtuosoProps['ItemContent'] = ({ data: message, context }) => {
  const ownMessage = context.channel.currentUser === message.user
  return (
    <div style={{ display: 'flex', gap: '1rem', paddingBottom: '1rem', flexDirection: ownMessage ? 'row-reverse' : 'row' }}>
      <img src={message.user.avatar} style={{ borderRadius: '100%', width: 30, height: 30, border: '1px solid #ccc' }} />
      <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', maxWidth: '50%' }}>
        <div
          style={{
            background: ownMessage ? '#0253B3' : '#F0F0F3',
            color: ownMessage ? 'white' : 'black',
            borderRadius: '0.5rem',
            padding: '0.5rem',
            ...(ownMessage ? { borderTopRightRadius: '0' } : { borderTopLeftRadius: 'auto' }),
          }}
        >
          {message.message}
        </div>
        {!message.delivered && <div style={{ textAlign: 'right' }}>Delivering...</div>}
      </div>
    </div>
  )
}

const EmptyPlaceholder: VirtuosoProps['EmptyPlaceholder'] = ({ context }) => <div>{!context.channel.loaded ? 'Loading...' : 'Empty'}</div>

const Header: VirtuosoProps['Header'] = ({ context }) => {
  return <div style={{ height: 30 }}>{context.loadingNewer ? 'Loading...' : ''}</div>
}

function App() {
  const channel = React.useMemo(() => new ChatChannel('general', 500), [])
  const messageListRef = React.useRef<VirtuosoMessageListMethods<ChatMessage>>(null)
  const [loadingNewer, setLoadingNewer] = React.useState(false)
  const firstMessageId = React.useRef<number | null>(null)

  React.useEffect(() => {
    channel.onNewMessages = (messages) => {
      // cleanup messages that map to the same localId, they got delivered
      const updatingMessageIds: number[] = []
      messageListRef.current?.data.map((item) => {
        const updatedItem = !item.delivered && messages.find((m) => m.localId === item.localId)
        if (updatedItem) {
          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
          updatingMessageIds.push(updatedItem.id!)
          return updatedItem
        } else {
          return item
        }
      })

      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
      const nonUpdatingMessages = messages.filter((m) => !updatingMessageIds.includes(m.id!))

      messageListRef.current?.data.append(nonUpdatingMessages, ({ atBottom, scrollInProgress }) => {
        if (atBottom || scrollInProgress) {
          return 'smooth'
        } else {
          return false
        }
      })
    }

    if (!channel.loaded) {
      channel
        .getMessages({ limit: 20 })
        .then((messages) => {
          if (messages !== null) {
            firstMessageId.current = messages[0].id
            messageListRef.current?.data.append(messages)
          }
        })
        .catch((error) => {
          console.error(error)
        })
    }
  }, [channel])

  const onScroll = React.useCallback(
    (location: ListScrollLocation) => {
      // offset is 0 at the top, -totalScrollSize + viewportHeight at the bottom
      if (location.listOffset > -100 && !loadingNewer && firstMessageId.current) {
        setLoadingNewer(true)
        channel
          .getMessages({ limit: 20, before: firstMessageId.current })
          .then((messages) => {
            if (messages !== null) {
              firstMessageId.current = messages[0].id
              messageListRef.current?.data.prepend(messages)
              setLoadingNewer(false)
            }
          })
          .catch((error) => {
            console.error(error)
          })
      }
    },
    [channel, loadingNewer]
  )

  return (
    <div className="tall-example" style={{ height: 800, display: 'flex', flexDirection: 'column', fontSize: '70%' }}>
      <VirtuosoMessageListLicense licenseKey="">
        <VirtuosoMessageList<ChatMessage, MessageListContext>
          key={channel.name}
          context={{ loadingNewer, channel }}
          initialData={channel.messages}
          shortSizeAlign="bottom-smooth"
          initialLocation={{ index: 'LAST', align: 'end' }}
          onScroll={onScroll}
          EmptyPlaceholder={EmptyPlaceholder}
          computeItemKey={({ data }) => {
            if (data.id !== null) {
              return data.id
            } else {
              return `l-${data.localId}`
            }
          }}
          Header={Header}
          style={{ flex: 1 }}
          ItemContent={ItemContent}
          ref={messageListRef}
        />
      </VirtuosoMessageListLicense>
      <div style={{ display: 'flex', gap: '1rem', padding: '1rem', justifyItems: 'end' }}>
        <button
          onClick={() => {
            const tempMessage = channel.sendOwnMessage()
            messageListRef.current?.data.append([tempMessage], ({ scrollInProgress, atBottom }) => {
              if (atBottom || scrollInProgress) {
                return 'smooth'
              } else {
                return 'auto'
              }
            })
          }}
        >
          Send
        </button>

        <button
          onClick={() => {
            channel.createNewMessageFromAnotherUser()
          }}
        >
          Receive
        </button>
      </div>
    </div>
  )
}
render(<App />)
Result
Loading...