Using SignalR, IOptionsMonitor with Umbraco Bellissima for reactive .NET Options

Hello 👋
I have been working on a community package for Umbraco Bellissima called Content Lock, that allows users to lock content nodes and see who else is logged into the backoffice currently. Some of this work uses SignalR to give realtime updates pushed out to the backoffice UI.

For this I wanted to added some .NET Options to allow users of the package to turn certain features on or off. As I had a SignalR hub already in my package, I wondered if I could combine the use of IOptionsMonitor and a SignalR hub to allow features to be turned on and off easily without requiring a deployment and with a bit of time I have managed to achieve exactly that.

Lets take a look at it in action

Show me the code

Below is the various files and code used to make this happen, hopefully all the various code snippets helps explains how it all glues together, but any questions feel free to hit me up in the comments or message me.

.NET Options Class

namespace ContentLock.Options;

public class ContentLockOptions
{
    public const string ConfigName = "ContentLock";

    /// <summary>
    /// Settings related to the feature of showing the number of online users in the backoffice
    /// </summary>
    public OnlineUsersOptions OnlineUsers { get; set; } = new();

    public class OnlineUsersOptions
    {
        /// <summary>
        /// Enable or disable the online users feature.
        /// This is used to indicate the number of online users in the backoffice
        /// and displaying the names of the users
        /// By default this is enabled
        /// </summary>
        public bool Enable { get; set; } = true;

        /// <summary>
        /// Settings to control the sounds played when a user logs in or logs out
        /// </summary>
        public SoundsOptions Sounds { get; set; } = new();
        
        public class SoundsOptions
        {
            /// <summary>
            /// Enable or disable the audio notifications for a
            /// user logging in or out of the backoffice
            /// By default this feature is enabled
            /// </summary>
            public bool Enable { get; set; } = true;

            /// <summary>
            /// Path to the login sound file
            /// This can be a relative path or an absolute URL.
            /// </summary>
            public string LoginSound { get; set; } = "/App_Plugins/ContentLock/sounds/login.mp3";

            /// <summary>
            /// Path to the logout sound file.
            /// This can be a relative path or an absolute URL.
            /// </summary>
            public string LogoutSound { get; set; } = "/App_Plugins/ContentLock/sounds/logout.mp3";
        }
    }
}

Registering Options

For my use case I have registered the Options using a Composer, but you may prefer to do it differently if you are not creating a package that is being consumed by others.

// Mine is placed inside ContentLockSignalRComposer
builder.Services
   .AddOptions<ContentLockOptions>()
   .Bind(builder.Config.GetSection(ContentLockOptions.ConfigName));

Registering a SignalR Hub

For this part I suggest you refer to the documentation from Umbraco themselves.
https://docs.umbraco.com/umbraco-cms/implementation/custom-routing/signalr

This documentation involves and covers the following:

  • Creating an interface for the SignalR Hub and its methods
  • Create a sample SignalR Hub based on the interface
  • Creating the route with Umbraco for the SignalR Hub
  • Registering the route with Umbraco in the UmbracoPipelineFilter

SignalR Hub with IOptionsMonitor

using System.Collections.Concurrent;
using ContentLock.Interfaces;
using ContentLock.Options;

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Options;

using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;

namespace ContentLock.SignalR;

[Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)]
public class ContentLockHub : Hub<IContentLockHubEvents>
{
    private readonly IOptionsMonitor<ContentLockOptions> _options;

    public ContentLockHub(IOptionsMonitor<ContentLockOptions> options)
    {
        _contentLockService = contentLockService;
        _options = options;
        _options.OnChange(OnOptionsChanged);
    }

    private void OnOptionsChanged(ContentLockOptions options)
    {
        // Notify all connected clients of the new options values
        // As the value has been changed
        this.Clients.All.ReceiveLatestOptions(options);
    }

    public override async Task<Task> OnConnectedAsync()
    {
        await GetCurrentOptions();
        return base.OnConnectedAsync();
    }

    private async Task GetCurrentOptions()
    {
        // When a client connects do the initial lookup of options
        var currentOptions = _options.CurrentValue;

        // Send the current options to the caller
        // Did not use .All as other connected clients should have a stored state of options in an observable
        await Clients.Caller.ReceiveLatestOptions(currentOptions);
    }
}

Creating a Bellissima GlobalContext

We can then use Bellissima to create a Global Context that we can register as part of our extension to Umbraco

Global Context Manifest

export const manifests: Array<UmbExtensionManifest> = [
    {
      name: '[Content Lock] SignalR Global Context',
      alias: 'ContentLock.GlobalContext.SignalR',
      type: 'globalContext',
      js: () => import('./contentlock.signalr.context'),
    }
  ];

Global Context

