Build an Interactive Messaging App with Stream, MML, Node and React

Amin M.
Amin M.
Published January 8, 2021 Updated May 4, 2021

Message Markup Language (MML) enables you to build an interactive messaging experience. MML supports embedding elements as simple as a button to your message or as complex as date pickers and custom forms within your chat experience. MML also supports images, icons, and tables out of the box.

The goal for MML is to provide a standardized way to handle the most common use cases for message interactivity. MML can be extended into custom components using the MML React library.

MML for React

The first Stream SDK to support MML is the Stream Chat React SDK. This feature accepts an MML string and renders it as React components. To see specific examples of use cases for MML on React, read our docs for MML-React. Read more about MML in our chat docs.

Prerequisites

Basic knowledge of Node.js (Javascript) and React is required to follow this tutorial. This code is supposed to run locally on your machine. Make sure you have installed Node.js and Yarn. You also need to install create-react-app, which helps us kick off our React app.

$ yarn global add create-react-app

// OR

$ npm install create-react-app -g

You need to create an account for Stream and start your chat trial. Once you made your account, proceed to the dashboard and grab your app key and secret, we'll need them later.

Stream Dashboard API Key and Secret

Building the App

Our projects consist of a simple backend app written in Node.js, Express.js, and localtunnel to expose our localhost to the internet. The frontend app is a simple create-react-app project which uses Stream-Chat-React components that support MML out of the box. Let's start with our Frontend app.

Frontend

First, we need to create a new React application, install some dependencies, and then open the editor's frontend folder.

$ create-react-app frontend
$ yarn add stream-chat stream-chat-react

// OR

$ npm install stream-chat stream-chat-react --save

Open the src/App.css and replace its content with this:

body,
html,
#root,
.str-chat-channel-list,
.str-chat-channel {
  height: 100%;
}

.str-chat__container {
  flex-direction: column;
}

Next, we need to choose a user id and generate a token for this user. Let's call our user "jim", grab your app secret from Stream Dashboard and head over to this the user token generator. In the user id field, enter jim and paste your app secret from the dashboard in the secret field. You now have a user token signed for this user. (Note that in production, you need to generate user token in your backend) Now we need to update our src/App.js file with this and put our API KEY from Stream Dashboard in line 8 and our generate user token in line 11:

import { useState, useEffect, useCallback } from 'react';
import { StreamChat } from 'stream-chat';
import { Chat, Channel, MessageInput, ChannelHeader, ChannelList, VirtualizedMessageList } from 'stream-chat-react';

import 'stream-chat-react/dist/css/index.css';
import './App.css';

const apiKey = 'YOUR_API_KEY'; // grab your api key from https://getstream.io/dashboard/
const user = { id: 'jim', name: 'Jim' };
// you can generate user token using your app secret via https://getstream.io/chat/docs/token_generator/
const token = 'GENERATED_TOKEN';

