Avoid rendering app download buttons if disabled in config (#11741)

* Add default desktop_builds and mobile_builds into SdkConfig.DEFAULTS

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Avoid rendering app download buttons if config sets to `null`

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Disable app download onboarding task if config has no apps to download

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add tests and update types

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix types

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
pull/28217/head
Michael Telatynski 2023-10-13 10:43:39 +01:00 committed by GitHub
parent e22fa2efc1
commit bdf2ebd301
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 723 additions and 70 deletions

View File

@ -71,15 +71,15 @@ export interface IConfigOptions {
permalink_prefix?: string;
update_base_url?: string;
desktop_builds?: {
desktop_builds: {
available: boolean;
logo: string; // url
url: string; // download url
};
mobile_builds?: {
ios?: string; // download url
android?: string; // download url
fdroid?: string; // download url
mobile_builds: {
ios: string | null; // download url
android: string | null; // download url
fdroid: string | null; // download url
};
mobile_guide_toast?: boolean;

View File

@ -61,6 +61,17 @@ export const DEFAULTS: DeepReadonly<IConfigOptions> = {
"https://github.com/vector-im/element-web/issues?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc",
new_issue_url: "https://github.com/vector-im/element-web/issues/new/choose",
},
desktop_builds: {
available: true,
logo: "vector-icons/1024.png",
url: "https://element.io/download",
},
mobile_builds: {
ios: "https://apps.apple.com/app/vector/id1083446067",
android: "https://play.google.com/store/apps/details?id=im.vector.app",
fdroid: "https://f-droid.org/repository/browse/?fdid=im.vector.app",
},
};
export type ConfigOptions = Defaultize<IConfigOptions, typeof DEFAULTS>;

View File

@ -44,7 +44,7 @@ import PosthogTrackers from "../../PosthogTrackers";
import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
import { IMatrixClientCreds, MatrixClientPeg } from "../../MatrixClientPeg";
import PlatformPeg from "../../PlatformPeg";
import SdkConfig from "../../SdkConfig";
import SdkConfig, { ConfigOptions } from "../../SdkConfig";
import dis from "../../dispatcher/dispatcher";
import Notifier from "../../Notifier";
import Modal from "../../Modal";
@ -122,7 +122,6 @@ import { ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePaylo
import { AfterLeaveRoomPayload } from "../../dispatcher/payloads/AfterLeaveRoomPayload";
import { DoAfterSyncPreparedPayload } from "../../dispatcher/payloads/DoAfterSyncPreparedPayload";
import { ViewStartChatOrReusePayload } from "../../dispatcher/payloads/ViewStartChatOrReusePayload";
import { IConfigOptions } from "../../IConfigOptions";
import { SnakedObject } from "../../utils/SnakedObject";
import { leaveRoomBehaviour } from "../../utils/leave-behaviour";
import { CallStore } from "../../stores/CallStore";
@ -165,7 +164,7 @@ interface IScreen {
}
interface IProps {
config: IConfigOptions;
config: ConfigOptions;
onNewScreen: (screen: string, replaceLast: boolean) => void;
enableGuest?: boolean;
// the queryParams extracted from the [real] query-string of the URI
@ -1138,7 +1137,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
private chatCreateOrReuse(userId: string): void {
const snakedConfig = new SnakedObject<IConfigOptions>(this.props.config);
const snakedConfig = new SnakedObject(this.props.config);
// Use a deferred action to reshow the dialog once the user has registered
if (MatrixClientPeg.safeGet().isGuest()) {
// No point in making 2 DMs with welcome bot. This assumes view_set_mxid will
@ -1295,7 +1294,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
* @returns {string} The room ID of the new room, or null if no room was created
*/
private async startWelcomeUserChat(): Promise<string | null> {
const snakedConfig = new SnakedObject<IConfigOptions>(this.props.config);
const snakedConfig = new SnakedObject(this.props.config);
const welcomeUserId = snakedConfig.get("welcome_user_id");
if (!welcomeUserId) return null;
@ -1390,7 +1389,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} else if (MatrixClientPeg.currentUserIsJustRegistered()) {
MatrixClientPeg.setJustRegisteredUserId(null);
const snakedConfig = new SnakedObject<IConfigOptions>(this.props.config);
const snakedConfig = new SnakedObject(this.props.config);
if (snakedConfig.get("welcome_user_id") && getCurrentLanguage().startsWith("en")) {
const welcomeUserRoom = await this.startWelcomeUserChat();
if (welcomeUserRoom === null) {

View File

@ -26,24 +26,32 @@ import QRCode from "../elements/QRCode";
import Heading from "../typography/Heading";
import BaseDialog from "./BaseDialog";
const fallbackAppStore = "https://apps.apple.com/app/vector/id1083446067";
const fallbackGooglePlay = "https://play.google.com/store/apps/details?id=im.vector.app";
const fallbackFDroid = "https://f-droid.org/repository/browse/?fdid=im.vector.app";
interface Props {
onFinished(): void;
}
export const showAppDownloadDialogPrompt = (): boolean => {
const desktopBuilds = SdkConfig.getObject("desktop_builds");
const mobileBuilds = SdkConfig.getObject("mobile_builds");
return (
!!desktopBuilds?.get("available") ||
!!mobileBuilds?.get("ios") ||
!!mobileBuilds?.get("android") ||
!!mobileBuilds?.get("fdroid")
);
};
export const AppDownloadDialog: FC<Props> = ({ onFinished }) => {
const brand = SdkConfig.get("brand");
const desktopBuilds = SdkConfig.getObject("desktop_builds");
const mobileBuilds = SdkConfig.getObject("mobile_builds");
const urlAppStore = mobileBuilds?.get("ios") ?? fallbackAppStore;
const urlAppStore = mobileBuilds?.get("ios");
const urlAndroid = mobileBuilds?.get("android") ?? mobileBuilds?.get("fdroid") ?? fallbackGooglePlay;
const urlGooglePlay = mobileBuilds?.get("android") ?? fallbackGooglePlay;
const urlFDroid = mobileBuilds?.get("fdroid") ?? fallbackFDroid;
const urlGooglePlay = mobileBuilds?.get("android");
const urlFDroid = mobileBuilds?.get("fdroid");
const urlAndroid = urlGooglePlay ?? urlFDroid;
return (
<BaseDialog
@ -67,57 +75,65 @@ export const AppDownloadDialog: FC<Props> = ({ onFinished }) => {
</div>
)}
<div className="mx_AppDownloadDialog_mobile">
<div className="mx_AppDownloadDialog_app">
<Heading size="3">{_t("common|ios")}</Heading>
<QRCode data={urlAppStore} margin={0} width={172} />
<div className="mx_AppDownloadDialog_info">
{_t("onboarding|qr_or_app_links", {
appLinks: "",
qrCode: "",
})}
{urlAppStore && (
<div className="mx_AppDownloadDialog_app">
<Heading size="3">{_t("common|ios")}</Heading>
<QRCode data={urlAppStore} margin={0} width={172} />
<div className="mx_AppDownloadDialog_info">
{_t("onboarding|qr_or_app_links", {
appLinks: "",
qrCode: "",
})}
</div>
<div className="mx_AppDownloadDialog_links">
<AccessibleButton
element="a"
href={urlAppStore}
target="_blank"
aria-label={_t("onboarding|download_app_store")}
onClick={() => {}}
>
<IOSBadge />
</AccessibleButton>
</div>
</div>
<div className="mx_AppDownloadDialog_links">
<AccessibleButton
element="a"
href={urlAppStore}
target="_blank"
aria-label={_t("onboarding|download_app_store")}
onClick={() => {}}
>
<IOSBadge />
</AccessibleButton>
)}
{urlAndroid && (
<div className="mx_AppDownloadDialog_app">
<Heading size="3">{_t("common|android")}</Heading>
<QRCode data={urlAndroid} margin={0} width={172} />
<div className="mx_AppDownloadDialog_info">
{_t("onboarding|qr_or_app_links", {
appLinks: "",
qrCode: "",
})}
</div>
<div className="mx_AppDownloadDialog_links">
{urlGooglePlay && (
<AccessibleButton
element="a"
href={urlGooglePlay}
target="_blank"
aria-label={_t("onboarding|download_google_play")}
onClick={() => {}}
>
<GooglePlayBadge />
</AccessibleButton>
)}
{urlFDroid && (
<AccessibleButton
element="a"
href={urlFDroid}
target="_blank"
aria-label={_t("onboarding|download_f_droid")}
onClick={() => {}}
>
<FDroidBadge />
</AccessibleButton>
)}
</div>
</div>
</div>
<div className="mx_AppDownloadDialog_app">
<Heading size="3">{_t("common|android")}</Heading>
<QRCode data={urlAndroid} margin={0} width={172} />
<div className="mx_AppDownloadDialog_info">
{_t("onboarding|qr_or_app_links", {
appLinks: "",
qrCode: "",
})}
</div>
<div className="mx_AppDownloadDialog_links">
<AccessibleButton
element="a"
href={urlGooglePlay}
target="_blank"
aria-label={_t("onboarding|download_google_play")}
onClick={() => {}}
>
<GooglePlayBadge />
</AccessibleButton>
<AccessibleButton
element="a"
href={urlFDroid}
target="_blank"
aria-label={_t("onboarding|download_f_droid")}
onClick={() => {}}
>
<FDroidBadge />
</AccessibleButton>
</div>
</div>
)}
</div>
<div className="mx_AppDownloadDialog_legal">
<p>{_t("onboarding|apple_trademarks")}</p>

View File

@ -16,7 +16,7 @@ limitations under the License.
import { useMemo } from "react";
import { AppDownloadDialog } from "../components/views/dialogs/AppDownloadDialog";
import { AppDownloadDialog, showAppDownloadDialogPrompt } from "../components/views/dialogs/AppDownloadDialog";
import { UserTab } from "../components/views/dialogs/UserTab";
import { ButtonEvent } from "../components/views/elements/AccessibleButton";
import { Action } from "../dispatcher/actions";
@ -42,6 +42,7 @@ interface UserOnboardingTask {
hideOnComplete?: boolean;
};
completed: (ctx: UserOnboardingContext) => boolean;
disabled?(): boolean;
}
export interface UserOnboardingTaskWithResolvedCompletion extends Omit<UserOnboardingTask, "completed"> {
@ -111,6 +112,9 @@ const tasks: UserOnboardingTask[] = [
Modal.createDialog(AppDownloadDialog, {}, "mx_AppDownloadDialog_wrapper", false, true);
},
},
disabled(): boolean {
return !showAppDownloadDialogPrompt();
},
},
{
id: "setup-profile",
@ -149,7 +153,10 @@ export function useUserOnboardingTasks(context: UserOnboardingContext): UserOnbo
return useMemo<UserOnboardingTaskWithResolvedCompletion[]>(() => {
return tasks
.filter((task) => !task.relevant || task.relevant.includes(useCase))
.filter((task) => {
if (task.disabled?.()) return false;
return !task.relevant || task.relevant.includes(useCase);
})
.map((task) => ({
...task,
completed: task.completed(context),

View File

@ -0,0 +1,80 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import { AppDownloadDialog } from "../../../../src/components/views/dialogs/AppDownloadDialog";
import SdkConfig, { ConfigOptions } from "../../../../src/SdkConfig";
describe("AppDownloadDialog", () => {
afterEach(() => {
SdkConfig.reset();
});
it("should render with desktop, ios, android, fdroid buttons by default", () => {
const { asFragment } = render(<AppDownloadDialog onFinished={jest.fn()} />);
expect(screen.queryByRole("button", { name: "Download Element Desktop" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Download on the App Store" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Get it on Google Play" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Get it on F-Droid" })).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
it("should allow disabling fdroid build", () => {
SdkConfig.add({
mobile_builds: {
fdroid: null,
},
} as ConfigOptions);
const { asFragment } = render(<AppDownloadDialog onFinished={jest.fn()} />);
expect(screen.queryByRole("button", { name: "Download Element Desktop" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Download on the App Store" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Get it on Google Play" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Get it on F-Droid" })).not.toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
it("should allow disabling desktop build", () => {
SdkConfig.add({
desktop_builds: {
available: false,
},
} as ConfigOptions);
const { asFragment } = render(<AppDownloadDialog onFinished={jest.fn()} />);
expect(screen.queryByRole("button", { name: "Download Element Desktop" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Download on the App Store" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Get it on Google Play" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Get it on F-Droid" })).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
it("should allow disabling mobile builds", () => {
SdkConfig.add({
mobile_builds: {
ios: null,
android: null,
fdroid: null,
},
} as ConfigOptions);
const { asFragment } = render(<AppDownloadDialog onFinished={jest.fn()} />);
expect(screen.queryByRole("button", { name: "Download Element Desktop" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Download on the App Store" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Get it on Google Play" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Get it on F-Droid" })).not.toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
});

View File

@ -0,0 +1,540 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AppDownloadDialog should allow disabling desktop build 1`] = `
<DocumentFragment>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
aria-labelledby="mx_BaseDialog_title"
class="mx_AppDownloadDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
>
<div
class="mx_Dialog_header mx_Dialog_headerWithCancel"
>
<h2
class="mx_Heading_h3 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Download Element
</h2>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
/>
</div>
<div
class="mx_AppDownloadDialog_mobile"
>
<div
class="mx_AppDownloadDialog_app"
>
<h3
class="mx_Heading_h3"
>
iOS
</h3>
<div
class="mx_QRCode"
>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
</div>
<div
class="mx_AppDownloadDialog_info"
>
or
</div>
<div
class="mx_AppDownloadDialog_links"
>
<a
aria-label="Download on the App Store"
class="mx_AccessibleButton"
href="https://apps.apple.com/app/vector/id1083446067"
role="button"
tabindex="0"
target="_blank"
>
<div />
</a>
</div>
</div>
<div
class="mx_AppDownloadDialog_app"
>
<h3
class="mx_Heading_h3"
>
Android
</h3>
<div
class="mx_QRCode"
>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
</div>
<div
class="mx_AppDownloadDialog_info"
>
or
</div>
<div
class="mx_AppDownloadDialog_links"
>
<a
aria-label="Get it on Google Play"
class="mx_AccessibleButton"
href="https://play.google.com/store/apps/details?id=im.vector.app"
role="button"
tabindex="0"
target="_blank"
>
<div />
</a>
<a
aria-label="Get it on F-Droid"
class="mx_AccessibleButton"
href="https://f-droid.org/repository/browse/?fdid=im.vector.app"
role="button"
tabindex="0"
target="_blank"
>
<div />
</a>
</div>
</div>
</div>
<div
class="mx_AppDownloadDialog_legal"
>
<p>
App Store® and the Apple logo® are trademarks of Apple Inc.
</p>
<p>
Google Play and the Google Play logo are trademarks of Google LLC.
</p>
</div>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</DocumentFragment>
`;
exports[`AppDownloadDialog should allow disabling fdroid build 1`] = `
<DocumentFragment>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
aria-labelledby="mx_BaseDialog_title"
class="mx_AppDownloadDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
>
<div
class="mx_Dialog_header mx_Dialog_headerWithCancel"
>
<h2
class="mx_Heading_h3 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Download Element
</h2>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
/>
</div>
<div
class="mx_AppDownloadDialog_desktop"
>
<h3
class="mx_Heading_h3"
>
Download Element Desktop
</h3>
<a
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
href="https://element.io/download"
role="button"
tabindex="0"
target="_blank"
>
Download Element Desktop
</a>
</div>
<div
class="mx_AppDownloadDialog_mobile"
>
<div
class="mx_AppDownloadDialog_app"
>
<h3
class="mx_Heading_h3"
>
iOS
</h3>
<div
class="mx_QRCode"
>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
</div>
<div
class="mx_AppDownloadDialog_info"
>
or
</div>
<div
class="mx_AppDownloadDialog_links"
>
<a
aria-label="Download on the App Store"
class="mx_AccessibleButton"
href="https://apps.apple.com/app/vector/id1083446067"
role="button"
tabindex="0"
target="_blank"
>
<div />
</a>
</div>
</div>
<div
class="mx_AppDownloadDialog_app"
>
<h3
class="mx_Heading_h3"
>
Android
</h3>
<div
class="mx_QRCode"
>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
</div>
<div
class="mx_AppDownloadDialog_info"
>
or
</div>
<div
class="mx_AppDownloadDialog_links"
>
<a
aria-label="Get it on Google Play"
class="mx_AccessibleButton"
href="https://play.google.com/store/apps/details?id=im.vector.app"
role="button"
tabindex="0"
target="_blank"
>
<div />
</a>
</div>
</div>
</div>
<div
class="mx_AppDownloadDialog_legal"
>
<p>
App Store® and the Apple logo® are trademarks of Apple Inc.
</p>
<p>
Google Play and the Google Play logo are trademarks of Google LLC.
</p>
</div>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</DocumentFragment>
`;
exports[`AppDownloadDialog should allow disabling mobile builds 1`] = `
<DocumentFragment>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
aria-labelledby="mx_BaseDialog_title"
class="mx_AppDownloadDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
>
<div
class="mx_Dialog_header mx_Dialog_headerWithCancel"
>
<h2
class="mx_Heading_h3 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Download Element
</h2>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
/>
</div>
<div
class="mx_AppDownloadDialog_desktop"
>
<h3
class="mx_Heading_h3"
>
Download Element Desktop
</h3>
<a
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
href="https://element.io/download"
role="button"
tabindex="0"
target="_blank"
>
Download Element Desktop
</a>
</div>
<div
class="mx_AppDownloadDialog_mobile"
/>
<div
class="mx_AppDownloadDialog_legal"
>
<p>
App Store® and the Apple logo® are trademarks of Apple Inc.
</p>
<p>
Google Play and the Google Play logo are trademarks of Google LLC.
</p>
</div>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</DocumentFragment>
`;
exports[`AppDownloadDialog should render with desktop, ios, android, fdroid buttons by default 1`] = `
<DocumentFragment>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
aria-labelledby="mx_BaseDialog_title"
class="mx_AppDownloadDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
>
<div
class="mx_Dialog_header mx_Dialog_headerWithCancel"
>
<h2
class="mx_Heading_h3 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Download Element
</h2>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
/>
</div>
<div
class="mx_AppDownloadDialog_desktop"
>
<h3
class="mx_Heading_h3"
>
Download Element Desktop
</h3>
<a
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
href="https://element.io/download"
role="button"
tabindex="0"
target="_blank"
>
Download Element Desktop
</a>
</div>
<div
class="mx_AppDownloadDialog_mobile"
>
<div
class="mx_AppDownloadDialog_app"
>
<h3
class="mx_Heading_h3"
>
iOS
</h3>
<div
class="mx_QRCode"
>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
</div>
<div
class="mx_AppDownloadDialog_info"
>
or
</div>
<div
class="mx_AppDownloadDialog_links"
>
<a
aria-label="Download on the App Store"
class="mx_AccessibleButton"
href="https://apps.apple.com/app/vector/id1083446067"
role="button"
tabindex="0"
target="_blank"
>
<div />
</a>
</div>
</div>
<div
class="mx_AppDownloadDialog_app"
>
<h3
class="mx_Heading_h3"
>
Android
</h3>
<div
class="mx_QRCode"
>
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
</div>
<div
class="mx_AppDownloadDialog_info"
>
or
</div>
<div
class="mx_AppDownloadDialog_links"
>
<a
aria-label="Get it on Google Play"
class="mx_AccessibleButton"
href="https://play.google.com/store/apps/details?id=im.vector.app"
role="button"
tabindex="0"
target="_blank"
>
<div />
</a>
<a
aria-label="Get it on F-Droid"
class="mx_AccessibleButton"
href="https://f-droid.org/repository/browse/?fdid=im.vector.app"
role="button"
tabindex="0"
target="_blank"
>
<div />
</a>
</div>
</div>
</div>
<div
class="mx_AppDownloadDialog_legal"
>
<p>
App Store® and the Apple logo® are trademarks of Apple Inc.
</p>
<p>
Google Play and the Google Play logo are trademarks of Google LLC.
</p>
</div>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</DocumentFragment>
`;