Building a Chat App Using Weavy and Telerik Conversational UI for ASP.NET MVC

Introduction

This tutorial will show how you can use the Telerik Conversational UI with Weavy acting as backend in an ASP.NET MVC Project. You will also see examples on how to use some other Telerik 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 2019 or Visual Studio for Mac
  • Installed trial or paid version of Telerik ASP.NET MVC. Head over to Telerik for more information.

Before you begin

Start by cloning the Telerik Project from Github. It contains all the code we are going to use for this tutorial. Run the project either via IIS Express or set it up as a IIS site.

The Telerik 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 essential functionallity for a chat app built with the Telerik Conversational UI. It is not a production ready application and should be regarded as an example on how to leverage Weavy functionality in a Telerik project.

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

  • Authentication - Hard coded against the public Weavy instance or using forms authentication against your own Weavy instance.
  • Listing conversations - Using the Telerik ListView component.
  • Creating conversations - Selecting Weavy recipients using the MultiSelect component.
  • Sending & Receiving messages - Using the Telerik Conversational UI backed by Weavy.
  • Badge - Showing unread conversations using the Telerik Badge component.
Most Telerik controls are instantiated using Kendo and javascript. This makes more sense, given how the REST API of Weavy is utilized. However, there is nothing that stops you from re-writing the code using solely Telerik server controls.

Authentication

By default, authentication is hardcoded against the public Weavy instance. There are four different user accounts you can sign in with. Authentication is done using static JWT tokens with long expiry solely for demo purposes.

Clicking sign in will set a local cookie using regular forms authentication. After signing in, the menu will now display the Chat menu-item.

The Chat Application

Navigate to /chat to view the application. There is alot going on here, we'll break it down for you.

Listing conversations

The Telerik ListView component is created using a Telerik datasource, that gets it's data from Weavy via the Weavy REST API.

// load conversations
_conversations = new kendo.data.DataSource({
    transport: {
        read: function (options) {
            _weavy.ajax("/api/conversations").then(function (conversations) {

                if (conversations.length === 0) {
                    $("#no-conversations").removeClass("d-none");
                } else {
                    $("#no-conversations").addClass("d-none");

                    conversations.forEach(function (c) {
                        c.thumb = _weavy.options.url + c.thumb.replace("{options}", "96");
                        if (c.isRoom) {
                            c.title = c.name;
                        } else {
                            if (c.members.length > 1) {
                                var u = c.members.filter(x => x.id !== _weavy.user.id)[0];
                                c.title = typeof (u.name) === "undefined" ? u.username : u.name;
                            } else {
                                c.title = c.members[0].name === "undefined" ? c.members[0].username : c.members[0].name;
                            }
                        }

                    });
                    options.success(conversations);

                    // open first conversation
                    if (_activeConversation == null) {
                        loadConversation(conversations[0].id);
                    }
                }
            });
        }
    }
});

_listView = $("#listView").kendoListView({
    dataSource: _conversations,
    selectable: "single",
    template: kendo.template($("#template").html())
}).data("kendoListView");

_listView.bind("change", onChangeConversation);

By using the Weavy Client SDK and it's built-in ajax() method, we don't need to worry about authentication (and other things), that is done under the hood and allows us to easily make requests and handle the response from the API.

For instance: The snippet below makes a GET request to the Weavy instance we have configured, returning the existing conversations for us to use when populating the DataSource.

_weavy.ajax("/api/conversations").then(function (conversations) {
    // work with the response...
});

Creating conversations

No conversations to display? Then we need to add some.

Click the New Conversation button and select the members of the conversation. The Telerik MultiSelect component is fed it's result from Weavy. After you click create the API is called and the conversation is created. The UI is then updated to reflect the new data.

// select users for new conversation
$("#users").kendoMultiSelect({
    placeholder: "Select conversation members...",
    dataTextField: "profile.name",
    dataValueField: "id",
    autoBind: false,
    dataSource: {
        serverFiltering: true,
        transport: {
            read: function (options) {
                _weavy.ajax("/api/users").then(function (users) {
                    users.data.forEach(function (u) {
                        if (typeof (u.profile.name) === "undefined") {
                            u.profile.name = u.username;
                        }
                    });
                    options.success(users.data);
                });
            }
        }
    }
});

...

// create new conversation
$(document).on("click", "#create-conversation", function () {
    if (_users.value().length > 0) {
        _weavy.ajax("/api/conversations", {
            members: _users.value()
        }, "POST").then(function (response) {
            $("#listView").data("kendoListView").dataSource.read();
            loadConversation(response.id);
            _users.value([]);
            $("#new-conversation").modal("hide");
        });
    }
});

The datasource part is bound to the /api/users endpoint, which returns existing Weavy users. Again, using the built in Weavy.ajax() methods makes it easy to work with.