function App() {
  const [chatClient] = useState(new StreamChat(apiKey)); // important to init chatClient only once, you can replace this with useMemo
  const [loading, setLoading] = useState(true); // used to render a loading UI until client successfully is connected

  useEffect(() => {
    if (loading) chatClient.connectUser(user, token).then(() => setLoading(false)); // client connects(async) with provided user token
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  const createNewChannel = useCallback(async () => {
    // we need a way to create new channels with current user as the member
    // this func allows us to create random channels
    const channelId = Math.random().toString(36).substring(7);
    await chatClient.channel('messaging', channelId, { name: channelId.toUpperCase(), members: [user.id] }).create();
  }, [chatClient]);

  if (loading) return <div>Loading...</div>;

  return (
    <Chat client={chatClient}>
      <button onClick={createNewChannel}>Creat a New Channel</button>
      <ChannelList filters={{ members: { $in: [user.id] } }} options={{ state: true, watch: true, presence: true }} />
      <Channel>
        <ChannelHeader />
        <VirtualizedMessageList />
        <MessageInput />
      </Channel>
    </Chat>
  );
}

export default App;

Awesome! Our fully functioning chat application is ready! We can now see our app by running yarn start or npm start from the terminal. Open http://localhost:3000 in your browser and click on the "Create a New Channel" button to create the first channel for Jim.

Frontend app powered by Stream Chat React components

Backend

Here things get interesting. We will create an API that accepts POST requests from the Stream Webhook system. Using webhooks allows you to integrate your server application with Stream Chat tightly. Our app will use the custom command webhook feature, and this will enable us to create interactive messages similar to how /giphy funk command works in Slack.

For our example app, we introduce a new command to our chat application that allows our users to create an appointment. When a user writes a message with /appointment [title], we will show a custom message to the user and follow a few steps to create an appointment in our server application. If you want to know more about how custom commands work best, see the Stream official documentation.

Let's create a new folder and start implementing our backend app:

$ mkdir backend && cd backend
$ yarn init
$ yarn add express body-parser localtunnel stream-chat
$ touch index.js

We are going to create a basic Express app in the index.js file. We are using the localtunnel library, which allows us to tunnel our localhost API and expose it on the internet with a random public URL.

Building your own app? Get early access to our Livestream or Video Calling API and launch in days!
const express = require('express');
const bodyParser = require('body-parser');
const localtunnel = require('localtunnel');

const PORT = 8000;

const app = express();

// body-parser parsed the body to a json object for us, it also store the rawBody so later we can check the request integrity
app.use(bodyParser.json({ verify: (req, res, buf) => (req.rawBody = buf) }));

app.get('/', (req, res) => {
  return res.status(200).json({ message: 'API is live!' });
});

const setupTunnelAndWebhook = async () => {
  const { url } = await localtunnel({ port: PORT });
  console.log(`Server running remotely in ${url}`);
};

app.listen(PORT, (err) => {
  if (err) throw err;
  console.log(`Server running in http://127.0.0.1:${PORT}`);

  setupTunnelAndWebhook();
});

Now you can run your API by running node index.js in your terminal, and it should show you an output similar to this:

Server running in http://127.0.0.1:8000
Server running remotely in https://wicked-firefox-26.loca.lt

Note that the second URL is randomly generated every time you run your API. If you open it, you should see a JSON response like this {"message": "API is live!"} in your browser.

To integrate Stream with our backend, we need to get our API Key and Secret from Stream Dashboard similar to our frontend app. Once you got it, update your index.js file with the following code. We initialized our chatClient using our keys and created an express middleware to verify the request's integrity. That is a crucial step since our API is publicly accessible to everyone; we have to make sure the requests are coming from Stream. You can read more about this here.

const express = require('express');
const bodyParser = require('body-parser');
const localtunnel = require('localtunnel');
const { StreamChat } = require('stream-chat');

const PORT = 8000;
const API_KEY = 'YOUR_API_KEY';
const API_SECRET = 'YOUR_API_SECRET';

// Stream Chat client used to validate webhook calls
const chatClient = new StreamChat(API_KEY, API_SECRET);

// mock function, here you can actually store the Appointment in your system
const storeInDb = (data) => console.log('YES!!! New Appointment', data);

const app = express();

// body-parser parsed the body to a json object for us, it also store the rawBody so later we can check the request integrity
app.use(bodyParser.json({ verify: (req, res, buf) => (req.rawBody = buf) }));

// security middleware called before all custom commands to verify the integrity of the request
app.use((req, res, next) => {
  // making sure we are using the correct apiKey
  if (req.headers['x-api-key'] !== API_KEY) {
    console.error('invalid api key: ', req.headers['x-api-key']);
    return res.status(403).json({ error: 'invalid api key' });
  }

  // check the payload is correctly signed
  const validSignature = chatClient.verifyWebhook(req.body, req.headers['x-signature']);
  if (!validSignature) {
    console.error('invalid signature', req);
    return res.status(403).json({ error: 'invalid signature' });
  }

  // if all good, continute processing the request
  next();
});

const setupTunnelAndWebhook = async () => {
  const { url } = await localtunnel({ port: PORT });
  console.log(`Server running remotely in ${url}`);
};

app.listen(PORT, (err) => {
  if (err) throw err;
  console.log(`Server running in http://127.0.0.1:${PORT}`);

  setupTunnelAndWebhook();
});

In the next step, we will update the setupTunnelAndWebhook function to set up our webhook configuration and update our app's settings in Stream. First, we create our appointment command and update our Channel type with it. Next, we update the app configuration to forward commands to our server application.

Note: this step needs to be done only once for a production app. We are running this step every time we run the app as our public URL changes with each run for this tutorial.

const setupTunnelAndWebhook = async () => {
  const { url } = await localtunnel({ port: PORT });
  console.log(`Server running remotely in ${url}`);

  // you need to these steps only once in production or manually in stream dashboard
  // https://getstream.io/chat/docs/custom_commands_webhook/
  const cmds = await chatClient.listCommands();
  if (!cmds.commands.find(({ name }) => name === 'appointment')) {
    await chatClient.createCommand({
      name: 'appointment',
      description: 'Create an appointment',
      args: '[description]',
      set: 'mml_commands_set',
    });
  }

  const type = await chatClient.getChannelType('messaging');
  if (!type.commands.find(({ name }) => name === 'appointment')) {
    await chatClient.updateChannelType('messaging', { commands: ['all', 'appointment'] });
  }

  // custom_action_handler_url has to be a publicly accessibly url
  await chatClient.updateAppSettings({ custom_action_handler_url: url });
};

So far, great! Finally, we can add the controller to handle the appointment command and respond to the user with our MML string. That is a rather complicated use case consisting of several steps to show the MML and Stream Webhooks' power.

app.post('/', (req, res) => {
  const { user, message, form_data } = req.body;
  const isAppointment = message.command === 'appointment'; // you can have up to 50 different custom command

  // first step: user sent an appointment command without any message action being called yet, i.e. without buttons in the mml being clicked
  // we will show a MML input component and ask for user's phone number
  if (isAppointment && !form_data) {
    message.text = ''; // remove user input
    message.type = 'ephemeral'; // switch the message to ephemeral so it's not stored until it's final
    message.mml = `
        <mml type="card">
            <input name="phone" label="Please Enter your phone number" placeholder="e.g. 999-999-9999"></input>
            <button name="action" value="submit">Submit</button>
        </mml>
    `;
  }
  // second step: user has submitted the phone number, we reply with a MML Scheduler component with predefined time slot
  else if (isAppointment && form_data && form_data.phone) {
    const buttonText = `Book ${message.args}`.trim();
    message.phone = form_data.phone; // store temporary data in the message object
    message.mml = `
        <mml type="card">
        <text>Please choose a time slot:</text>
        <scheduler name="appointment" duration="30" interval="30" selected="2021-03-15T10:30:00.000Z" />
        <button name="action" value="reserve" icon="add_alarm">${buttonText}</button>
        </mml>
  `;
  }
  // last step: user has submitted the preferred date
  // we are going to store the appointment in our database and update the message to show AddToCallendar component
  else if (isAppointment && form_data && form_data.action === 'reserve' && form_data.appointment) {
    storeInDb({ phone: message.phone, user: user.id, date: form_data.appointment }); // mock function

    message.type = 'regular'; // switch the message type to regular to make it persistent
    message.phone = undefined; // do not store intermediate value in the message
    const title = `Appointment ${message.args}`.trim();
    const end = new Date(Date.parse(form_data.appointment) + 30 * 60000).toISOString(); // add 30 minutes for the appointment duration
    message.mml = `
        <mml>
            <add_to_calendar
            title="${title}"
            start="${form_data.appointment}"
            end="${end}"
            description="Your appointment with stream"
            location="Stream, Amsterdam"
            />
        </mml>
    `;
  } else {
    message.type = 'error';
    message.text = 'invalid command or input';
  }

  return res.status(200).json({ ...req.body, message }); // reply to Stream with updated message object which updates the message for user
});

You can see the full backend code here. Let's run our backend app by running node index.js and our frontend app by running yarn start and see how our app works:

  1. User writes a message with appointment command like /appointment Doctor
User starts the flow by running our custom command
  1. Stream webhook calls our server application, we update the message with an MML Input component and ask for user phone number
User enters phone number
  1. User enters the phone number and click on the submit button

  2. Our server application now receives another webhook call, which has a form_date object that includes the user's phone number. It stores the number and updates the message to show an MML Scheduler component

User selects a time slot
  1. User now selects a time slot and click on the submit button

  2. Another webhook call is received by our server application with the selected time slot. Now we make the message persistent, store the appointment in our database and update the message to show an MML AddToCalendar component that allows the user to add the appointment to their calendar of choice.

User can add the appointment to their calendar Appointment received on our backend app

Final Thoughts

We successfully created a sample chat application powered by Stream with custom commands, and MML React components.

MML is a powerful and flexible feature that allows us to support a wide range of use cases in our applications. Stream supports different types of webhooks.

For example, you can create a bot user in channels to respond with custom MML strings whenever a specific event happens or a new message is posted to a channel.

The full source code for this tutorial can be found in GitHub.

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->