Building a Messenger App: Home Page

This post is the 8th on a series:

Continuing the frontend, let’s finish the home page in this post. We’ll add a form to start conversations and a list with the latest ones.

Conversation Form

conversation form screenshot

In the static/pages/home-page.js file add some markup in the HTML view.

<form id="conversation-form">
    <input type="search" placeholder="Start conversation with..." required>
</form>

Add that form just below the section in which we displayed the auth user and logout button.

page.getElementById('conversation-form').onsubmit = onConversationSubmit

Now we can listen to the “submit” event to create the conversation.

import http from '../http.js'
import { navigate } from '../router.js'

async function onConversationSubmit(ev) {
    ev.preventDefault()

    const form = ev.currentTarget
    const input = form.querySelector('input')

    input.disabled = true

    try {
        const conversation = await createConversation(input.value)
        input.value = ''
        navigate('/conversations/' + conversation.id)
    } catch (err) {
        if (err.statusCode === 422) {
            input.setCustomValidity(err.body.errors.username)
        } else {
            alert(err.message)
        }
        setTimeout(() => {
            input.focus()
        }, 0)
    } finally {
        input.disabled = false
    }
}

function createConversation(username) {
    return http.post('/api/conversations', { username })
}

On submit we do a POST request to /api/conversations with the username and redirect to the conversation page (for the next post).

Conversation List

conversation list screenshot

In the same file, we are going to make the homePage() function async to load the conversations first.

export default async function homePage() {
    const conversations = await getConversations().catch(err => {
        console.error(err)
        return []
    })
    /*...*/
}

function getConversations() {
    return http.get('/api/conversations')
}

Then, add a list in the markup to render conversations there.

<ol id="conversations"></ol>

Add it just below the current markup.

const conversationsOList = page.getElementById('conversations')
for (const conversation of conversations) {
    conversationsOList.appendChild(renderConversation(conversation))
}

So we can append each conversation to the list.

import { avatar, escapeHTML } from '../shared.js'

function renderConversation(conversation) {
    const messageContent = escapeHTML(conversation.lastMessage.content)
    const messageDate = new Date(conversation.lastMessage.createdAt).toLocaleString()

    const li = document.createElement('li')
    li.dataset['id'] = conversation.id
    if (conversation.hasUnreadMessages) {
        li.classList.add('has-unread-messages')
    }
    li.innerHTML = `
        <a href="/conversations/${conversation.id}">
            <div>
                ${avatar(conversation.otherParticipant)}
                <span>${conversation.otherParticipant.username}</span>
            </div>
            <div>
                <p>${messageContent}</p>
                <time>${messageDate}</time>
            </div>
        </a>
    `
    return li
}

Each conversation item contains a link to the conversation page and displays the other participant info and a preview of the last message. Also, you can use .hasUnreadMessages to add a class to the item and do some styling with CSS. Maybe a bolder font or accent the color.

Note that we’re escaping the message content. That function comes from static/shared.js:

export function escapeHTML(str) {
    return str
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#039;')
}

That prevents displaying as HTML the message the user wrote. If the user happens to write something like:

<script>alert('lololo')</script>

It would be very annoying because that script will be executed 😅 So yeah, always remember to escape content from untrusted sources.

Messages Subscription

Last but not least, I want to subscribe to the message stream here.

const unsubscribe = subscribeToMessages(onMessageArrive)
page.addEventListener('disconnect', unsubscribe)

Add that line in the homePage() function.

function subscribeToMessages(cb) {
    return http.subscribe('/api/messages', cb)
}

The subscribe() function returns a function that once called it closes the underlying connection. That’s why I passed it to the “disconnect” event; so when the user leaves the page, the event stream will be closed.

async function onMessageArrive(message) {
    const conversationLI = document.querySelector(`li[data-id="${message.conversationID}"]`)
    if (conversationLI !== null) {
        conversationLI.classList.add('has-unread-messages')
        conversationLI.querySelector('a > div > p').textContent = message.content
        conversationLI.querySelector('a > div > time').textContent = new Date(message.createdAt).toLocaleString()
        return
    }

    let conversation
    try {
        conversation = await getConversation(message.conversationID)
        conversation.lastMessage = message
    } catch (err) {
        console.error(err)
        return
    }

    const conversationsOList = document.getElementById('conversations')
    if (conversationsOList === null) {
        return
    }

    conversationsOList.insertAdjacentElement('afterbegin', renderConversation(conversation))
}

function getConversation(id) {
    return http.get('/api/conversations/' + id)
}

Every time a new message arrives, we go and query for the conversation item in the DOM. If found, we add the has-unread-messages class to the item, and update the view. If not found, it means the message is from a new conversation created just now. We go and do a GET request to /api/conversations/{conversationID} to get the conversation in which the message was created and prepend it to the conversation list.


That covers the home page 😊 On the next post we’ll code the conversation page.

Souce Code

Discuss on Twitter