diff --git a/playwright/e2e/branding/title.spec.ts b/playwright/e2e/branding/title.spec.ts new file mode 100644 index 00000000000..bf9c4895363 --- /dev/null +++ b/playwright/e2e/branding/title.spec.ts @@ -0,0 +1,43 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { expect, test } from "../../element-web-test"; + +/* + * Tests for branding configuration + **/ + +test.describe("Test without branding config", () => { + test("Shows standard branding when showing the home page", async ({ pageWithCredentials: page }) => { + await page.goto("/"); + await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 }); + expect(page.title()).toEqual("Element *"); + }); + test("Shows standard branding when showing a room", async ({ app, pageWithCredentials: page }) => { + await app.client.createRoom({ name: "Test Room" }); + await app.viewRoomByName("Test Room"); + expect(page.title()).toEqual("Element * | Test Room"); + }); +}); + +test.describe("Test with custom branding", () => { + test.use({ + config: { + brand: "TestBrand", + }, + }); + test("Shows custom branding when showing the home page", async ({ pageWithCredentials: page }) => { + await page.goto("/"); + await page.waitForSelector(".mx_MatrixChat", { timeout: 30000 }); + expect(page.title()).toEqual("TestingApp TestBrand * $ignoredParameter"); + }); + test("Shows custom branding when showing a room", async ({ app, pageWithCredentials: page }) => { + await app.client.createRoom({ name: "Test Room" }); + await app.viewRoomByName("Test Room"); + expect(page.title()).toEqual("TestingApp TestBrand * Test Room $ignoredParameter"); + }); +}); diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 9d3114c67cb..f7b4473cd4a 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -131,6 +131,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"; @@ -223,7 +224,6 @@ export default class MatrixChat extends React.PureComponent { private tokenLogin?: boolean; // What to focus on next component update, if anything private focusNext: FocusNextType; - private subTitleStatus: string; private prevWindowWidth: number; private readonly loggedInView = createRef(); @@ -232,6 +232,8 @@ export default class MatrixChat extends React.PureComponent { 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; @@ -275,10 +277,6 @@ export default class MatrixChat extends React.PureComponent { } 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 = ""; } /** @@ -1474,7 +1472,7 @@ export default class MatrixChat extends React.PureComponent { collapseLhs: false, currentRoomId: null, }); - this.subTitleStatus = ""; + this.subtitleContext = undefined; this.setPageSubtitle(); this.stores.onLoggedOut(); } @@ -1490,7 +1488,7 @@ export default class MatrixChat extends React.PureComponent { collapseLhs: false, currentRoomId: null, }); - this.subTitleStatus = ""; + this.subtitleContext = undefined; this.setPageSubtitle(); } @@ -1941,15 +1939,51 @@ export default class MatrixChat extends React.PureComponent { }); } - private setPageSubtitle(subtitle = ""): void { + private setPageSubtitle(): void { + const extraContext = this.subtitleContext; + let context: AppTitleContext = { + brand: SdkConfig.get().brand, + syncError: extraContext?.syncState === SyncState.Error, + notificationsMuted: extraContext && extraContext.userNotificationLevel < NotificationLevel.Activity, + unreadNotificationCount: extraContext?.unreadNotificationCount, + }; + if (this.state.currentRoomId) { const client = MatrixClientPeg.get(); const room = client?.getRoom(this.state.currentRoomId); - if (room) { - subtitle = `${this.subTitleStatus} | ${room.name} ${subtitle}`; + context = { + ...context, + roomId: this.state.currentRoomId, + roomName: room?.name, + }; + } + + const moduleTitle = ModuleRunner.instance.extensions.branding?.getAppTitle(context); + if (moduleTitle) { + if (document.title !== moduleTitle) { + document.title = moduleTitle; + } + return; + } + + // Use application default. + + let subtitle = ""; + if (context?.syncError) { + subtitle += `[${_t("common|offline")}] `; + } + if (context.unreadNotificationCount !== undefined && context.unreadNotificationCount > 0) { + subtitle += `[${context.unreadNotificationCount}]`; + } else if (context.notificationsMuted !== undefined && !context.notificationsMuted) { + subtitle += `*`; + } + + if ('roomId' in context && context.roomId) { + if (context.roomName) { + subtitle = `${subtitle} | ${context.roomName}`; } } else { - subtitle = `${this.subTitleStatus} ${subtitle}`; + subtitle = subtitle; } const title = `${SdkConfig.get().brand} ${subtitle}`; @@ -1966,17 +2000,11 @@ export default class MatrixChat extends React.PureComponent { 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.`, + ); + } + } } }