import { UmbContextBase } from "@umbraco-cms/backoffice/class-api";
import * as signalR from "@microsoft/signalr";
import { UmbContextToken } from "@umbraco-cms/backoffice/context-api";
import { UmbControllerHost } from "@umbraco-cms/backoffice/controller-api";
import { UMB_AUTH_CONTEXT } from "@umbraco-cms/backoffice/auth";
import { UmbObjectState } from "@umbraco-cms/backoffice/observable-api";
import { ContentLockOptions } from "../interfaces/ContentLockOptions";

export default class ContentLockSignalrContext extends UmbContextBase<ContentLockSignalrContext>
{
    // SignalR Hub URL endpoint
    #CONTENT_LOCK_HUB_URL = '/umbraco/ContentLockHub';

    // Currently made this public so that any place consuming the context
    // Could stop the signalR connection or listen to any .On() events etc
    public signalrConnection? : signalR.HubConnection;

    // Used to store the options for the content lock package
    // Sets the default values for the options
    #contentLockOptions = new UmbObjectState<ContentLockOptions>(
        {
            onlineUsers: {
                enable: true,
                sounds: {
                    enable: true,
                    loginSound: '/App_Plugins/ContentLock/sounds/login.mp3',
                    logoutSound: '/App_Plugins/ContentLock/sounds/logout.mp3',
                }
            }
        });

    // The entire options object as an observable
    public contentLockOptions = this.#contentLockOptions.asObservable();

    // The individual options as observables
    public EnableOnlineUsers = this.#contentLockOptions.asObservablePart(options => options.onlineUsers.enable);
    public EnableSounds = this.#contentLockOptions.asObservablePart(options => options.onlineUsers.sounds.enable);
    public LoginSound = this.#contentLockOptions.asObservablePart(options => options.onlineUsers.sounds.loginSound);
    public LogoutSound = this.#contentLockOptions.asObservablePart(options => options.onlineUsers.sounds.logoutSound);

    constructor(host: UmbControllerHost) {
        super(host, CONTENTLOCK_SIGNALR_CONTEXT);

        // Create a new SignalR connection in this context that we will expose
        // Then otherplaces can get this new'd up hub to send or receive messages

        // Need auth context to use the token to pass to SignalR hub
        this.consumeContext(UMB_AUTH_CONTEXT, async (authCtx) => {
            if (!authCtx) {
                console.error('Auth context is not available for SignalR connection');
                return;
            }

            // Create a new SignalR connection in this context that we will expose
            // Then otherplaces can get this new'd up hub to send or receive messages
            this.signalrConnection = new signalR.HubConnectionBuilder()
                .withUrl(this.#CONTENT_LOCK_HUB_URL, { 
                    accessTokenFactory: authCtx.getOpenApiConfiguration().token
                })
                .withAutomaticReconnect()
                .configureLogging(signalR.LogLevel.Information)
                .build();

            this.#startHub();
        });
    }

    // Start the engines...
    async #startHub() {
        if(this.signalrConnection) {
            // Start the connection straight away
            await this.signalrConnection.start();
        
            // Listen for the SignalR C# Hub server sending us the event 'ReceiveLatestOptions'
            this.signalrConnection.on('ReceiveLatestOptions', (options:ContentLockOptions) =>{
                // Update our object where other places are observing the entire object or an observable part
                this.#contentLockOptions.setValue(options);
            });
        }
    }

    override async destroy(): Promise<void> {
        if (this.signalrConnection) {
            await this.signalrConnection.stop();
        }
    }
}

export const CONTENTLOCK_SIGNALR_CONTEXT = new UmbContextToken<ContentLockSignalrContext>('ContentLockSignalRContext');

Header App

import { customElement, html, nothing, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbHeaderAppButtonElement } from '@umbraco-cms/backoffice/components';
import ContentLockSignalrContext, { CONTENTLOCK_SIGNALR_CONTEXT } from '../globalContexts/contentlock.signalr.context';
import { UMB_MODAL_MANAGER_CONTEXT, UmbModalManagerContext } from '@umbraco-cms/backoffice/modal';
import { CONTENTLOCK_ONLINEUSERS_MODAL } from '../modals/onlineusers.modal.token';
import { observeMultiple } from '@umbraco-cms/backoffice/observable-api';

@customElement('contentlock-nousers-online-headerapp')
export class ContentLockNoUsersOnlineHeaderApp extends UmbHeaderAppButtonElement {
	
    @state()
    private _totalConnectedUsers: number | undefined;

    @state()
    private _enableOnlineUsers: boolean = true;

    #modalManagerCtx?: UmbModalManagerContext;
    

