From 2814247e0433ceb6c71b6419b306830a81e57852 Mon Sep 17 00:00:00 2001
From: Half-Shot <will@half-shot.uk>
Date: Fri, 17 Jan 2025 11:47:37 +0000
Subject: [PATCH] Replace with module API.

---
 docs/config.md                           | 13 ----
 playwright/e2e/branding/title.spec.ts    |  4 -
 src/IConfigOptions.ts                    |  2 -
 src/components/structures/MatrixChat.tsx | 97 +++++++++++++-----------
 src/modules/ModuleRunner.ts              | 24 ++++++
 5 files changed, 76 insertions(+), 64 deletions(-)

diff --git a/docs/config.md b/docs/config.md
index 24c2d60e792..8ca4ba4eb8b 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -212,19 +212,6 @@ Starting with `branding`, the following subproperties are available:
 3. `auth_footer_links`: A list of links to add to the footer during login, registration, etc. Each entry must have a `text` and
    `url` property.
 
-4. `title_template`: A template string that can be used to configure the title of the application when not viewing a room.
-5. `title_template_in_room`: A template string that can be used to configure the title of the application when viewing a room
-
-#### `title_template` vars
-
-- `$brand` The name of the web app, as configured by the `brand` config value.
-- `$room_name` The friendly name of a room. Only applicable to `title_template_in_room`.
-- `$status` The client's status, repesented as.
-    - The notification count, when at least one room is unread.
-    - "\*" when no rooms are unread, but notifications are not muted.
-    - "Offline", when the client is offline.
-    - "", when the client isn't logged in or notifications are muted.
-
 `embedded_pages` can be configured as such:
 
 1. `welcome_url`: A URL to an HTML page to show as a welcome page (landing on `#/welcome`). When not specified, the default
