Skip to main content

Part 1 - Intro

This is the first part of the tutorial series on how to use the VirtuosoMessageList component. The final goal is to build a messenger-like interface that simulates sending and receiving messages, while supporting multiple channels.

The tutorial assumes that you have working knowledge of React and TypeScript and you are familiar with setting up and running a React project through the command line. The snippets use TypeScript, but you can easily adapt them to JavaScript. You won't need an active license to complete the tutorial, but you will need one if you want to deploy the app in a production environment.

We're going to start the tutorial from scratch, using Next.js for the initial setup. The message list component works with any of the modern React stacks, so you can use it in your existing project as well.

info

If you want to play around with the final result or if you get stuck at a certain step, you can refer to the virtuoso-message-list-tutorial repository, where each step of the tutorial is available as a separate commit.

With a few exceptions, the tutorial will not cover the styling of the components. You can use any UI/CSS framework or write your own styles.

Project Setup

Bootstrap your project by running the following command - this will create a new Next.js project in the virtuoso-message-list-tutorial directory.

npx create-next-app@latest virtuoso-message-list-tutorial

Accept the default suggestions of the Next.js wizard - TypeScript, App router, and no Tailwind CSS.

The Chat Channel Client Class

Our tutorial will not connect to a real backend server. Instead, we are going to simulate the server-client communication by using a client class that generates messages and sends them to the message list component. To make things a bit more realistic, we are going to use the @ngneat/falso package to generate random avatars and phrases. First, add the dependency to your project:

npm install @ngneat/falso

Let's create a stateful ChatChannel class that will act as a "proxy" to the server. The class exposes an API for loading the initial messages, loading older messages, etc. The implementation also includes User and Message data structures - most likely, you will have similar implementations in your real-world project.

For now, you don't need to understand the implementation details of the ChatChannel class. We are going to use it as a black box in the tutorial.

Add the following code to a file named lib/ChatChannel.ts:

import { rand, randFullName, randNumber, randSentence } from '@ngneat/falso'

type GetMessageParams = { limit?: number } | { before: number; limit?: number }

export class ChatChannel {
public users: ChatUser[]
private localIdCounter = 0
public messages: ChatMessage[] = []

public onNewMessages = (messages: ChatMessage[]) => {
void messages
}
public currentUser: ChatUser
private otherUser: ChatUser
private loading = false
public loaded = false

constructor(
public name: string,
private totalMessages: number
) {
this.users = Array.from({ length: 2 }, (_, i) => new ChatUser(i))
this.currentUser = this.users[0]
this.otherUser = this.users[1]
if (this.totalMessages === 0) {
this.loaded = true
}
}

async getMessages(params: GetMessageParams) {
if (this.loading) {
return null
}

this.loading = true

await new Promise((r) => setTimeout(r, 1000))
const { limit = 10 } = params
this.loading = false

if (!this.loaded) {
this.loaded = true
}

if (this.messages.length >= this.totalMessages) {
return []
}

// prepending messages, simplified for the sake of the example
if ('before' in params) {
if (this.messages.length >= this.totalMessages) {
return []
}

const offset = this.totalMessages - this.messages.length - limit

const newMessages = Array.from({ length: limit }, (_, i) => {
const id = offset + i
return new ChatMessage(id, rand(this.users))
})
this.messages = newMessages.concat(this.messages)
return newMessages
} else {
// initial load
this.messages = Array.from({ length: limit }, (_, i) => {
const id = this.totalMessages - limit + i
return new ChatMessage(id, rand(this.users))
})
return this.messages
}
}

createNewMessageFromAnotherUser() {
const newMessage = new ChatMessage(this.messages.length, this.otherUser)
this.messages.push(newMessage)
this.onNewMessages([newMessage])
}

sendOwnMessage() {
const tempMessage = new ChatMessage(null, this.currentUser)
tempMessage.localId = ++this.localIdCounter
tempMessage.delivered = false

setTimeout(() => {
const deliveredMessage = new ChatMessage(this.messages.length, this.currentUser, tempMessage.message)
deliveredMessage.localId = tempMessage.localId
this.messages.push(deliveredMessage)
this.onNewMessages([deliveredMessage])
}, 1000)

return tempMessage
}
}

export class ChatUser {
constructor(
public id: number | null,
public name = randFullName(),
public avatar = `https://i.pravatar.cc/30?u=${encodeURIComponent(name)}`
) {}
}

// a ChatMessage class with a random message
export class ChatMessage {
public delivered = true
public localId: number | null = null
constructor(
public id: number | null,
public user: ChatUser,
public message = randSentence({
length: randNumber({ min: 1, max: 5 }),
}).join(' ')
) {}
}

In the next step, we will add the message list itself and bind it to the ChatChannel class.