	constructor() {
		super();

        this.consumeContext(CONTENTLOCK_SIGNALR_CONTEXT, (signalrContext: ContentLockSignalrContext) => {
            this.observe(observeMultiple([signalrContext.totalConnectedUsers, signalrContext.connectedUserKeys, signalrContext.EnableOnlineUsers]), ([totalConnectedUsers, connectedUserKeys, enableOnlineUsers]) => {
                this._totalConnectedUsers = totalConnectedUsers;

                // This is an observable from SignalR watching the AppSettings/Options
                // TODO: Perhaps can retire this and use the condition approach when HeaderApps supports it
                // Now due in V16 🙂
                // https://github.com/umbraco/Umbraco-CMS/issues/18979
                this._enableOnlineUsers = enableOnlineUsers;

            });
        });

        this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (modalManagerCtx) => {
            this.#modalManagerCtx = modalManagerCtx;
        });
	}

    async #openUserListModal() {
        await this.#modalManagerCtx?.open(this, CONTENTLOCK_ONLINEUSERS_MODAL);
    };

	override render() {

        // TODO: Can remove when HeaderApps support conditions in manifest
        if (!this._enableOnlineUsers) {
            return html ``;
        }

        const badgeValue = this._totalConnectedUsers !== undefined
            ? (this._totalConnectedUsers > 99 ? '99+' : this._totalConnectedUsers.toString())
            : nothing;

		return html`
            <uui-button compact label=${this.localize.term('general_help')} look="primary" @click=${this.#openUserListModal}>
				<uui-icon name="icon-users"></uui-icon>
                <uui-badge color="default" look="secondary">${badgeValue}</uui-badge>
			</uui-button>
        `;
	}

	static override styles = UmbHeaderAppButtonElement.styles;
}

export default ContentLockNoUsersOnlineHeaderApp;

declare global {
	interface HTMLElementTagNameMap {
		'contentlock-nousers-online-headerapp': ContentLockNoUsersOnlineHeaderApp;
	}
}

Pull Request

If you want to see the entire code that made up this feature, then feel free to take a look at the Pull Request for the feature on GitHub

Hope this has inspired you to play around with SignalR and Bellissima.
I would love to hear what you think of this, so leave me a comment or perhaps tell me how you would use this?

Until next time, Happy Hacking 😀

Why I Have Still Stuck Around in the Umbraco Community After All These Years

Hey everyone! Guess what? I’ve just received this years Umbraco MVP award! 🎉 It feels surreal to be honoured once again, and I wanted to take a moment to share why this means so much to me and why I’ve been dedicated to Umbraco for nearly two decades.

Why I’m Still Hooked on Umbraco After 20 Years

You might wonder why, after all this time, I’m still as enthusiastic about Umbraco as I was when I started. Well, it’s pretty simple:

Community Vibes

The Umbraco community is second to none. The camaraderie, the sharing of knowledge, and the willingness to help each other out have kept me hooked. It’s not just about coding; it’s about connecting with people who share the same passion.

Ever-Evolving Platform

Umbraco has evolved significantly over the years, constantly adapting and improving. From the days of version 4 to the latest V14 “Bellissima,” it’s been an exciting journey of growth and innovation. Each update brings new challenges and opportunities to learn and improve as a developer.

Developer Experience (DX): Improving the Developer Experience has always been at the heart of my contributions. Whether it’s creating packages like “Examine Peek” for V14 or sharing quick tips on customizing the login screen, enhancing DX is what drives me. It’s about making life easier for fellow developers and seeing them succeed.

Weren’t you already a Lifetime MVP ?!

This was a question that came up a few times during CodeGarden. Now, some of you might remember that I was awarded a lifetime MVP some time ago. And while that’s a great honour, let’s be honest, it was under the old management. Does it even count? 😉

Speaking of lifetime MVPs, I guess I can now proudly say that I hold a lifetime MVP+1! Which means, in the most official of capacities, I will continue to be an MVP one year after I die. I mean, who wouldn’t want an award that outlasts them? 😆

Just kidding, of course. But seriously, every recognition is special, and it keeps me motivated to keep contributing to this amazing community.

Staying Humble and Grateful

Receiving the MVP award again is incredibly humbling. It’s a testament to the amazing support from the community and the collaborative spirit we all share. I want to extend a huge thank you to everyone who’s been part of this journey. Your support, feedback, and camaraderie are what make all the late-night coding sessions and bug hunts worthwhile.

Looking Ahead

As we move forward, I’m excited about the future of Umbraco and the community. Whether it’s through new projects, sharing knowledge on my blog, or just having a chat with fellow Umbracians, I look forward to many more years of collaboration and innovation.

Thank you once again for this incredible honour. Here’s to many more years of Umbraco magic! Stay curious, keep coding, and remember: the community is what makes it all worthwhile.

Cheers,
Warren 🥰