diff --git a/docs/config.md b/docs/config.md index 4f4724ad403..b9e6f0f13a7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -130,32 +130,37 @@ complete re-branding/private labeling, a more personalised experience can be ach 6. `mobile_builds`: Optional. Like `desktop_builds`, except for the mobile apps. Also described in more detail down below. 7. `mobile_guide_toast`: When `true` (default), users accessing the Element Web instance from a mobile device will be prompted to download the app instead. -8. `update_base_url`: For the desktop app only, the URL where to acquire update packages. If specified, must be a path to a directory +8. `mobile_guide_app_variant`: Optional. The mobile app that the user is prompted to download from the `/mobile_guide` page. When omitted + the mobile guide will be configured for the new Element X apps. Allowed values are as follows: + 1. `classic`: Element Android/iOS. + 2. `x`: Element X Android/iOS. + 3. `pro`: Element Pro Android/iOS. +9. `update_base_url`: For the desktop app only, the URL where to acquire update packages. If specified, must be a path to a directory containing `macos` and `win32` directories, with the update packages within. Defaults to `https://packages.element.io/desktop/update/` in production. -9. `map_style_url`: Map tile server style URL for location sharing. e.g. `https://api.maptiler.com/maps/streets/style.json?key=YOUR_KEY_GOES_HERE` - This setting is ignored if your homeserver provides `/.well-known/matrix/client` in its well-known location, and the JSON file - at that location has a key `m.tile_server` (or the unstable version `org.matrix.msc3488.tile_server`). In this case, the - configuration found in the well-known location is used instead. -10. `welcome_user_id`: **DEPRECATED** An optional user ID to start a DM with after creating an account. Defaults to nothing (no DM created). -11. `custom_translations_url`: An optional URL to allow overriding of translatable strings. The JSON file must be in a format of +10. `map_style_url`: Map tile server style URL for location sharing. e.g. `https://api.maptiler.com/maps/streets/style.json?key=YOUR_KEY_GOES_HERE` + This setting is ignored if your homeserver provides `/.well-known/matrix/client` in its well-known location, and the JSON file + at that location has a key `m.tile_server` (or the unstable version `org.matrix.msc3488.tile_server`). In this case, the + configuration found in the well-known location is used instead. +11. `welcome_user_id`: **DEPRECATED** An optional user ID to start a DM with after creating an account. Defaults to nothing (no DM created). +12. `custom_translations_url`: An optional URL to allow overriding of translatable strings. The JSON file must be in a format of `{"affected|translation|key": {"languageCode": "new string"}}`. See https://github.com/matrix-org/matrix-react-sdk/pull/7886 for details. -12. `branding`: Options for configuring various assets used within the app. Described in more detail down below. -13. `embedded_pages`: Further optional URLs for various assets used within the app. Described in more detail down below. -14. `disable_3pid_login`: When `false` (default), **enables** the options to log in with email address or phone number. Set to +13. `branding`: Options for configuring various assets used within the app. Described in more detail down below. +14. `embedded_pages`: Further optional URLs for various assets used within the app. Described in more detail down below. +15. `disable_3pid_login`: When `false` (default), **enables** the options to log in with email address or phone number. Set to `true` to hide these options. -15. `disable_login_language_selector`: When `false` (default), **enables** the language selector on the login pages. Set to `true` +16. `disable_login_language_selector`: When `false` (default), **enables** the language selector on the login pages. Set to `true` to hide this dropdown. -16. `disable_guests`: When `false` (default), **enable** guest-related functionality (peeking/previewing rooms, etc) for unregistered +17. `disable_guests`: When `false` (default), **enable** guest-related functionality (peeking/previewing rooms, etc) for unregistered users. Set to `true` to disable this functionality. -17. `user_notice`: Optional notice to show to the user, e.g. for sunsetting a deployment and pushing users to move in their own time. +18. `user_notice`: Optional notice to show to the user, e.g. for sunsetting a deployment and pushing users to move in their own time. Takes a configuration object as below: 1. `title`: Required. Title to show at the top of the notice. 2. `description`: Required. The description to use for the notice. 3. `show_once`: Optional. If true then the notice will only be shown once per device. -18. `help_url`: The URL to point users to for help with the app, defaults to `https://element.io/help`. -19. `help_encryption_url`: The URL to point users to for help with encryption, defaults to `https://element.io/help#encryption`. -20. `force_verification`: If true, users must verify new logins (eg. with another device / their recovery key) +19. `help_url`: The URL to point users to for help with the app, defaults to `https://element.io/help`. +20. `help_encryption_url`: The URL to point users to for help with encryption, defaults to `https://element.io/help#encryption`. +21. `force_verification`: If true, users must verify new logins (eg. with another device / their recovery key) ### `desktop_builds` and `mobile_builds` diff --git a/playwright/e2e/mobile-guide/mobile-guide.spec.ts b/playwright/e2e/mobile-guide/mobile-guide.spec.ts new file mode 100644 index 00000000000..ff2ad69bf87 --- /dev/null +++ b/playwright/e2e/mobile-guide/mobile-guide.spec.ts @@ -0,0 +1,36 @@ +/* +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 { test, expect } from "../../element-web-test"; +import { MobileAppVariant } from "../../../src/vector/mobile_guide/mobile-apps"; + +const variants = [MobileAppVariant.Classic, MobileAppVariant.X, MobileAppVariant.Pro]; + +test.describe("Mobile Guide Screenshots", { tag: "@screenshot" }, () => { + for (const variant of variants) { + test.describe(`for variant ${variant}`, () => { + test.use({ + config: { + default_server_config: { + "m.homeserver": { + base_url: "https://matrix.server.invalid", + server_name: "server.invalid", + }, + }, + mobile_guide_app_variant: variant, + }, + viewport: { width: 390, height: 844 }, // iPhone 16e + }); + + test("should match the mobile_guide screenshot", async ({ page, axe }) => { + await page.goto("/mobile_guide/"); + await expect(page).toMatchScreenshot(`mobile-guide-${variant}.png`); + await expect(axe).toHaveNoViolations(); + }); + }); + } +}); diff --git a/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-classic-linux.png b/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-classic-linux.png new file mode 100644 index 00000000000..f091eeed74f Binary files /dev/null and b/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-classic-linux.png differ diff --git a/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-pro-linux.png b/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-pro-linux.png new file mode 100644 index 00000000000..ff1bb69a4f8 Binary files /dev/null and b/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-pro-linux.png differ diff --git a/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-x-linux.png b/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-x-linux.png new file mode 100644 index 00000000000..9c32731ab79 Binary files /dev/null and b/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-x-linux.png differ diff --git a/sonar-project.properties b/sonar-project.properties index 2d87d32efcd..d95f46460bd 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -19,5 +19,6 @@ sonar.coverage.exclusions=\ src/vector/modernizr.js,\ src/components/views/dialogs/devtools/**/*,\ src/utils/SessionLock.ts,\ - src/**/*.d.ts + src/**/*.d.ts,\ + src/vector/mobile_guide/**/* sonar.testExecutionReportPaths=coverage/jest-sonar-report.xml diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts index 8b88a18075c..2e129f11869 100644 --- a/src/IConfigOptions.ts +++ b/src/IConfigOptions.ts @@ -81,6 +81,7 @@ export interface IConfigOptions { }; mobile_guide_toast?: boolean; + mobile_guide_app_variant?: "classic" | "x" | "pro"; default_theme?: "light" | "dark" | string; // custom themes are strings default_country_code?: string; // ISO 3166 alpha2 country code diff --git a/src/vector/mobile_guide/assets/app-store-badge.svg b/src/vector/mobile_guide/assets/app-store-badge.svg new file mode 100755 index 00000000000..072b425a1ab --- /dev/null +++ b/src/vector/mobile_guide/assets/app-store-badge.svg @@ -0,0 +1,46 @@ + + Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/vector/mobile_guide/assets/bottom-gradient.svg b/src/vector/mobile_guide/assets/bottom-gradient.svg new file mode 100644 index 00000000000..07404409466 --- /dev/null +++ b/src/vector/mobile_guide/assets/bottom-gradient.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/vector/mobile_guide/assets/element-logo.svg b/src/vector/mobile_guide/assets/element-logo.svg new file mode 100644 index 00000000000..d2cb52e498f --- /dev/null +++ b/src/vector/mobile_guide/assets/element-logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/vector/mobile_guide/assets/google-play-badge.svg b/src/vector/mobile_guide/assets/google-play-badge.svg new file mode 100644 index 00000000000..fac62d70ed3 --- /dev/null +++ b/src/vector/mobile_guide/assets/google-play-badge.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/vector/mobile_guide/index.css b/src/vector/mobile_guide/index.css new file mode 100644 index 00000000000..cb51c3fb24e --- /dev/null +++ b/src/vector/mobile_guide/index.css @@ -0,0 +1,183 @@ +/* +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 url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css"); + +html { + min-height: 100%; + position: relative; +} + +body { + background: var(--cpd-color-bg-canvas-default); + max-width: 680px; + margin: var(--cpd-space-0x) auto; + padding-bottom: 178px; /* Match the height of mx_BottomGradient */ + font-family: var(--cpd-font-family-sans); + font-size: var(--cpd-font-size-body-lg); /* Design says 16px, this is 17px */ + color: var(--cpd-color-text-primary); +} + +hr { + border: none; + height: var(--cpd-border-width-1); + background-color: var( + --cpd-color-bg-subtle-primary /* Design uses Border token from "Compound Marketing" set, but this matches. */ + ); + color: var( + --cpd-color-bg-subtle-primary /* Design uses Border token from "Compound Marketing" set, but this matches. */ + ); + margin: 0; +} + +p { + margin: var(--cpd-space-1x) var(--cpd-space-0x); + padding: var(--cpd-space-0x); +} + +.mx_Button { + border: 0; + border-radius: 100px; + min-width: 80px; + background-color: var(--cpd-color-bg-action-primary-rest); + color: var(--cpd-color-text-on-solid-primary); + cursor: pointer; + padding: 12px 22px; + word-break: break-word; + text-decoration: none; +} + +#deep_link_button { + margin-top: 12px; + display: inline-block; + width: auto; + box-sizing: border-box; +} + +.mx_StoreLinks { + margin: 15px 0 12px 0; +} + +.mx_StoreBadge { + text-decoration: none !important; + margin: 16px 16px 16px 0px; +} + +#f_droid_link { + color: var(--cpd-color-text-action-accent); + font-weight: bold; + text-decoration: none; +} + +#f_droid_link:visited { + color: var(--cpd-color-text-action-accent); +} + +.mx_HomePage_header { + color: var(--cpd-color-text-secondary); + align-items: center; + justify-content: center; + text-align: center; + padding-top: 48px; + padding-bottom: 48px; +} + +.mx_HomePage_header #header_title { + margin-top: 8px; + margin-bottom: 0px; +} + +.mx_HomePage h3 { + margin-top: 30px; +} + +.mx_HomePage_col { + display: flex; + flex-direction: row; +} + +.mx_HomePage_row { + flex: 1 1 0; + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: flex-start; +} + +.mx_HomePage_container { + margin: 10px 20px; +} + +.mx_HomePage_errorContainer { + display: none; /* shown in JS if needed */ + margin: 20px; + border: var(--cpd-border-width-1) solid var(--cpd-color-border-critical-primary); + background-color: var(--cpd-color-bg-critical-subtle); + padding: 5px; +} + +.mx_HomePage_container h1, +.mx_HomePage_container h2, +.mx_HomePage_container h3, +.mx_HomePage_container h4 { + font-weight: var(--cpd-font-weight-semibold); + font-size: var(--cpd-font-size-body-lg); /* Design says 16px, this is 17px */ + margin-bottom: 8px; + margin-top: 4px; +} + +.mx_Spacer { + margin-top: 48px; +} + +.mx_DesktopLink { + color: var(--cpd-color-text-action-accent); + font-weight: var(--cpd-font-weight-semibold); + text-decoration: none; +} + +/* + * The bottom gradient is a full-width background image that stretches horizontally across the page. + * It is positioned pinned to the bottom of the viewport unless the content is taller than the viewport, + * in which case it will be pinned to the bottom of the content. + */ +.mx_BottomGradient { + position: absolute; + bottom: 0; + left: 0; + right: 0; + width: 100vw; + height: 178px; /* Match the height of assets/bottom-gradient.svg so the gradient only stretches horizontally */ + background-image: url("./assets/bottom-gradient.svg"); + background-size: 100% 100%; + background-repeat: no-repeat; + z-index: -1; + margin-left: calc(50% - 50vw); /* Center the gradient regardless of body width */ +} + +.mx_HomePage_step_number { + display: flex; + align-items: flex-start; + margin-right: 8px; +} + +.mx_HomePage_step_number span { + display: flex; + align-items: center; + justify-content: center; + width: var(--cpd-space-6x); + height: var(--cpd-space-6x); + border-radius: 50%; + border: var(--cpd-border-width-1) solid var(--cpd-color-bg-subtle-primary); /* Not a border token, but matches the Design (Border token from the "Compound Marketing" set). */ + background-color: transparent; + color: var(--cpd-color-text-secondary); + font-size: var(--cpd-font-size-body-md); /* Design says 14px, this is 15px */ +} + +#step2_description { + color: var(--cpd-color-text-secondary); +} diff --git a/src/vector/mobile_guide/index.html b/src/vector/mobile_guide/index.html index d58842d6a64..34c3c4cc4a2 100644 --- a/src/vector/mobile_guide/index.html +++ b/src/vector/mobile_guide/index.html @@ -1,141 +1,13 @@ + Element Mobile Guide - - @@ -144,648 +16,90 @@
-
- -

