Sending Direct Messages using SignalR with ASP.NET core and Angular

This article should how could be used to send direct messages between different clients using ASP.NET Core to host the SignalR Hub and Angular to implement the clients.

Code: https://github.com/damienbod/AspNetCoreAngularSignalRSecurity

Other posts in this series:

When the application is started, different clients can log in using an email, if already registered, and can send direct messages from one SignalR client to the other SignalR client using the email of the user which was used to sign in. All messages are sent using a JWT token which is used to validate the identity.

The latest Nuget package can be added to the ASP.NET Core project in the csproj file, or by using the Visual Studio Nuget package manager to add the package.

<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.0.0-alpha2-final" />

A single SignalR Hub is used to add the logic to send the direct messages between the clients. The Hub is protected using the bearer token authentication scheme which is defined in the Authorize filter. A client can leave or join using the Context.User.Identity.Name, which is configured to use the email of the Identity. When the user joins, the connectionId is saved to the in-memory database, which can then be used to send the direct messages. All other online clients are sent a message, with the new user data. The actual client is sent the complete list of existing clients.

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace ApiServer.SignalRHubs
{
    [Authorize(AuthenticationSchemes = "Bearer")]
    public class UsersDmHub : Hub
    {
        private UserInfoInMemory _userInfoInMemory;

        public UsersDmHub(UserInfoInMemory userInfoInMemory)
        {
            _userInfoInMemory = userInfoInMemory;
        }

        public async Task Leave()
        {
            _userInfoInMemory.Remove(Context.User.Identity.Name);
            await Clients.AllExcept(new List<string> { Context.ConnectionId }).InvokeAsync(
                   "UserLeft",
                   Context.User.Identity.Name
                   );
        }

        public async Task Join()
        {
            if (!_userInfoInMemory.AddUpdate(Context.User.Identity.Name, Context.ConnectionId))
            {
                // new user

                var list = _userInfoInMemory.GetAllUsersExceptThis(Context.User.Identity.Name).ToList();
                await Clients.AllExcept(new List<string> { Context.ConnectionId }).InvokeAsync(
                    "NewOnlineUser",
                    _userInfoInMemory.GetUserInfo(Context.User.Identity.Name)
                    );
            }
            else
            {
                // existing user joined again
                
            }

            await Clients.Client(Context.ConnectionId).InvokeAsync(
                "Joined",
                _userInfoInMemory.GetUserInfo(Context.User.Identity.Name)
                );

            await Clients.Client(Context.ConnectionId).InvokeAsync(
                "OnlineUsers",
                _userInfoInMemory.GetAllUsersExceptThis(Context.User.Identity.Name)
            );
        }

        public Task SendDirectMessage(string message, string targetUserName)
        {
            var userInfoSender = _userInfoInMemory.GetUserInfo(Context.User.Identity.Name);
            var userInfoReciever = _userInfoInMemory.GetUserInfo(targetUserName);
            return Clients.Client(userInfoReciever.ConnectionId).InvokeAsync("SendDM", message, userInfoSender);
        }
    }
}

The UserInfoInMemory is used as an in-memory database, which is nothing more than a ConcurrentDictionary to manage the online users.

System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;

namespace ApiServer.SignalRHubs
{
    public class UserInfoInMemory
    {
        private ConcurrentDictionary<string, UserInfo> _onlineUser { get; set; } = new ConcurrentDictionary<string, UserInfo>();

        public bool AddUpdate(string name, string connectionId)
        {
            var userAlreadyExists = _onlineUser.ContainsKey(name);

            var userInfo = new UserInfo
            {
                UserName = name,
                ConnectionId = connectionId
            };

            _onlineUser.AddOrUpdate(name, userInfo, (key, value) => userInfo);

            return userAlreadyExists;
        }

        public void Remove(string name)
        {
            UserInfo userInfo;
            _onlineUser.TryRemove(name, out userInfo);
        }

        public IEnumerable<UserInfo> GetAllUsersExceptThis(string username)
        {
            return _onlineUser.Values.Where(item => item.UserName != username);
        }

        public UserInfo GetUserInfo(string username)
        {
            UserInfo user;
            _onlineUser.TryGetValue(username, out user);
            return user;
        }
    }
}

The UserInfo class is used to save the ConnectionId from the SignalR Hub, and the user name.

namespace ApiServer.SignalRHubs
{
    public class UserInfo
    {
        public string ConnectionId { get; set; }
        public string UserName { get; set; }
    }
}

The JWT Bearer token is configured in the startup class, to read the token from the URL parameters.

var tokenValidationParameters = new TokenValidationParameters()
{
 ValidIssuer = "https://localhost:44318/",
 ValidAudience = "dataEventRecords",
 IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("dataEventRecordsSecret")),
 NameClaimType = "name",
 RoleClaimType = "role", 
};

var jwtSecurityTokenHandler = new JwtSecurityTokenHandler
{
 InboundClaimTypeMap = new Dictionary<string, string>()
};

