Rickard Hansson Written by Rickard Hansson 7 min read

Building In-App Chat Using Weavy and Telerik UI for Blazor

Introduction

This tutorial will show how you can use Telerik's Blazor Server-Side UI Components with Weavy acting as backend to build a chat app in a .NET Blazor Server 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 UI for Blazor. Head over to Telerik for more information.
Although this project targets .NET Blazor Server, it should be possible to quite easily port the application to .NET Blazor WebAssembly.

Before you begin

Start by cloning the project from Github. It contains all the code you need for this tutorial.

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.

App Composition

The app will showcase functionality for a chat app built with Telerik Blazor UI Components. It is not a production ready application and should be regarded as an example on how to leverage Weavy functionality in a Telerik/Blazor project.

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

  • Authentication - Using Hard coded JWT tokens against the public Weavy instance.
  • Listing conversations & Messages - Using the ListView component & custom markup for displaying messages.
  • Creating conversations - Selecting recipients using the Telerik MultiSelect component.
  • Sending & Receiving messages - Using the Weavy API and Weavy SignalR real time functionality.
  • Weavy Drop-in UI - Showing how easy it is to embed the Weavy Drop-in UI into your Blazor project.

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. Every time a request is made for the Weavy API that static JWT token will be passed along as a Bearer token.

Authorization: Bearer <jwt-token>

The token will be un-wrapped and validated in Weavy. If validation succeeds (it will ;-), you will be authenticated as the user you signed in with.

Working with static JWT tokens is of course not something you would use in production. To learn more about JWT in Weavy visit our Single Sign-On tutorial.
telerik-blazor-authenticate

Clicking a user will sign you in as that user, using cookie-based authentication. After signing in you will be presented with a menu for app navigation.

The Chat Application

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

telerik-blazor-chatapp

The Chat Application.

You will find most of the relevant code in the file /Pages/Chat.razor.

Listing conversations

The conversations are fetched using the Weavy REST API when the components are first initialized. Calls to the API is routed through a simple C# class called ChatService. It contains functions that map to the ones in the Weavy API and simply performs the HTTP calls and deserializes the JSON responses.

// invoked when the component is initialized after having received its initial parameters
protected override async Task OnInitializedAsync() {
...       
    conversations = await ChatService.GetConversationsAsync();
...
}

When the conversations property has been set, Telerik will automatically bind and populate the conversations since the conversations property is defined as the Data attribute of a ListView component. You can read more about Telerik Blazor data binding in this Telerik Getting Started article.

<TelerikListView Data="@conversations" Class="conversations">
    <Template>
        <div class="listview-item conversation" @onclick="@(_ => LoadConversation(context.id))">
            <img src="@context.thumb.AsAbsoluteUrl()" alt="">
            <h3>@context.title</h3>
            <div class="p">@context.last_message?.html</div>
        </div>
    </Template>
</TelerikListView>

The template defines how to render the items. Notice the onclick handler. When triggered a call will be made to the LoadConversation function with the id of the conversation to load:

// loads messages from a conversation and updates the UI
protected async Task LoadConversation(int conversationId) {
    isMessagesLoading = true;
    activeConversation = conversationId;

    // make sure the correct css class is used in the listview
    conversations = conversations.Select(x => { x.selected = x.id == activeConversation ? true : false; return x; }).ToArray();

    // get messages in conversation
    messages = await ChatService.GetMessagesAsync(conversationId);

    if (messages != null && messages.data != null) {
        // some grouping of messages to get the markup we need
        messageMarkup = new MarkupString(GetMarkup(messages.data.ToArray()));
    } else {
        messageMarkup = new MarkupString("");
    }

    // clear loading indicator
    isMessagesLoading = false;
}

Again, the Weavy API is responsible for returning the actual data we need. In this case the messages of the conversation that was clicked. The message objects returned are then turned into a string with the markup we need in order to render the content of the conversation. When the messageMarkup property is updated it will automatically re-render the messages view.

Did you notice the statement: isMessagesLoading = true;? That boolean property is data bound to a <TelerikLoader /> component. When the property is true the component is visible:

<TelerikLoader Class="loader-indicator" ThemeColor="primary" Visible="@isMessagesLoading" Type="LoaderType.InfiniteSpinner" Size="LoaderSize.Large"></TelerikLoader>

A super easy way to let the user know the UI is being updated.

Creating conversations

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

Click the New Conversation... button and select the members you want to be included in the conversation. The TelerikMultiSelect component is fed it's result from all available chat users in Weavy. After you click create the API is called and the conversation is created. The UI is then updated to reflect the new data.

