Handling theme change in MS Teams app with Blazor

Piotr Ładoński - Dec 6 '22 - - Dev Community

Recently, I've been playing a bit with Blazor and Microsoft Teams apps. I had hard time finding clear explanation how to handle theme changes in MS Teams. As, after spending some time, I figured out the way, I'll share my approach.

The problem

The problem is very simple - when user changes their theme settings in Microsoft Teams, we want to react to that instantly; without app reload. In my case, I want to change BaseLayerLuminance of FluentDesignSystemProvider in App.razor, so all the Fluent UI components have their look updated automatically.

The tricky part is that we want to do that using Blazor.

JavaScript approach

Solution with JavaScript is incredibly simple, found at the bottom of the docs:

// You can register your app to be informed if the theme changes by calling
microsoftTeams.app.registerOnThemeChangeHandler(function(theme) { /* ... */ }).
Enter fullscreen mode Exit fullscreen mode

In Blazor we also have something similar to microsoftTeams object when we inject @inject MicrosoftTeams MicrosoftTeams in the component, so that should be pretty simple, right?

No.

Blazor approach

Turns out, Interop.TeamsSDK.MicrosoftTeams class doesn't expose any app or registerOnThemeChangeHandler. After searching a bit it gets even better - there doesn't seem to be any C# SDK doing that. This means we are stuck with JavaScript and JS Interop.

Conceptually, it's rather simple:

  1. Create .js file which has a function calling microsoftTeams.app.registerOnThemeChangeHandler
  2. Import and call the function from App.razor
  3. On theme change, the JS function calls C# code
  4. Update the variable bound to BaseLayerLuminance and rerender the component

However, there are some technicalities which make it a bit more complicated than it looks.

Starting code

Code at the beginning looks like that.

  • _luminance field is used to set BaseLayerLuminance
  • after the first render, the theme is grabbed from the context and used to determine _luminance
  • UpdateLuminance has this weird async implementation as it's required later by the JS interop
// App.razor

@inject MicrosoftTeams MicrosoftTeams

<FluentDesignSystemProvider AccentBaseColor="#6264A7" BaseLayerLuminance="_luminance">
    <Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true">
        <Found Context="routeData">
            <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
        </Found>
        <NotFound>
            <LayoutView Layout="@typeof(MainLayout)">
                <p>Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</FluentDesignSystemProvider>
Enter fullscreen mode Exit fullscreen mode
@code
{
    private float _luminance = 1.0f;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        await base.OnAfterRenderAsync(firstRender);

        if(firstRender)
        {
            await MicrosoftTeams.InitializeAsync();
            var context = await MicrosoftTeams.GetTeamsContextAsync();
            await UpdateLuminance(context.Theme);
        }
    }

    public async Task UpdateLuminance(string theme = "default")
    {
        await Task.Run(() =>
        {
            _luminance = theme switch
            {
                "default" => 1.0f,
                "dark" => 0.0f,
                "contrast" => 0.5f, // it doesn't make sense, but let's pretend it does
                _ => 1.0f
            };
        });
        StateHasChanged();
    }
}

Enter fullscreen mode Exit fullscreen mode

Calling JS from Blazor component

That is described pretty accurately in the docs. The examples usually show the functions attached to the window object. I just prefer to have them exported from the .js file. Thus, that's my ThemeChange.js:

// ./wwwroot/js/ThemeChange.js

export function onTeamsThemeChange() {
    microsoftTeams.app.registerOnThemeChangeHandler(function (theme) {
        console.log('Theme changed to:', theme);
    });
}
Enter fullscreen mode Exit fullscreen mode

As I want to update the theme of the whole app, I'll be modifying App.razor.

First of all, it needs IJSRuntime to be injected:

@inject IJSRuntime JS
// Rest of the HTML/Code
Enter fullscreen mode Exit fullscreen mode

Then, in the @code block, we declare IJSObjectReference field to hold the loaded module and call the function inside it. We load all of that after the first render:

