Handling theme change in MS Teams app with Blazor
Tags: blazor, teams, razor, webdev
Published: 2022-12-06 22:49:31

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](https://learn.microsoft.com/en-us/microsoftteams/platform/tabs/how-to/access-teams-context?tabs=teamsjs-v2#handle-theme-change):
```javascript
// You can register your app to be informed if the theme changes by calling
microsoftTeams.app.registerOnThemeChangeHandler(function(theme) { /* ... */ }).
```

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

```html
// 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>
```

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

```

### Calling JS from Blazor component

That is described pretty accurately in the [docs](https://learn.microsoft.com/en-us/aspnet/core/blazor/javascript-interoperability/call-javascript-from-dotnet?view=aspnetcore-7.0). 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`:

```javascript
// ./wwwroot/js/ThemeChange.js

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

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
```

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:

```csharp
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");
    }
}
```

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](https://learn.microsoft.com/en-us/aspnet/core/blazor/javascript-interoperability/call-dotnet-from-javascript?view=aspnetcore-7.0#create-javascript-object-and-data-references-to-pass-to-net). 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:

```javascript
// ./wwwroot/js/ThemeChange.js

export function onTeamsThemeChange(dotNetHelper) {
    microsoftTeams.app.registerOnThemeChangeHandler(function (theme) {
        dotNetHelper.invokeMethodAsync('UpdateLuminance', theme);
    });
}
```

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:

    ```html
    @implements IDisposable
    @inject IJSRuntime JS
    @inject MicrosoftTeams MicrosoftTeams
    ```

2. Then, we have to declare a field to hold reference to the `App` in JavaScript:

    ```csharp
    private float _luminance = 1.0f;
    private IJSObjectReference? _module;
    private DotNetObjectReference<App>? _objRef;
    ```

3. This reference is created in overriden `OnInitialized` method:

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

4. As our JS function has a parameter now, we need to pass it:

    ```csharp
    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:

    ```csharp
    [JSInvokable]
    public async Task UpdateLuminance(string theme = "default")
    ```

6. To finish up, don't forget about disposing:

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

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

-----------------------

Full code:

```HTML
// 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>
```

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

```javascript
// ./wwwroot/js/ThemeChange.js

export function onTeamsThemeChange(dotNetHelper) {
    microsoftTeams.app.registerOnThemeChangeHandler(function (theme) {
        dotNetHelper.invokeMethodAsync('UpdateLuminance', theme);
    });
}
```