<TelerikWindow Size="WindowSize.Small" Centered="true" @bind-Visible="addConversationModalVisible" Width="400px" Modal="true">
    <WindowTitle>
        <strong>Add Conversation</strong>
    </WindowTitle>
    <WindowActions>
        <WindowAction Name="Close" />
    </WindowActions>
    <WindowContent>
        <TelerikMultiSelect Data="@users.data" TextField="title" TItem="User" TValue="int" ValueField="id" @bind-Value="@selectedMembers" AutoClose="false" Placeholder="Select Conversation Members" Width="100%"></TelerikMultiSelect>
        <TelerikButton OnClick="@(_ => CreateConversation())" Primary="true" ButtonType="ButtonType.Button" Class="create">Create</TelerikButton>
    </WindowContent>
</TelerikWindow>

Alot of databinding going on here: The TelerikMultiSelect is getting its data from the Weavy API via the Data attribute and the members that the user selects are bound to the selectedMembers property. All controls are wrapped in a TelerikWindow component for a nice presentaion.

Realtime Events

Realtime events in Weavy are pushed to users using SignalR. In order to recieve realtime events from the Weavy instance, we first hook us up against the Weavy realtime-hub using the javascript client.


var wvy = wvy || {}; wvy.interop = (function ($) { var weavyConnection = $.hubConnection("https://showcase.weavycloud.com/signalr", { useDefaultPath: false });

// enable additional logging weavyConnection.logging = true; // log errors weavyConnection.error(function (error) { console.warn('SignalR error: ' + error) }); var rtmProxy = weavyConnection.createHubProxy('rtm'); rtmProxy.on('eventReceived', function (name, data) { // log incoming event console.debug(name, data); // when we receive an event on the websocket we publish it to our subscribers via js interop DotNet.invokeMethodAsync('WeavyTelerikBlazor', 'ReceivedEvent', name, data); }); // connect to weavy realtime hub function connect() { weavyConnection.start().done(function () { console.debug("weavy connection:", weavyConnection.id); }).fail(function () { console.error("could not connect to weavy"); }); } return { connect: connect } })(jQuery);

wwwroot\js\interop.js

All events are recieved here and then routed to C# (on the server) using a Blazor techology called JavaScript interoperability (JS interop), which enables javascript to call .NET methods and vice versa.

After some additional framework magic the event ends up in the Chat.razor component and can easily be handled. You can find a list of available events in the Server API / Weavy.Core.Events section on docs.weavy.com.

...

// called after a component has finished rendering
protected override async Task OnAfterRenderAsync(bool firstRender) {
    if (firstRender && user.Identity.IsAuthenticated) {
        // connect to weavy for listening to realtime events
        await JS.InvokeVoidAsync("wvy.interop.connect");

        // add event handler for realtime event        
        Realtime.OnMessage += HandleMessage;        
        Realtime.OnTyping += HandleTyping;    
    }
}
        
...
        
// event handler for new message events
async void HandleMessage(object sender, MessageEventArgs e) {
    Console.WriteLine($"Chat received message event");    
        
    if (e.Data.conversation == activeConversation) {        
        await LoadConversation(e.Data.conversation);
    } else {
        conversations = await ChatService.GetConversationsAsync();
    }
    StateHasChanged();
}
        
// event handler for typing events
void HandleTyping(object sender, TypingEventArgs e) {
    Console.WriteLine("@" + e.Data.user.username + " is typing in conversation " + e.Data.conversation);
}

Weavy Drop-in UI

So far we have only been using Weavy as an API in order to get the chat app up and running. Now we are going to utilize the Weavy Drop-in UI. You'll see how easy it is to incorporate the Weavy built in UI in an app.

The Weavy Client SDK has been packaged as Blazor component and using it is super simple.

<Weavy>
    <WeavyApp SpaceKey="telerik-blazor-ui" Height="100vh" key="posts" type="posts" />
</Weavy>

/Pages/Index.razor

Here you can see the Drop-in UI in action. If the user is authenticated, the <Weavy> component will load.

For a visible UI of a perticular app type you simply add the app you want as a <WeavyApp> element. In this case a space with a key of telerik-blazor-ui will be created or fetched and a post app will be added to the space and then rendered in place. Here's the result:

telerik-blazor-dropin

The source code for the Blazor Weavy component is available on GitHub.

It's really easy to get started using the Weavy Drop-in UI. With just a few lines of code you get feature complete apps that can be created on the fly.

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());
        }
    }
}

These events can easily be hooked up to using the Weavy Client SDK.

Conclusion

This tutorial has been trying to demonstrate how you can use the Telerik Blazor 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.

Topics

In this article