private float _luminance = 1.0f;
private IJSObjectReference? _module;

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    await base.OnAfterRenderAsync(firstRender);

    if(firstRender)
    {
        await MicrosoftTeams.InitializeAsync();
        var context = await MicrosoftTeams.GetTeamsContextAsync();
        await UpdateLuminance(context.Theme);
        _module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/ThemeChange.js");
        await _module.InvokeVoidAsync("onTeamsThemeChange");
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, after changing the theme in Teams settings, console should log which theme is currently selected.

Calling C# from JS

At this point, we just want to call UpdateLuminance from inside the callback of our JS function. Details are described in the docs. We have to follow the instance method way, as we are calling non-static StatusHasChanged method, so it can't be called from a static context.

Let's start with updating JS:

// ./wwwroot/js/ThemeChange.js

export function onTeamsThemeChange(dotNetHelper) {
    microsoftTeams.app.registerOnThemeChangeHandler(function (theme) {
        dotNetHelper.invokeMethodAsync('UpdateLuminance', theme);
    });
}
Enter fullscreen mode Exit fullscreen mode

Details are described in the docs, but in short, Blazor is going to pass some object which will be used to call the C# method. The second argument to invokeMethodAsync is just an argument to UpdateLuminance.

Code in App.razor will require a bit more changes:

  1. Starting a bit bacwards, but we will have to clean up after all the JS Interop fun, so declare that the component is disposable:

    @implements IDisposable
    @inject IJSRuntime JS
    @inject MicrosoftTeams MicrosoftTeams
    
  2. Then, we have to declare a field to hold reference to the App in JavaScript:

    private float _luminance = 1.0f;
    private IJSObjectReference? _module;
    private DotNetObjectReference<App>? _objRef;
    
  3. This reference is created in overriden OnInitialized method:

    protected override void OnInitialized()
    {
        _objRef = DotNetObjectReference.Create(this);
    }
    
  4. As our JS function has a parameter now, we need to pass it:

    if(firstRender)
    {
        await MicrosoftTeams.InitializeAsync();
        var context = await MicrosoftTeams.GetTeamsContextAsync();
        await UpdateLuminance(context.Theme);
        _module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/ThemeChange.js");
        await _module.InvokeVoidAsync("onTeamsThemeChange", _objRef);
    }
    
  5. Minor but important thing - our UpdateLuminance method has to be decorated with JSInvokable attribute:

    [JSInvokable]
    public async Task UpdateLuminance(string theme = "default")
    
  6. To finish up, don't forget about disposing:

    public void Dispose()
    {
        _objRef?.Dispose();
    }
    

Et voilà! Now theme of your Fluent UI controls should change instantly after user changes their settings 🙂


Full code:

// App.razor
@implements IDisposable
@inject IJSRuntime JS
@inject MicrosoftTeams MicrosoftTeams

<FluentDesignSystemProvider AccentBaseColor="#6264A7" BaseLayerLuminance="_luminance">
    <Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true">
        <Found Context="routeData">
            <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
        </Found>
        <NotFound>
            <LayoutView Layout="@typeof(MainLayout)">
                <p>Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</FluentDesignSystemProvider>
Enter fullscreen mode Exit fullscreen mode
@code{
    private float _luminance = 1.0f;

    private IJSObjectReference? _module;
    private DotNetObjectReference<App>? _objRef;

    protected override void OnInitialized()
    {
        _objRef = DotNetObjectReference.Create(this);
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        await base.OnAfterRenderAsync(firstRender);

        if(firstRender)
        {
            await MicrosoftTeams.InitializeAsync();
            var context = await MicrosoftTeams.GetTeamsContextAsync();
            await UpdateLuminance(context.Theme);
            _module = await JS.InvokeAsync<IJSObjectReference>("import", "./js/ThemeChange.js");
            await _module.InvokeVoidAsync("onTeamsThemeChange", _objRef);
        }
    }

    [JSInvokable]
    public async Task UpdateLuminance(string theme = "default")
    {
        await Task.Run(() =>
        {
            _luminance = theme switch
            {
                "default" => 1.0f,
                "dark" => 0.0f,
                "contrast" => 0.5f,
                _ => 1.0f
            };
        });
        StateHasChanged();
    }

    public void Dispose()
    {
        _objRef?.Dispose();
    }
}
Enter fullscreen mode Exit fullscreen mode
// ./wwwroot/js/ThemeChange.js

export function onTeamsThemeChange(dotNetHelper) {
    microsoftTeams.app.registerOnThemeChangeHandler(function (theme) {
        dotNetHelper.invokeMethodAsync('UpdateLuminance', theme);
    });
}
Enter fullscreen mode Exit fullscreen mode
. . . . . .