Meeting Companion

In this post we’re going to build a real-time web application with SignalR and React that lets you create chatrooms and send noises to each other, perfect as a meeting companion when you don’t want everybody coming off mute. If you follow along, you’ll be able to build and deploy your very own copy of Meeting Companion and run it completely free on Microsoft Azure. All of the code is available in my GitHub repository. This is a slightly simplified version of another webapp I built, Soundroom, which can also be found in my GitHub repository.

Project setup

Let’s get started by creating our project directory and adding the required boilerplate. Open up your favourite terminal and run the following commands to create a blank React app for the front-end and a .NET Core web application for the back-end.

mkdir meeting-companion
cd meeting-companion
npx create-react-app .
dotnet new web
yarn build

Next we need to enable static file serving on our backend so that it can serve our front-end. In Program.cs, make the following change.

@@ -21,6 +21,7 @@ namespace meeting_companion
                 .ConfigureWebHostDefaults(webBuilder =>
                 {
                     webBuilder.UseStartup();
+                    webBuilder.UseWebRoot("build");
                 });
     }
 }

In Startup.cs, make the following two changes.

@@ -26,14 +26,11 @@ namespace meeting_companion
                 app.UseDeveloperExceptionPage();
             }

+            app.UseFileServer();
             app.UseRouting();

             app.UseEndpoints(endpoints =>
             {
-                endpoints.MapGet("/", async context =>
-                {
-                    await context.Response.WriteAsync("Hello World!");
-                });
             });
         }
     }

Now we can build and run the front-end and back-end.

yarn build
dotnet run

You should now be able to open your browser to http://localhost:5000 and see the default React app landing page.

If you’re planning to version-control this project, now would be a good time to add the bin/ and obj/ directories to .gitignore. Add the following two lines anywhere to .gitignore.

bin/
obj/

Adding SignalR to the back-end

Next up, we will add SignalR to the backend. The first thing we need to do is create a Hub. Hubs are similar to controllers in that they accept incoming messages and decide how they get processed. Our app is very simple and will only require a single hub, so create a new file called MeetingHub.cs and add the following code.

using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;

namespace meeting_companion
{
    public class MeetingHub : Hub
    {
        public Task Send(string sound) => Clients.All.SendAsync(sound);
    }
}

This hub can receive Send messages which contain a sound parameter. When it receives one of these messages, it will forward it straight through to all connected clients. We don’t need to add any code for storing incoming connections or anything like that—this is all handled by the Hub base class. Next we need to register the hub and connect it to the ASP.NET routing middleware. Back in Startup.cs, make the following two change.

@@ -16,6 +16,7 @@ namespace meeting_companion
         // ...
         public void ConfigureServices(IServiceCollection services)
         {
+            services.AddSignalR();
         }

         // ...
@@ -31,6 +32,7 @@ namespace meeting_companion

             app.UseEndpoints(endpoints =>
             {
+                endpoints.MapHub<MeetingHub>("/meeting");
             });
         }
     }

That’s all we need to add to the back-end! You could build and run again, but we haven’t added any observable changes yet.

Adding SignalR to the front-end

Next up, we’ll add SignalR to the front-end. The first thing we need to do is add the signalr package.

yarn add @microsoft/signalr

Now we’ll import the signalr package and create a connection to our hub. We’re also going to create a helper object to hide some of the details of interacting with the connection from the rest of our application. Open src/index.js and make the following two changes.

@@ -3,10 +3,21 @@ import ReactDOM from 'react-dom';
 import './index.css';
 import App from './App';
 import * as serviceWorker from './serviceWorker';
+import { HubConnectionBuilder } from '@microsoft/signalr';
+
+const connection = new HubConnectionBuilder()
+  .withUrl('/meeting')
+  .build();
+connection.start();
+
+const connectionProxy = {
+  send: sound => connection.invoke('send', sound),
+  recv: (sound, callback) => connection.on(sound, callback)
+};

 ReactDOM.render(
   <React.StrictMode>
-    <App />
+    <App connection={connectionProxy} />
   </React.StrictMode>,
   document.getElementById('root')
 );

Our application can now use the send method of this object to send a message to the Send message handler of our MeetingHub, and the recv method to register a callback that will fire any time a response message is received from the server.

Our front-end will now connect to our back-end, but because we haven’t written any logic to send messages, we still won’t be able to observe any interesting behaviour from running the app. Hang in there just a little bit longer.

Building out the front-end

Now it’s time to add some interactivity to the front-end of the app so we can actually have fun with it. We’re going to add a few buttons to the screen, and when you press the button it should play a sound for everybody also connected. This is going to involve using the send method of our connection proxy to send a message to the server, and then the recv method to hook up incoming messages from the server to audio players.

