Building a Chat App Using Weavy and Telerik Kendo Conversational UI for React

Introduction

This tutorial will show how you can use the Telerik Kendo React Conversational UI with Weavy acting as backend in an React Project. You will also see examples on how to use some other Telerik Kendo React controls throughout the project.

Requirements

  • A local or remote Weavy instance. Take a look at the Getting Started how to setup Weavy on your machine.
    (To speed things up, you can skip setting up your own Weavy instance and use the publicly available instance hosted at https://showcase.weavycloud.com)
  • Visual Studio Code or any other preferred code editor

Before you begin

Start by cloning the project from Github. It contains all the code we are going to use for this tutorial.

The project is configured to use the publicly available Weavy instance hosted at https://showcase.weavycloud.com and is fully functional. BUT, if you are interested in setting up your own local Weavy instance as the acting backend, you can also clone the Weavy solution used in this tutorial from Github. This will require some additional steps that is explained in the Configuration section.

Application Composition

The app will showcase some basic functionallity for a chat app built with the Telerik Kendo Conversational UI for React. It is not a production ready application and should be regarded as an example on how to leverage Weavy functionality in a Telerik Kendo React project.

These are the functions we are going to use in this tutorial:

  • Authentication - Cookie based authentication using one of the below hard coded JWT tokens for demo purpose.
  • Listing conversations - A simple list using the React Avatar component for user images.
  • Sending & Receiving messages - Using the React Conversational UI backed by Weavy.

Configuration

In the constants.js file there are a url setting that you can change if you want to change the Weavy instance:

API_URL - The url to the Weavy instance

Authentication

In this turorial, we'll show how to use cookie based authentication with Weavy. We'll request an authentication cookie by passing in one of four hard coded user accounts created for this demo.

In the LoginForm.js file, which is presented to the user on application start, you can choose from one of the pre-defined users. Each user has a JWT token that is sent to the end point https://showcase.weavycloud.com/client/sign-in. Upon successful authentication, the cookie is returned in the response.

PLEASE NOTE! There is no persistant state management in this demo app. When refreshing the page, you are presented with the Login Page again.
fetch(API_URL + '/client/sign-in', {
    method: 'GET',
    credentials: 'include',
    headers: {
        'Accept': 'application/json',
        'Authorization': 'Bearer ' + token
    },
})
    .then(res => res.json())
    .then((user) => {        
        login(user);
    });

The pre-defined JWT tokens that we have prepared for this tutorial/demo does only work for the https://showcase.weavycloud.com/ Weavy test site.

IMPORTANT! When generating JWT tokens in a real world app, the tokens should be very short lived for maximum security.

The Chat Application

Listing conversations

In the /components/ConversationList.js component, we do a request to the /api/conversations endpoint to get all the user's current conversations. We simply iterate over the result and use the Kendo React Avatar component to render the user's avtar image.

<Avatar shape='circle' type='image'>
    <img src={`${API_URL}${c.thumb.replace('{options}', '34' )}`} />
</Avatar>

The user's conversations is fetched in when the component has mounted.

useEffect(() => {
    fetch(API_URL + '/api/conversations/', {
        method: 'GET',
        credentials: 'include',     
        headers: {
            'Accept': 'application/json'        
        },
    })
        .then(res => res.json())
        .then((conversations) => {            
            setConversations(conversations);            
        });
}, []);

The Chat Component

The Kendo React Conversational UI component is populated when a user clicks a conversation. Take a look in the /components/Messages.js file. First, the conversation is fetched and then all the messages in the conversation are retrieved and rendered.

<Chat user={user}
      placeholder="Type a message..."
      messages={messages}
      onMessageSend={addNewMessage} />
useEffect(() => {

    if (!props.conversationid) return;

    fetch(API_URL + '/api/conversations/' + props.conversationid + '/messages', {
        method: 'GET',
        credentials: 'include',     
        headers: {
            'Accept': 'application/json'        
        }
    })
        .then(res => res.json())
        .then((r) => {
            setMessages(r.data.map((m) => {
                return {
                    text: m.text,
                    timestamp: new Date(m.created_at),
                    author: { id: m.created_by.id, name: m.created_by.name }
                }
            }));
        });
}, [props.conversationid]);

Weavy and Realtime Events

In order to take advantage of Weavy realtime functionality we have added signalR to the project. The connection is handled in the ConnectionContext. Take a look in the connection-context.js and connection-provider.js files. When the user has signed in, a call to the connect method is made and a proxy hub is available in all react components by using the hook useContext(ConnectionContext). Via the proxy hub, we can respond to various events being distributed in realtime. You can find a list of available events in the Server API / Weavy.Core.Events section.

export const ConnectionContext = React.createContext(
    {
        connect: () => null,
        proxy: null
    }
);

const ConnectionProvider = (props) => {
    const [proxy, setProxy] = useState(null);

    const connect = () => {
        const connection = hubConnection(API_URL);
        const hubProxy = connection.createHubProxy('rtm');
        hubProxy.on('init', (type, data) => { }); // dummy event to get signalR started...
        setProxy(hubProxy);

        if (connection) {
            connection.start();
        }
    }

    return (
        <ConnectionContext.Provider value={{
            proxy,
            connect
        }}>
            {props.children}
        </ConnectionContext.Provider>
    );
}

The realtime events we are interested in are the message-inserted event - for updating the conversations list and the badge event - for updating our badge control when the number of unread conversations changes.

const ConversationList = (conversationProps) => {
    ...

    const { proxy } = useContext(ConnectionContext);

    ...

    useEffect(() => {
        if (!proxy) return;
        proxy.on('eventReceived', (type, data) => {
            switch (type) {
                case "message-inserted.weavy":
                    messageReceived(data);
                    break;
                default:
            }
        });
    }, [proxy])
}

Weavy Server SDK

Let's move away from the Telerik Kendo React project for a while and focus on the Weavy installation that backs the UI we have been working with. You can disregard this section if you are running the default project at https://showcase.weavycloud.com.

Configuration

Start by cloning the Weavy Showcase project. Build the project and deploy locally or to a location of your choosing. Refer to the Getting Started Guide for help on setting up Weavy.

Complete the setup wizard, then navigate to /manage/clients. Add a new client and copy the values for client id and client secret. Also note, the URL Weavy is configured to run on.

Go back to the Telerik Kendo React project. Locate the file constants.js, open it and enter the API_URL.

Now that you have your own Weavy instance, the demo JWT tokens will not work. Those where configured with the Client ID and Client secret for the showcase.weavycloud.com site. You either need to create new tokens that you can use for development purposes or if you want to do it the real way, you should allow your users to authenticate against your own host system, and then dynamically create a valid JWT token for that user. Real JWT tokens should be short lived for maximum security.

REST API

Weavy is highly extendable, enabling developers the ability to modify most parts of the product. For this project we have been using REST API endpoints that we created custom for this project. Being able to modify and extend the Weavy REST API can be a powerful tool for building applications that are efficient and complex. Below you can see the endpoints used in this tutorial.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Web.Http;
using System.Web.Http.Description;
using Weavy.Areas.Api.Models;
using Weavy.Core;
using Weavy.Core.Models;
using Weavy.Core.Services;
using Weavy.Web.Api.Controllers;
using Weavy.Web.Api.Models;

namespace Weavy.Areas.Api.Controllers {

    /// <summary>
    /// Api controller for manipulating Conversations.
    /// </summary>
    [RoutePrefix("api")]
    public class ConversationsController : WeavyApiController {

        /// <summary>
        /// Get the <see cref="Conversation" /> with the specified id.
        /// </summary>
        /// <param name="id">The conversation id.</param>
        /// <example>GET /api/conversations/527</example>
        /// <returns>The specified conversation.</returns>
        [HttpGet]
        [ResponseType(typeof(Conversation))]
        [Route("conversations/{id:int}")]
        public IHttpActionResult Get(int id) {
            // read conversation
            ConversationService.SetRead(id, DateTime.UtcNow);

            var conversation = ConversationService.Get(id);
            if (conversation == null) {
                ThrowResponseException(HttpStatusCode.NotFound, $"Conversation with id {id} not found.");
            }
            return Ok(conversation);
        }

        /// <summary>
        /// Get all <see cref="Conversation" /> for the current user.
        /// </summary>        
        /// <example>GET /api/conversations</example>
        /// <returns>The users conversations.</returns>
        [HttpGet]
        [ResponseType(typeof(IEnumerable<Conversation>))]
        [Route("conversations")]
        public IHttpActionResult List() {
            var conversations = ConversationService.Search(new ConversationQuery());
            return Ok(conversations);
        }

        /// <summary>
        /// Create a new or get the existing conversation between the current- and specified user.
        /// </summary>
        /// <param name="model"></param>
        /// <returns></returns>
        [HttpPost]
        [ResponseType(typeof(Conversation))]
        [Route("conversations")]
        public IHttpActionResult Create(CreateConversationIn model) {
            string name = null;
            if (model.Members.Count() > 1) {
                name = string.Join(", ", model.Members.Select(u => UserService.Get(u).GetTitle()));
            }

            // create new room or one-on-one conversation or get the existing one
            return Ok(ConversationService.Insert(new Conversation() { Name = name }, model.Members));
        }

        /// <summary>
        /// Get the messages in the specified conversation.
        /// </summary>
        /// <param name="id">The conversation id.</param>
        /// <param name="opts">Query options for paging, sorting etc.</param>
        /// <returns>Returns a conversation.</returns>
        [HttpGet]
        [ResponseType(typeof(ScrollableList<Message>))]
        [Route("conversations/{id:int}/messages")]
        public IHttpActionResult GetMessages(int id, QueryOptions opts) {
            var conversation = ConversationService.Get(id);
            if (conversation == null) {
                ThrowResponseException(HttpStatusCode.NotFound, "Conversation with id " + id + " not found");
            }
            var messages = ConversationService.GetMessages(id, opts);
            messages.Reverse();
            return Ok(new ScrollableList<Message>(messages, Request.RequestUri));
        }

        /// <summary>
        /// Creates a new message in the specified conversation.
        /// </summary>
        /// <param name="id"></param>
        /// <param name="model"></param>
        /// <returns></returns>
        [HttpPost]
        [ResponseType(typeof(Message))]
        [Route("conversations/{id:int}/messages")]
        public IHttpActionResult InsertMessage(int id, InsertMessageIn model) {
            var conversation = ConversationService.Get(id);
            if (conversation == null) {
                ThrowResponseException(HttpStatusCode.NotFound, "Conversation with id " + id + " not found");
            }
            return Ok(MessageService.Insert(new Message { Text = model.Text, }, conversation));
        }

        /// <summary>
        /// Called by current user to indicate that they are typing in a conversation.
        /// </summary>
        /// <param name="id">Id of conversation.</param>
        /// <returns></returns>
        [HttpPost]
        [Route("conversations/{id:int}/typing")]
        public IHttpActionResult StartTyping(int id) {
            var conversation = ConversationService.Get(id);
            // push typing event to other conversation members
            PushService.PushToUsers(PushService.EVENT_TYPING, new { Conversation = id, User = WeavyContext.Current.User, Name = WeavyContext.Current.User.Profile.Name ?? WeavyContext.Current.User.Username }, conversation.MemberIds.Where(x => x != WeavyContext.Current.User.Id));
            return Ok(conversation);
        }

        /// <summary>
        /// Called by current user to indicate that they are no longer typing.
        /// </summary>
        /// <param name="id"></param>
        /// <returns></returns>
        [HttpDelete]
        [Route("conversations/{id:int}/typing")]
        public IHttpActionResult StopTyping(int id) {
            var conversation = ConversationService.Get(id);
            // push typing event to other conversation members
            PushService.PushToUsers("typing-stopped.weavy", new { Conversation = id, User = WeavyContext.Current.User, Name = WeavyContext.Current.User.Profile.Name ?? WeavyContext.Current.User.Username }, conversation.MemberIds.Where(x => x != WeavyContext.Current.User.Id));
            return Ok(conversation);
        }

        /// <summary>
        /// Marks a conversation as read for the current user.
        /// </summary>
        /// <param name="id">Id of the conversation to mark as read.</param>
        /// <returns>The read conversation.</returns>
        [HttpPost]
        [Route("conversations/{id:int}/read")]
        public Conversation Read(int id) {
            ConversationService.SetRead(id, readAt: DateTime.UtcNow);
            return ConversationService.Get(id);
        }

        /// <summary>
        /// Get the number of unread conversations.
        /// </summary>
        /// <returns></returns>
        [HttpGet]
        [ResponseType(typeof(int))]
        [Route("conversations/unread")]
        public IHttpActionResult GetUnread() {
            return Ok(ConversationService.GetUnread().Count());
        }
    }
}

In the StopTyping method you can see how easy it is to raise custom events. These events can easily be hooked up to using the Weavy Client SDK, as described in the The Weavy Client SDK and Realtime Events section.

Conclusion

This tutorial has been trying to demonstrate how you can use the Telerik Kendo React UI framework to work with data that is being served from and persisted in Weavy. Weavy is designed around well known standards and provides great flexibility when it comes to building your own stuff.