When the Create button is clicked, the conversation is created and the UI is updated (list of conversations refreshed, chat loaded and the multiselect reset etc.)

The Chat Component

The Telerik Chat component is populated when a user clicks a conversation. First, the conversation is fetched and then all the messages in the conversation are retrieved and rendered using the renderMessage function.

// load messages in chat
function loadChat(id) {
    _weavy.ajax("/api/conversations/" + id).then(function (conversation) {

        _activeConversation = conversation.id;

        _weavy.ajax("/api/conversations/" + id + "/messages").then(function (messages) {

            if (_chat !== null) {
                $("#chat").data("kendoChat").destroy();
                $("#chat").empty();
            }

            $("#chat").kendoChat({
                user: {
                    iconUrl: _weavy.options.url + _weavy.user.thumb.replace("{options}", "96"),
                    name: _weavy.user.name
                }, sendMessage: function (e) {
                    _weavy.ajax("api/conversations/" + id + "/messages", {
                        text: e.text
                    }, "POST").then(function (message) {
                    });
                }, typingStart: function (e) {
                    _weavy.ajax("api/conversations/" + id + "/typing", null, "POST");
                }, typingEnd: function (e) {
                    _weavy.ajax("api/conversations/" + id + "/typing", null, "DELETE");
                }
            });

            _chat = $("#chat").data("kendoChat");

            if (typeof (messages.data) !== "undefined") {
                messages.data.forEach(function (m) {
                    renderMessage(m);
                });
            }
        });
    });
}

// renders a message in the active chat
function renderMessage(message) {
    var user = message.createdBy.id == _weavy.user.id ? _chat.getUser() : {
        id: message.createdBy.id,
        name: typeof (message.createdBy.name) === "undefined" ? message.createdBy.username : message.createdBy.name,
        iconUrl: _weavy.options.url + message.thumb.replace("{options}", "96"),
    };

    _chat.renderMessage({
        type: "text",
        text: message.text,
        timestamp: new Date(message.createdAt)
    }, user);
}

The typingStart and typingEnd events are hooked up so that a call to the API is done when typing status changes. These events are then distributed in realtime to other users, so that they will see when a member of the conversation is typing.

The Weavy Client SDK and Realtime Events

In order to take advantage of Weavy realtime functionality we are instantiating a Weavy Client. Via the client 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.

_weavy = new Weavy({ jwt: getToken });

    // wait for loaded event
    _weavy.whenLoaded().then(function () {

        // handle realtime events
        _weavy.connection.on("message-inserted.weavy", function (e, message) {
            if (message.conversation === _activeConversation && _weavy.user.id !== message.createdBy.id) {
                renderMessage(message);
            } else {
                $("#listView").data("kendoListView").dataSource.read();
            }
        });

        _weavy.connection.on("badge.weavy", function (e, item) {
            var badge = _badge.find(".k-badge")
            if (item.conversations > 0) {
                badge.removeClass("k-hidden");
            } else {
                badge.addClass("k-hidden");
            }
            badge.text(item.conversations);
        });

        _weavy.connection.on("typing.weavy", function (e, item) {
            if (item.conversation === _activeConversation) {
                _chat.renderUserTypingIndicator({ name: item.name, id: item.user.id });
            }
        });

        _weavy.connection.on("typing-stopped.weavy", function (e, item) {
            if (item.conversation === _activeConversation) {
                _chat.clearUserTypingIndicator({ name: item.name, id: item.user.id });
            }
        });

        // code removed for readability...
    });

The realtime events we are interested in are the message-inserted event - for updating the conversations list, the badge event - for updating our badge control when the number of unread conversations changes, the typing and typing-stopped event - for showing/hiding the typing indicator in the Telerik Chat component.
The typing-stopped event is actually a custom event that we trigger via the API. More on that later.

Weavy Server SDK

Let's move away from the Telerik MVC 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 MVC project. Locate the file ~/web.config, open it and enter the URL, client id and client secret in the appSettings section. See below:

<!-- Update weavy-url if you are using your own Weavy instance -->
<add key="weavy-url" value="https://showcase.weavycloud.com" />
    
<!-- Update these values to match with the Application Client in your Weavy instance -->
<add key="weavy-client-id" value="id" />
<add key="weavy-client-secret" value="secret" />

And lastly, in the Telerik MVC project, locate the file ~/Views/Account/SignIn.cshtml. There are two login forms on the page. Make sure that the first form is active. The second form is used when authenticating against the publicly available Weavy instance. Now you are using your own Weavy Server SDK.

The logic for autenticating users is really simple and unsecure for demo purposes. If you are interested in building something else, take a look in the ~/Controllers/AccountController.cs file.

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 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.