Set up Element on iOS or Android

-
-
- - -
-

- Go to Desktop Site -
- Please note the Desktop site does not work on mobile. -

-
+ +
+ + diff --git a/src/vector/mobile_guide/index.ts b/src/vector/mobile_guide/index.ts index ae769039a8e..0b27624f50b 100644 --- a/src/vector/mobile_guide/index.ts +++ b/src/vector/mobile_guide/index.ts @@ -5,9 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ +import "./index.css"; +import "@fontsource/inter/400.css"; +import "@fontsource/inter/600.css"; + import { logger } from "matrix-js-sdk/src/logger"; import { getVectorConfig } from "../getconfig"; +import { type MobileAppVariant, mobileApps, updateMobilePage } from "./mobile-apps.ts"; function onBackToElementClick(): void { // Cookie should expire in 4 hours @@ -38,18 +43,19 @@ function renderConfigError(message: string): void { } async function initPage(): Promise { - document.getElementById("back_to_element_button")!.onclick = onBackToElementClick; - const config = await getVectorConfig(".."); // We manually parse the config similar to how validateServerConfig works because // calling that function pulls in roughly 4mb of JS we don't use. const wkConfig = config?.["default_server_config"]; // overwritten later under some conditions - const serverName = config?.["default_server_name"]; + let serverName = config?.["default_server_name"]; const defaultHsUrl = config?.["default_hs_url"]; const defaultIsUrl = config?.["default_is_url"]; + const appVariant = (config?.["mobile_guide_app_variant"] ?? "x") as MobileAppVariant; + const metadata = mobileApps[appVariant]; + const incompatibleOptions = [wkConfig, serverName, defaultHsUrl].filter((i) => !!i); if (defaultHsUrl && (wkConfig || serverName)) { return renderConfigError( @@ -66,6 +72,7 @@ async function initPage(): Promise { if (!serverName && typeof wkConfig?.["m.homeserver"]?.["base_url"] === "string") { hsUrl = wkConfig["m.homeserver"]["base_url"]; + serverName = wkConfig["m.homeserver"]["server_name"]; if (typeof wkConfig["m.identity_server"]?.["base_url"] === "string") { isUrl = wkConfig["m.identity_server"]["base_url"]; @@ -110,21 +117,21 @@ async function initPage(): Promise { if (hsUrl && !hsUrl.endsWith("/")) hsUrl += "/"; if (isUrl && !isUrl.endsWith("/")) isUrl += "/"; - if (hsUrl !== "https://matrix.org/") { - let url = "https://mobile.element.io?hs_url=" + encodeURIComponent(hsUrl); + let deepLinkUrl = `https://mobile.element.io${metadata.deepLinkPath}`; + if (metadata.usesLegacyDeepLink) { + deepLinkUrl += `?hs_url=${encodeURIComponent(hsUrl)}`; if (isUrl) { - document.getElementById("custom_is")!.style.display = "block"; - document.getElementById("is_url")!.style.display = "block"; - document.getElementById("is_url")!.innerText = isUrl; - url += "&is_url=" + encodeURIComponent(isUrl ?? ""); + deepLinkUrl += `&is_url=${encodeURIComponent(isUrl)}`; } - - (document.getElementById("configure_element_button") as HTMLAnchorElement).href = url; - document.getElementById("step1_heading")!.innerHTML = "1: Install the app"; - document.getElementById("step2_container")!.style.display = "block"; - document.getElementById("hs_url")!.innerText = hsUrl; + } else if (serverName) { + deepLinkUrl += `?account_provider=${serverName}`; } + + // Not part of updateMobilePage as the link is only shown on mobile_guide and not on mobile.element.io + document.getElementById("back_to_element_button")!.onclick = onBackToElementClick; + + updateMobilePage(metadata, deepLinkUrl, serverName ?? hsUrl); } void initPage(); diff --git a/src/vector/mobile_guide/mobile-apps.ts b/src/vector/mobile_guide/mobile-apps.ts new file mode 100644 index 00000000000..ab2ff2240aa --- /dev/null +++ b/src/vector/mobile_guide/mobile-apps.ts @@ -0,0 +1,88 @@ +/* +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. +*/ + +/* + * Shared code that is used by the mobile guide and the mobile.element.io site. + */ + +export enum MobileAppVariant { + Classic = "classic", + X = "x", + Pro = "pro", +} + +export interface MobileAppMetadata { + name: string; + appleAppId: string; + appStoreUrl: string; + playStoreUrl: string; + fDroidUrl?: string; + deepLinkPath: string; + usesLegacyDeepLink: boolean; + isProApp: boolean; +} + +export const mobileApps: Record = { + [MobileAppVariant.Classic]: { + name: "Element", + appleAppId: "id1083446067", + appStoreUrl: "https://apps.apple.com/app/element-messenger/id1083446067", + playStoreUrl: "https://play.google.com/store/apps/details?id=im.vector.app", + fDroidUrl: "https://f-droid.org/packages/im.vector.app", + deepLinkPath: "", + usesLegacyDeepLink: true, + isProApp: false, + }, + [MobileAppVariant.X]: { + name: "Element X", + appleAppId: "id1631335820", + appStoreUrl: "https://apps.apple.com/app/element-x-secure-chat-call/id1631335820", + playStoreUrl: "https://play.google.com/store/apps/details?id=io.element.android.x", + fDroidUrl: "https://f-droid.org/packages/io.element.android.x", + deepLinkPath: "/element", + usesLegacyDeepLink: false, + isProApp: false, + }, + [MobileAppVariant.Pro]: { + name: "Element Pro", + appleAppId: "id6502951615", + appStoreUrl: "https://apps.apple.com/app/element-pro-for-work/id6502951615", + playStoreUrl: "https://play.google.com/store/apps/details?id=io.element.enterprise", + deepLinkPath: "/element-pro", + usesLegacyDeepLink: false, + isProApp: true, + }, +}; + +export function updateMobilePage(metadata: MobileAppMetadata, deepLinkUrl: string, server: string | undefined): void { + const appleMeta = document.querySelector('meta[name="apple-itunes-app"]') as Element; + appleMeta.setAttribute("content", `app-id=${metadata.appleAppId}`); + + if (server) { + (document.getElementById("header_title") as HTMLHeadingElement).innerText = `Join ${server} on Element`; + } + (document.getElementById("app_store_link") as HTMLAnchorElement).href = metadata.appStoreUrl; + (document.getElementById("play_store_link") as HTMLAnchorElement).href = metadata.playStoreUrl; + + if (metadata.fDroidUrl) { + (document.getElementById("f_droid_link") as HTMLAnchorElement).href = metadata.fDroidUrl; + } else { + document.getElementById("f_droid_section")!.style.display = "none"; + } + + const step1Heading = document.getElementById("step1_heading")!; + step1Heading.innerHTML = step1Heading!.innerHTML.replace("Element", metadata.name); + + // Step 2 is only shown on the mobile guide, not on mobile.element.io + if (document.getElementById("step2_container")) { + document.getElementById("step2_container")!.style.display = "block"; + if (metadata.isProApp) { + document.getElementById("step2_description")!.innerHTML = "Use your work email to join"; + } + (document.getElementById("deep_link_button") as HTMLAnchorElement).href = deepLinkUrl; + } +} diff --git a/webpack.config.js b/webpack.config.js index e0d7d4fe933..bc5b405232b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -679,6 +679,12 @@ module.exports = (env, argv) => { context: path.resolve(__dirname, "node_modules/@element-hq/element-call-embedded/dist"), to: path.join(__dirname, "webapp", "widgets", "element-call"), }, + // Mobile guide assets + { + from: "assets/**", + context: path.resolve(__dirname, "src/vector/mobile_guide"), + to: "mobile_guide", + }, ], }), @@ -771,6 +777,8 @@ module.exports = (env, argv) => { function getAssetOutputPath(url, resourcePath) { const isKaTeX = resourcePath.includes("KaTeX"); const isFontSource = resourcePath.includes("@fontsource"); + const mobileGuideAssetsPath = path.join("mobile_guide", "assets"); + const isMobileGuide = resourcePath.includes(mobileGuideAssetsPath); // `res` is the parent dir for our own assets in various layers // `dist` is the parent dir for KaTeX assets // `files` is the parent dir for @fontsource assets @@ -804,6 +812,11 @@ function getAssetOutputPath(url, resourcePath) { outputDir = "fonts"; } + if (isMobileGuide) { + // Specific handling for the mobile guide assets, as they live alongside the page sources. + outputDir = mobileGuideAssetsPath; + } + if (isKaTeX) { // Add a clearly named directory segment, rather than leaving the KaTeX // assets loose in each asset type directory.