The first thing we’ll do is create a Button component which displays a button on the screen, adds an audio player, and wires them both up to the connection, so create a new file called src/Button.js and add the following code.

import React from 'react';

export default function Button ({ icon, src, alt, connection }) {
  const audio = React.createRef();
  connection.recv(icon, () => audio.current.play());
  return <>
    <button aria-title={alt} onClick={() => connection.send(icon)}>{icon}</button>
    <audio ref={audio} src={src} />
  </>;
};

The button takes as properties an emoji to display on the button, the filename of the audio, a title for accessibility, and the connection object. When the client-side connection receives the icon back as a message from the server, it will cause the audio player to fire, and pressing the button will cause the connection to send the icon to the server’s Send message handler (which we previously programmed to forward the message through to all connections).

Next up we need to add a bunch of these buttons to the app. Open up src/App.js and replace the App definition with the following.

import Button from './Button';

function App({ connection }) {
  return (
    <div className="App">
      <Button connection={connection} icon="👏" alt="Clap" src="/audio/clap.mp3" />
      <Button connection={connection} icon="👍" alt="Yes" src="/audio/yes.mp3" />
      <Button connection={connection} icon="👎" alt="No" src="/audio/no.mp3" />
      <Button connection={connection} icon="🔍" alt="Zoom" src="/audio/zoom.mp3" />
      <Button connection={connection} icon="🧏‍♀️" alt="Louder" src="/audio/louder.mp3" />
      <Button connection={connection} icon="💬" alt="Check chat" src="/audio/chat.mp3" />
    </div>
  );
}

You’ll need to create a public/audio directory and add audio files there—you can grab sample files from my GitHub repository.

All that’s left now is to fix up the CSS. Open up src/App.css and replace all of the content with the following.

.App {
  display: flex;
  flex-flow: wrap;
  justify-content: center;
}

.App button { 
  font-size: 5em;
}

Now we finally have something worth running. Run the following again.

yarn build
dotnet run

Now you should see the six buttons on the screen, and when you press any of them you should hear the corresponding audio file. If you open a second tab, pressing a button in either tab should cause audio to play in both tabs.

Adding rooms

The app so far is pretty interesting, but I said we were going to be able to create separate rooms—so let’s add that feature in now. This is going to be deceptively easy, so don’t blink or you’ll miss it.

To start with, we’re going to add a new message handler to our back-end to accept requests to join a room. We’ll also tweak our Send message handler to take an additional parameter to identify the room to which the message should be sent. Open MeetingHub.cs and make the following change.

@@ -5,6 +5,9 @@ namespace meeting_companion
 {
     public class MeetingHub : Hub
     {
-        public Task Send(string sound) => Clients.All.SendAsync(sound);
+        public Task Join(string room)
+            => Groups.AddToGroupAsync(Context.ConnectionId, room);
+        public Task Send(string room, string sound)
+            => Clients.Group(room).SendAsync(sound);
     }
 }

That’s all we need from the back-end. We don’t need to write any code to handle group membership and distribution—again that’s all handled by the Hub base class.

Now we need to update the front-end to send a Join message when the connection starts, and change the way we send Send messages to include the room name. And we also need to let the user pick the room name. We’ll be extremely lazy and just take the room name from the URL hash.

Open src/App.js and make the following three changes.

@@ -5,13 +5,15 @@ import App from './App';
 import * as serviceWorker from './serviceWorker';
 import { HubConnectionBuilder } from '@microsoft/signalr';

+const room = window.location.hash.substr(1) || '%DEFAULT%';
+
 const connection = new HubConnectionBuilder()
   .withUrl('/meeting')
   .build();
-connection.start();
+connection.start().then(() => connection.invoke('join', room));

 const connectionProxy = {
-  send: sound => connection.invoke('send', sound),
+  send: sound => connection.invoke('send', room, sound),
   recv: (sound, callback) => connection.on(sound, callback)
 };

And that’s all we need! Build and run again, but this time make sure to open a few tabs in the same room and a few in different rooms. You can use http://localhost:5000/#room-1 and http://localhost:5000/#room-2 as examples.

Deploying to Azure

We can deploy this to an App Service, optionally with SignalR Service, both of which have free tiers. The simplest way to do this which will suffice for this blog post is via the dreaded right-click publish, by opening meeting-companion.csproj in Visual Studio and using Publish.

Good luck, and I hope my instructions were complete and accurate. If not, check the source code in my GitHub repository or get in touch.