element-web/src/BasePlatform.ts

461 lines
15 KiB
TypeScript
Raw Normal View History

/*
2016-11-03 11:47:57 +00:00
Copyright 2016 Aviral Dasgupta
Copyright 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2020 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.
*/
2021-06-29 12:11:58 +00:00
import { MatrixClient } from "matrix-js-sdk/src/client";
import { encodeUnpaddedBase64 } from "matrix-js-sdk/src/crypto/olmlib";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
import dis from './dispatcher/dispatcher';
import BaseEventIndexManager from './indexing/BaseEventIndexManager';
2021-06-29 12:11:58 +00:00
import { ActionPayload } from "./dispatcher/payloads";
import { CheckUpdatesPayload } from "./dispatcher/payloads/CheckUpdatesPayload";
import { Action } from "./dispatcher/actions";
import { hideToast as hideUpdateToast } from "./toasts/UpdateToast";
import { MatrixClientPeg } from "./MatrixClientPeg";
import { idbLoad, idbSave, idbDelete } from "./utils/StorageManager";
import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
import { IConfigOptions } from "./IConfigOptions";
export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url";
export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url";
2021-02-01 16:25:50 +00:00
export const SSO_IDP_ID_KEY = "mx_sso_idp_id";
export enum UpdateCheckStatus {
Checking = "CHECKING",
Error = "ERROR",
NotAvailable = "NOTAVAILABLE",
Downloading = "DOWNLOADING",
Ready = "READY",
}
const UPDATE_DEFER_KEY = "mx_defer_update";
/**
* Base class for classes that provide platform-specific functionality
* eg. Setting an application badge or displaying notifications
*
* Instances of this class are provided by the application.
*/
export default abstract class BasePlatform {
protected notificationCount = 0;
protected errorDidOccur = false;
constructor() {
dis.register(this.onAction);
this.startUpdateCheck = this.startUpdateCheck.bind(this);
}
abstract getConfig(): Promise<IConfigOptions>;
abstract getDefaultDeviceDisplayName(): string;
protected onAction = (payload: ActionPayload) => {
switch (payload.action) {
case 'on_client_not_viable':
case 'on_logged_out':
this.setNotificationCount(0);
break;
}
};
// Used primarily for Analytics
abstract getHumanReadableName(): string;
setNotificationCount(count: number) {
this.notificationCount = count;
}
setErrorStatus(errorDidOccur: boolean) {
this.errorDidOccur = errorDidOccur;
}
/**
* Whether we can call checkForUpdate on this platform build
*/
async canSelfUpdate(): Promise<boolean> {
return false;
}
startUpdateCheck() {
hideUpdateToast();
localStorage.removeItem(UPDATE_DEFER_KEY);
dis.dispatch<CheckUpdatesPayload>({
action: Action.CheckUpdates,
status: UpdateCheckStatus.Checking,
});
}
/**
* Update the currently running app to the latest available version
* and replace this instance of the app with the new version.
*/
installUpdate() {
}
/**
* Check if the version update has been deferred and that deferment is still in effect
* @param newVersion the version string to check
*/
protected shouldShowUpdate(newVersion: string): boolean {
2020-11-02 17:25:48 +00:00
// If the user registered on this client in the last 24 hours then do not show them the update toast
if (MatrixClientPeg.userRegisteredWithinLastHours(24)) return false;
try {
const [version, deferUntil] = JSON.parse(localStorage.getItem(UPDATE_DEFER_KEY));
return newVersion !== version || Date.now() > deferUntil;
} catch (e) {
return true;
}
}
/**
* Ignore the pending update and don't prompt about this version
* until the next morning (8am).
*/
deferUpdate(newVersion: string) {
const date = new Date(Date.now() + 24 * 60 * 60 * 1000);
date.setHours(8, 0, 0, 0); // set to next 8am
localStorage.setItem(UPDATE_DEFER_KEY, JSON.stringify([newVersion, date.getTime()]));
hideUpdateToast();
}
/**
* Return true if platform supports multi-language
* spell-checking, otherwise false.
*/
supportsMultiLanguageSpellCheck(): boolean {
return false;
}
Add message right click context menu v2 (#5672) * migrate the message context menu to IconizedContextMenu Signed-off-by: Michael Weimann <mail@michael-weimann.eu> * migrate the message context menu to IconizedContextMenu Signed-off-by: Michael Weimann <mail@michael-weimann.eu> * Added right-click menu Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * add message context menu group keys Signed-off-by: Michael Weimann <mail@michael-weimann.eu> * add message context menu icons Signed-off-by: Michael Weimann <mail@michael-weimann.eu> * add _MessageContextMenu.scss license header Signed-off-by: Michael Weimann <mail@michael-weimann.eu> * use null vars for context menu lists * Add allowOverridingNativeContextMenus() Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Use allowOverridingNativeContextMenus() Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Fix types Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Fix types Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Remove mistaken line Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Fix styling Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * InputHTMLAttributes -> AllHTMLAttributes Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Convert to TS Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Add some types Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Make onClick optional Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Add rightClick prop Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Add copy button Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * What about upgrading deps after the eslint migration, Simon? Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Add edit button Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * fix Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Add reply button Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Add react button Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Cleanup render() Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Fix comments Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Add save button Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Don't show context menu if editing Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Add special handling for click a timestamp Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Fix double empty line Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Don't show context menu for images Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Cleanup Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Fix order Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Keep action bar shown when right-clicking Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Highlight event tile when right-clicking Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Delint Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Pointless change so that I can re-run the CI Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Remove dowload button Because we don't use this menu when clicking on images Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Be more clear for non-bools Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Use triggerOnMouse down prop Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Remove a comment Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Remove unused var Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Remove unnecessary import Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Add some missing types Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Add missing type Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Remove unused import Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Add a missing type Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Fix types Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Fix types/naming Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Add missing current Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Remove unused var Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Fix editing and replying Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * i18n Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Fix import Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Support right-click context menu for threads Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Make button order match `MessageActionBar` Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Fix missing permalink button Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Remove useless part of if statement Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Some small refactoring for consistency Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Some more refactoring Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Fix `editEvent()` call Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Make editing polls work Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Fix collapse reply chain button Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Fix timelineRenderingType Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Fix reply button Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Hide right-click context menu behind a labs flag Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Add missing return type Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Make `contextMene` optional Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Move `renderContextMenu()` Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Simplify `renderContextMenu()` Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Improve `aboveLeftOf` typing Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Use `InputHTMLAttributes` Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Disable message right-click context menu in browser (for now) Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> * Give permalink button more props Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com> Co-authored-by: Michael Weimann <mail@michael-weimann.eu>
2022-04-15 14:22:59 +00:00
/**
* Returns true if platform allows overriding native context menus
*/
public allowOverridingNativeContextMenus(): boolean {
return false;
}
/**
* Returns true if the platform supports displaying
* notifications, otherwise false.
* @returns {boolean} whether the platform supports displaying notifications
*/
supportsNotifications(): boolean {
return false;
}
/**
* Returns true if the application currently has permission
* to display notifications. Otherwise false.
* @returns {boolean} whether the application has permission to display notifications
*/
maySendNotifications(): boolean {
return false;
}
/**
* Requests permission to send notifications. Returns
* a promise that is resolved when the user has responded
* to the request. The promise has a single string argument
* that is 'granted' if the user allowed the request or
* 'denied' otherwise.
*/
abstract requestNotificationPermission(): Promise<string>;
public displayNotification(
title: string,
msg: string,
avatarUrl: string,
room: Room,
ev?: MatrixEvent,
): Notification {
const notifBody = {
body: msg,
silent: true, // we play our own sounds
};
if (avatarUrl) notifBody['icon'] = avatarUrl;
const notification = new window.Notification(title, notifBody);
notification.onclick = () => {
const payload: ViewRoomPayload = {
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: "Notification",
};
if (ev.getThread()) {
payload.event_id = ev.getId();
}
dis.dispatch(payload);
window.focus();
};
return notification;
}
loudNotification(ev: MatrixEvent, room: Room) {
2020-06-18 13:32:43 +00:00
}
2020-08-05 10:07:10 +00:00
clearNotification(notif: Notification) {
// Some browsers don't support this, e.g Safari on iOS
// https://developer.mozilla.org/en-US/docs/Web/API/Notification/close
if (notif.close) {
notif.close();
}
2020-08-05 10:07:10 +00:00
}
/**
* Returns a promise that resolves to a string representing the current version of the application.
*/
abstract getAppVersion(): Promise<string>;
/*
* If it's not expected that capturing the screen will work
* with getUserMedia, return a string explaining why not.
* Otherwise, return null.
*/
screenCaptureErrorString(): string {
return "Not implemented";
}
/**
* Restarts the application, without necessarily reloading
* any application code
*/
abstract reload();
supportsAutoLaunch(): boolean {
return false;
}
// XXX: Surely this should be a setting like any other?
async getAutoLaunchEnabled(): Promise<boolean> {
return false;
}
async setAutoLaunchEnabled(enabled: boolean): Promise<void> {
throw new Error("Unimplemented");
}
2021-03-25 14:14:38 +00:00
supportsWarnBeforeExit(): boolean {
return false;
}
async shouldWarnBeforeExit(): Promise<boolean> {
return false;
}
async setWarnBeforeExit(enabled: boolean): Promise<void> {
throw new Error("Unimplemented");
}
supportsAutoHideMenuBar(): boolean {
return false;
}
async getAutoHideMenuBarEnabled(): Promise<boolean> {
return false;
}
async setAutoHideMenuBarEnabled(enabled: boolean): Promise<void> {
throw new Error("Unimplemented");
}
supportsMinimizeToTray(): boolean {
return false;
}
async getMinimizeToTrayEnabled(): Promise<boolean> {
return false;
}
async setMinimizeToTrayEnabled(enabled: boolean): Promise<void> {
throw new Error("Unimplemented");
}
/**
* Get our platform specific EventIndexManager.
*
* @return {BaseEventIndexManager} The EventIndex manager for our platform,
* can be null if the platform doesn't support event indexing.
*/
getEventIndexingManager(): BaseEventIndexManager | null {
return null;
}
2021-04-27 14:11:49 +00:00
async setLanguage(preferredLangs: string[]) {}
2020-12-01 19:17:24 +00:00
setSpellCheckLanguages(preferredLangs: string[]) {}
getSpellCheckLanguages(): Promise<string[]> | null {
return null;
}
async getDesktopCapturerSources(options: GetSourcesOptions): Promise<Array<DesktopCapturerSource>> {
return [];
}
supportsDesktopCapturer(): boolean {
return false;
}
2022-02-23 09:12:04 +00:00
public overrideBrowserShortcuts(): boolean {
return false;
}
public navigateForwardBack(back: boolean): void {}
getAvailableSpellCheckLanguages(): Promise<string[]> | null {
return null;
}
protected getSSOCallbackUrl(fragmentAfterLogin: string): URL {
const url = new URL(window.location.href);
url.hash = fragmentAfterLogin || "";
return url;
}
/**
* Begin Single Sign On flows.
2020-03-02 15:05:56 +00:00
* @param {MatrixClient} mxClient the matrix client using which we should start the flow
* @param {"sso"|"cas"} loginType the type of SSO it is, CAS/SSO.
* @param {string} fragmentAfterLogin the hash to pass to the app during sso callback.
* @param {string} idpId The ID of the Identity Provider being targeted, optional.
*/
startSingleSignOn(mxClient: MatrixClient, loginType: "sso" | "cas", fragmentAfterLogin: string, idpId?: string) {
// persist hs url and is url for when the user is returned to the app with the login token
localStorage.setItem(SSO_HOMESERVER_URL_KEY, mxClient.getHomeserverUrl());
if (mxClient.getIdentityServerUrl()) {
localStorage.setItem(SSO_ID_SERVER_URL_KEY, mxClient.getIdentityServerUrl());
}
2021-02-01 16:25:50 +00:00
if (idpId) {
localStorage.setItem(SSO_IDP_ID_KEY, idpId);
}
const callbackUrl = this.getSSOCallbackUrl(fragmentAfterLogin);
window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType, idpId); // redirect to SSO
}
onKeyDown(ev: KeyboardEvent): boolean {
return false; // no shortcuts implemented
}
/**
* Get a previously stored pickle key. The pickle key is used for
* encrypting libolm objects.
* @param {string} userId the user ID for the user that the pickle key is for.
* @param {string} userId the device ID that the pickle key is for.
* @returns {string|null} the previously stored pickle key, or null if no
* pickle key has been stored.
*/
2020-05-28 15:56:48 +00:00
async getPickleKey(userId: string, deviceId: string): Promise<string | null> {
if (!window.crypto || !window.crypto.subtle) {
return null;
}
let data;
try {
data = await idbLoad("pickleKey", [userId, deviceId]);
} catch (e) {
logger.error("idbLoad for pickleKey failed", e);
}
if (!data) {
return null;
}
if (!data.encrypted || !data.iv || !data.cryptoKey) {
logger.error("Badly formatted pickle key");
return null;
}
const additionalData = new Uint8Array(userId.length + deviceId.length + 1);
for (let i = 0; i < userId.length; i++) {
additionalData[i] = userId.charCodeAt(i);
}
additionalData[userId.length] = 124; // "|"
for (let i = 0; i < deviceId.length; i++) {
additionalData[userId.length + 1 + i] = deviceId.charCodeAt(i);
}
try {
const key = await crypto.subtle.decrypt(
2021-06-29 12:11:58 +00:00
{ name: "AES-GCM", iv: data.iv, additionalData }, data.cryptoKey,
data.encrypted,
);
return encodeUnpaddedBase64(key);
} catch (e) {
logger.error("Error decrypting pickle key");
return null;
}
}
/**
* Create and store a pickle key for encrypting libolm objects.
* @param {string} userId the user ID for the user that the pickle key is for.
2021-06-24 18:20:02 +00:00
* @param {string} deviceId the device ID that the pickle key is for.
* @returns {string|null} the pickle key, or null if the platform does not
* support storing pickle keys.
*/
2020-05-28 15:56:48 +00:00
async createPickleKey(userId: string, deviceId: string): Promise<string | null> {
if (!window.crypto || !window.crypto.subtle) {
return null;
}
const crypto = window.crypto;
const randomArray = new Uint8Array(32);
crypto.getRandomValues(randomArray);
const cryptoKey = await crypto.subtle.generateKey(
2021-06-29 12:11:58 +00:00
{ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"],
);
const iv = new Uint8Array(32);
crypto.getRandomValues(iv);
const additionalData = new Uint8Array(userId.length + deviceId.length + 1);
for (let i = 0; i < userId.length; i++) {
additionalData[i] = userId.charCodeAt(i);
}
additionalData[userId.length] = 124; // "|"
for (let i = 0; i < deviceId.length; i++) {
additionalData[userId.length + 1 + i] = deviceId.charCodeAt(i);
}
const encrypted = await crypto.subtle.encrypt(
2021-06-29 12:11:58 +00:00
{ name: "AES-GCM", iv, additionalData }, cryptoKey, randomArray,
);
try {
2021-06-29 12:11:58 +00:00
await idbSave("pickleKey", [userId, deviceId], { encrypted, iv, cryptoKey });
} catch (e) {
return null;
}
return encodeUnpaddedBase64(randomArray);
}
/**
* Delete a previously stored pickle key from storage.
* @param {string} userId the user ID for the user that the pickle key is for.
* @param {string} userId the device ID that the pickle key is for.
*/
2020-05-28 15:56:48 +00:00
async destroyPickleKey(userId: string, deviceId: string): Promise<void> {
try {
await idbDelete("pickleKey", [userId, deviceId]);
} catch (e) {
logger.error("idbDelete failed in destroyPickleKey", e);
}
}
}