diff --git a/playwright/e2e/branding/title.spec.ts b/playwright/e2e/branding/title.spec.ts
index 2a2eb1593ab..bf9c4895363 100644
--- a/playwright/e2e/branding/title.spec.ts
+++ b/playwright/e2e/branding/title.spec.ts
@@ -28,10 +28,6 @@ test.describe("Test with custom branding", () => {
     test.use({
         config: {
             brand: "TestBrand",
-            branding: {
-                title_template: "TestingApp $ignoredParameter $brand $status $ignoredParameter",
-                title_template_in_room: "TestingApp $brand $status $room_name $ignoredParameter",
-            },
         },
     });
     test("Shows custom branding when showing the home page", async ({ pageWithCredentials: page }) => {
diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts
index bbed4c9722d..bbb377e07b7 100644
--- a/src/IConfigOptions.ts
+++ b/src/IConfigOptions.ts
@@ -50,8 +50,6 @@ export interface IConfigOptions {
         welcome_background_url?: string | string[]; // chosen at random if array
         auth_header_logo_url?: string;
         auth_footer_links?: { text: string; url: string }[];
-        title_template?: string;
-        title_template_in_room?: string;
     };
 
     force_verification?: boolean; // if true, users must verify new logins
diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx
index 14990c9eecc..254f2287abe 100644
--- a/src/components/structures/MatrixChat.tsx
+++ b/src/components/structures/MatrixChat.tsx
@@ -133,6 +133,7 @@ import { ConfirmSessionLockTheftView } from "./auth/ConfirmSessionLockTheftView"
 import { LoginSplashView } from "./auth/LoginSplashView";
 import { cleanUpDraftsIfRequired } from "../../DraftCleaner";
 import { InitialCryptoSetupStore } from "../../stores/InitialCryptoSetupStore";
+import { AppTitleContext } from "@matrix-org/react-sdk-module-api/lib/lifecycles/BrandingExtensions";
 
 // legacy export
 export { default as Views } from "../../Views";
@@ -225,18 +226,16 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
     private tokenLogin?: boolean;
     // What to focus on next component update, if anything
     private focusNext: FocusNextType;
-    private subTitleStatus: string;
     private prevWindowWidth: number;
 
-    private readonly titleTemplate: string;
-    private readonly titleTemplateInRoom: string;
-
     private readonly loggedInView = createRef<LoggedInViewType>();
     private dispatcherRef?: string;
     private themeWatcher?: ThemeWatcher;
     private fontWatcher?: FontWatcher;
     private readonly stores: SdkContextClass;
 
+    private subtitleContext?: {unreadNotificationCount: number, userNotificationLevel: NotificationLevel, syncState: SyncState};
+
     public constructor(props: IProps) {
         super(props);
         this.stores = SdkContextClass.instance;
@@ -280,13 +279,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
         }
 
         this.prevWindowWidth = UIStore.instance.windowWidth || 1000;
-
-        // object field used for tracking the status info appended to the title tag.
-        // we don't do it as react state as i'm scared about triggering needless react refreshes.
-        this.subTitleStatus = "";
-
-        this.titleTemplate = props.config.branding?.title_template ?? "$brand $status";
-        this.titleTemplateInRoom = props.config.branding?.title_template_in_room ?? "$brand $status | $room_name";
     }
 
     /**
@@ -1112,7 +1104,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
         }
         this.setStateForNewView({
             view: Views.WELCOME,
-            currentRoomId: null,
         });
         this.notifyNewScreen("welcome");
         ThemeController.isLogin = true;
@@ -1122,7 +1113,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
     private viewLogin(otherState?: any): void {
         this.setStateForNewView({
             view: Views.LOGIN,
-            currentRoomId: null,
             ...otherState,
         });
         this.notifyNewScreen("login");
@@ -1490,7 +1480,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
             collapseLhs: false,
             currentRoomId: null,
         });
-        this.subTitleStatus = "";
+        this.subtitleContext = undefined;
         this.setPageSubtitle();
         this.stores.onLoggedOut();
     }
@@ -1506,7 +1496,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
             collapseLhs: false,
             currentRoomId: null,
         });
-        this.subTitleStatus = "";
+        this.subtitleContext = undefined;
         this.setPageSubtitle();
     }
 
@@ -1958,33 +1948,56 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
     }
 
     private setPageSubtitle(): void {
-        const params: {
-            $brand: string;
-            $status: string;
-            $room_name: string | undefined;
-        } = {
-            $brand: SdkConfig.get().brand,
-            $status: this.subTitleStatus,
-            $room_name: undefined,
+        const extraContext = this.subtitleContext;
+        let context: AppTitleContext = {
+            brand: SdkConfig.get().brand,
+            syncError: extraContext?.syncState === SyncState.Error,
         };
 
-        if (this.state.currentRoomId) {
-            const client = MatrixClientPeg.get();
-            const room = client?.getRoom(this.state.currentRoomId);
-            if (room) {
-                params.$room_name = room.name;
+        if (extraContext) {
+            if (this.state.currentRoomId) {
+                const client = MatrixClientPeg.get();
+                const room = client?.getRoom(this.state.currentRoomId);
+                context = {
+                    ...context,
+                    roomId: this.state.currentRoomId,
+                    roomName: room?.name,
+                    notificationsMuted: extraContext.userNotificationLevel < NotificationLevel.Activity,
+                    unreadNotificationCount: extraContext.unreadNotificationCount,
+                };
             }
         }
 
-        const titleTemplate = params.$room_name ? this.titleTemplateInRoom : this.titleTemplate;
+        const moduleTitle = ModuleRunner.instance.extensions.branding?.getAppTitle(context);
+        if (moduleTitle) {
+            if (document.title !== moduleTitle) {
+                document.title = moduleTitle;
+            }
+            return;
+        }
 
-        const title = Object.entries(params).reduce(
-            (title: string, [key, value]) => title.replaceAll(key, (value ?? "").replaceAll("$", "$_DLR$")),
-            titleTemplate,
-        );
+        let subtitle = "";
+        if (context?.syncError) {
+            subtitle += `[${_t("common|offline")}] `;
+        }
+        if ('unreadNotificationCount' in context && context.unreadNotificationCount > 0) {
+            subtitle += `[${context.unreadNotificationCount}]`;
+        } else if ('notificationsMuted' in context && !context.notificationsMuted) {
+            subtitle += `*`;
+        }
+
+        if ('roomId' in context && context.roomId) {
+            if (context.roomName) {
+                subtitle = `${subtitle} | ${context.roomName}`;
+            }
+        } else {
+            subtitle = subtitle;
+        }
+
+        const title = `${SdkConfig.get().brand} ${subtitle}`;
 
         if (document.title !== title) {
-            document.title = title.replaceAll("$_DLR$", "$");
+            document.title = title;
         }
     }
 
@@ -1995,17 +2008,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
             PlatformPeg.get()!.setErrorStatus(state === SyncState.Error);
             PlatformPeg.get()!.setNotificationCount(numUnreadRooms);
         }
-
-        this.subTitleStatus = "";
-        if (state === SyncState.Error) {
-            this.subTitleStatus += `[${_t("common|offline")}] `;
-        }
-        if (numUnreadRooms > 0) {
-            this.subTitleStatus += `[${numUnreadRooms}]`;
-        } else if (notificationState.level >= NotificationLevel.Activity) {
-            this.subTitleStatus += `*`;
-        }
-
+        this.subtitleContext = {
+            syncState: state,
+            userNotificationLevel: notificationState.level,
+            unreadNotificationCount: numUnreadRooms,
+        };
         this.setPageSubtitle();
     };
 
diff --git a/src/modules/ModuleRunner.ts b/src/modules/ModuleRunner.ts
index c01015206dd..042976da19c 100644
--- a/src/modules/ModuleRunner.ts
+++ b/src/modules/ModuleRunner.ts
@@ -17,6 +17,10 @@ import {
     DefaultExperimentalExtensions,
     ProvideExperimentalExtensions,
 } from "@matrix-org/react-sdk-module-api/lib/lifecycles/ExperimentalExtensions";
+import {
+    ProvideBrandingExtensions,
+} from "@matrix-org/react-sdk-module-api/lib/lifecycles/BrandingExtensions";
+
 
 import { AppModule } from "./AppModule";
 import { ModuleFactory } from "./ModuleFactory";
@@ -30,6 +34,7 @@ class ExtensionsManager {
     // Private backing fields for extensions
     private cryptoSetupExtension: ProvideCryptoSetupExtensions;
     private experimentalExtension: ProvideExperimentalExtensions;
+    private brandingExtension?: ProvideBrandingExtensions;
 
     /** `true` if `cryptoSetupExtension` is the default implementation; `false` if it is implemented by a module. */
     private hasDefaultCryptoSetupExtension = true;
@@ -67,6 +72,15 @@ class ExtensionsManager {
         return this.experimentalExtension;
     }
 
+    /**
+     * Provides branding extension.
+     *
+     * @returns The registered extension. If no module provides this extension, undefined is returned..
+     */
+    public get branding(): ProvideBrandingExtensions|undefined {
+        return this.brandingExtension;
+    }
+
     /**
      * Add any extensions provided by the module.
      *
@@ -100,6 +114,16 @@ class ExtensionsManager {
                 );
             }
         }
+
+        if (runtimeModule.extensions?.branding) {
+            if (!this.brandingExtension) {
+                this.brandingExtension = runtimeModule.extensions?.branding;
+            } else {
+                throw new Error(
+                    `adding experimental branding implementation from module ${runtimeModule.moduleName} but an implementation was already provided.`,
+                );
+            }
+        }
     }
 }