services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
 options.Authority = "https://localhost:44318/";
 options.Audience = "dataEventRecords";
 options.IncludeErrorDetails = true;
 options.SaveToken = true;
 options.SecurityTokenValidators.Clear();
 options.SecurityTokenValidators.Add(jwtSecurityTokenHandler);
 options.TokenValidationParameters = tokenValidationParameters;
 options.Events = new JwtBearerEvents
 {
  OnMessageReceived = context =>
  {
   if ( (context.Request.Path.Value.StartsWith("/loo")) || (context.Request.Path.Value.StartsWith("/usersdm")) 
    && context.Request.Query.TryGetValue("token", out StringValues token)
   )
   {
    context.Token = token;
   }

   return Task.CompletedTask;
  },
  OnAuthenticationFailed = context =>
  {
   var te = context.Exception;
   return Task.CompletedTask;
  }
 };
});

Angular SignalR Client

The Angular SignalR client is implemented using the npm package “@aspnet/signalr-client”: “1.0.0-alpha2-final”

A ngrx store is used to manage the states sent, received from the API. All SiganlR messages are sent using the DirectMessagesService Angular service. This service is called from the ngrx effects, or sends the received information to the reducer of the ngrx store.

import 'rxjs/add/operator/map';
import { Subscription } from 'rxjs/Subscription';

import { HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';

import { HubConnection } from '@aspnet/signalr-client';
import { Store } from '@ngrx/store';
import * as directMessagesActions from './store/directmessages.action';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { OnlineUser } from './models/online-user';

@Injectable()
export class DirectMessagesService {

    private _hubConnection: HubConnection;
    private headers: HttpHeaders;

    isAuthorizedSubscription: Subscription;
    isAuthorized: boolean;

    constructor(
        private store: Store<any>,
        private oidcSecurityService: OidcSecurityService
    ) {
        this.headers = new HttpHeaders();
        this.headers = this.headers.set('Content-Type', 'application/json');
        this.headers = this.headers.set('Accept', 'application/json');

        this.init();
    }

    sendDirectMessage(message: string, userId: string): string {

        this._hubConnection.invoke('SendDirectMessage', message, userId);
        return message;
    }

    leave(): void {
        this._hubConnection.invoke('Leave');
    }

    join(): void {
        this._hubConnection.invoke('Join');
    }

    private init() {
        this.isAuthorizedSubscription = this.oidcSecurityService.getIsAuthorized().subscribe(
            (isAuthorized: boolean) => {
                this.isAuthorized = isAuthorized;
                if (this.isAuthorized) {
                    this.initHub();
                }
            });
        console.log('IsAuthorized:' + this.isAuthorized);
    }

    private initHub() {
        console.log('initHub');
        const token = this.oidcSecurityService.getToken();
        let tokenValue = '';
        if (token !== '') {
            tokenValue = '?token=' + token;
        }
        const url = 'https://localhost:44390/';
        this._hubConnection = new HubConnection(`${url}usersdm${tokenValue}`);

        this._hubConnection.on('NewOnlineUser', (onlineUser: OnlineUser) => {
            console.log('NewOnlineUser received');
            console.log(onlineUser);
            this.store.dispatch(new directMessagesActions.ReceivedNewOnlineUser(onlineUser));
        });

        this._hubConnection.on('OnlineUsers', (onlineUsers: OnlineUser[]) => {
            console.log('OnlineUsers received');
            console.log(onlineUsers);
            this.store.dispatch(new directMessagesActions.ReceivedOnlineUsers(onlineUsers));
        });

        this._hubConnection.on('Joined', (onlineUser: OnlineUser) => {
            console.log('Joined received');
            this.store.dispatch(new directMessagesActions.JoinSent());
            console.log(onlineUser);
        });

        this._hubConnection.on('SendDM', (message: string, onlineUser: OnlineUser) => {
            console.log('SendDM received');
            this.store.dispatch(new directMessagesActions.ReceivedDirectMessage(message, onlineUser));
        });

        this._hubConnection.on('UserLeft', (name: string) => {
            console.log('UserLeft received');
            this.store.dispatch(new directMessagesActions.ReceivedUserLeft(name));
        });

        this._hubConnection.start()
            .then(() => {
                console.log('Hub connection started')
                this._hubConnection.invoke('Join');
            })
            .catch(() => {
                console.log('Error while establishing connection')
            });
    }

}

The DirectMessagesComponent is used to display the data, or send the events to the ngrx store, which in turn, sends the data to the SignalR server.

import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs/Subscription';
import { Store } from '@ngrx/store';
import { DirectMessagesState } from '../store/directmessages.state';
import * as directMessagesAction from '../store/directmessages.action';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { OnlineUser } from '../models/online-user';
import { DirectMessage } from '../models/direct-message';
import { Observable } from 'rxjs/Observable';

@Component({
    selector: 'app-direct-message-component',
    templateUrl: './direct-message.component.html'
})

export class DirectMessagesComponent implements OnInit, OnDestroy {
    public async: any;
    onlineUsers: OnlineUser[];
    onlineUser: OnlineUser;
    directMessages: DirectMessage[];
    selectedOnlineUserName = '';
    dmState$: Observable<DirectMessagesState>;
    dmStateSubscription: Subscription;
    isAuthorizedSubscription: Subscription;
    isAuthorized: boolean;
    connected: boolean;
    message = '';

    constructor(
        private store: Store<any>,
        private oidcSecurityService: OidcSecurityService
    ) {
        this.dmState$ = this.store.select<DirectMessagesState>(state => state.dm.dm);
        this.dmStateSubscription = this.store.select<DirectMessagesState>(state => state.dm.dm)
            .subscribe((o: DirectMessagesState) => {
                this.connected = o.connected;
            });

    }

    public sendDm(): void {
        this.store.dispatch(new directMessagesAction.SendDirectMessageAction(this.message, this.onlineUser.userName));
    }

    ngOnInit() {
        this.isAuthorizedSubscription = this.oidcSecurityService.getIsAuthorized().subscribe(
            (isAuthorized: boolean) => {
                this.isAuthorized = isAuthorized;
                if (this.isAuthorized) {
                }
            });
        console.log('IsAuthorized:' + this.isAuthorized);
    }

    ngOnDestroy(): void {
        this.isAuthorizedSubscription.unsubscribe();
        this.dmStateSubscription.unsubscribe();
    }

    selectChat(onlineuserUserName: string): void {
        this.selectedOnlineUserName = onlineuserUserName
    }

    sendMessage() {
        console.log('send message to:' + this.selectedOnlineUserName + ':' + this.message);
        this.store.dispatch(new directMessagesAction.SendDirectMessageAction(this.message, this.selectedOnlineUserName));
    }

    getUserInfoName(directMessage: DirectMessage) {
        if (directMessage.fromOnlineUser) {
            return directMessage.fromOnlineUser.userName;
        }

        return '';
    }

    disconnect() {
        this.store.dispatch(new directMessagesAction.Leave());
    }

    connect() {
        this.store.dispatch(new directMessagesAction.Join());
    }
}

The Angular HTML template displays the data using Angular material.

<div class="full-width" *ngIf="isAuthorized">
    <div class="left-navigation-container" >
        <nav>

            <mat-list>
                <mat-list-item *ngFor="let onlineuser of (dmState$|async)?.onlineUsers">
                    <a mat-button (click)="selectChat(onlineuser.userName)">{{onlineuser.userName}}</a>
                </mat-list-item>
            </mat-list>

        </nav>
    </div>
    <div class="column-container content-container">
        <div class="row-container info-bar">
            <h3 style="padding-left: 20px;">{{selectedOnlineUserName}}</h3>
            <a mat-button (click)="sendMessage()" *ngIf="connected && selectedOnlineUserName && selectedOnlineUserName !=='' && message !==''">SEND</a>
            <a mat-button (click)="disconnect()" *ngIf="connected">Disconnect</a>
            <a mat-button (click)="connect()" *ngIf="!connected">Connect</a>
        </div>

        <div class="content" *ngIf="selectedOnlineUserName && selectedOnlineUserName !==''">

            <mat-form-field  style="width:95%">
                <textarea matInput placeholder="your message" [(ngModel)]="message" matTextareaAutosize matAutosizeMinRows="2"
                          matAutosizeMaxRows="5"></textarea>
            </mat-form-field>
           
            <mat-chip-list class="mat-chip-list-stacked">
                <ng-container *ngFor="let directMessage of (dmState$|async)?.directMessages">

                    <ng-container *ngIf="getUserInfoName(directMessage) !== ''">
                        <mat-chip selected="true" style="width:95%">
                            {{getUserInfoName(directMessage)}} {{directMessage.message}}
                        </mat-chip>
                    </ng-container>
                       
                    <ng-container *ngIf="getUserInfoName(directMessage) === ''">
                        <mat-chip style="width:95%">
                            {{getUserInfoName(directMessage)}} {{directMessage.message}}
                        </mat-chip>
                    </ng-container>

                    </ng-container>
            </mat-chip-list>

        </div>
    </div>
</div>

Links

https://github.com/aspnet/SignalR

https://github.com/aspnet/SignalR#readme

https://github.com/ngrx

https://www.npmjs.com/package/@aspnet/signalr-client

https://dotnet.myget.org/F/aspnetcore-ci-dev/api/v3/index.json

https://dotnet.myget.org/F/aspnetcore-ci-dev/npm/

https://dotnet.myget.org/feed/aspnetcore-ci-dev/package/npm/@aspnet/signalr-client

https://www.npmjs.com/package/msgpack5

Tags: , , , ,

This content was originally published here.

Categories: Mobile App
vinova: