diff --git a/package.json b/package.json index 41ba3f47c1..3fd7703afb 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "@babel/register": "^7.7.4", "@peculiar/webcrypto": "^1.0.22", "@types/classnames": "^2.2.10", + "@types/counterpart": "^0.18.1", "@types/flux": "^3.1.9", "@types/lodash": "^4.14.152", "@types/modernizr": "^3.5.3", diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index ffd3277892..2c2fec759c 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -20,6 +20,7 @@ import { IMatrixClientPeg } from "../MatrixClientPeg"; import ToastStore from "../stores/ToastStore"; import DeviceListener from "../DeviceListener"; import { RoomListStore2 } from "../stores/room-list/RoomListStore2"; +import { PlatformPeg } from "../PlatformPeg"; declare global { interface Window { @@ -33,6 +34,7 @@ declare global { mx_ToastStore: ToastStore; mx_DeviceListener: DeviceListener; mx_RoomListStore2: RoomListStore2; + mxPlatformPeg: PlatformPeg; } // workaround for https://github.com/microsoft/TypeScript/issues/30933 @@ -45,6 +47,10 @@ declare global { hasStorageAccess?: () => Promise; } + interface Navigator { + userLanguage?: string; + } + interface StorageEstimate { usageDetails?: {[key: string]: number}; } diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index 1d11495e61..acf72a986c 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -53,6 +53,10 @@ export default abstract class BasePlatform { this.startUpdateCheck = this.startUpdateCheck.bind(this); } + abstract async getConfig(): Promise<{}>; + + abstract getDefaultDeviceDisplayName(): string; + protected onAction = (payload: ActionPayload) => { switch (payload.action) { case 'on_client_not_viable': diff --git a/src/PlatformPeg.js b/src/PlatformPeg.ts similarity index 80% rename from src/PlatformPeg.js rename to src/PlatformPeg.ts index 34131fde7d..1d2b813ebc 100644 --- a/src/PlatformPeg.js +++ b/src/PlatformPeg.ts @@ -1,5 +1,6 @@ /* Copyright 2016 OpenMarket 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. @@ -14,6 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import BasePlatform from "./BasePlatform"; + /* * Holds the current Platform object used by the code to do anything * specific to the platform we're running on (eg. web, electron) @@ -21,10 +24,8 @@ limitations under the License. * This allows the app layer to set a Platform without necessarily * having to have a MatrixChat object */ -class PlatformPeg { - constructor() { - this.platform = null; - } +export class PlatformPeg { + platform: BasePlatform = null; /** * Returns the current Platform object for the application. @@ -39,12 +40,12 @@ class PlatformPeg { * application. * This should be an instance of a class extending BasePlatform. */ - set(plaf) { + set(plaf: BasePlatform) { this.platform = plaf; } } -if (!global.mxPlatformPeg) { - global.mxPlatformPeg = new PlatformPeg(); +if (!window.mxPlatformPeg) { + window.mxPlatformPeg = new PlatformPeg(); } -export default global.mxPlatformPeg; +export default window.mxPlatformPeg; diff --git a/src/components/views/elements/EditableItemList.js b/src/components/views/elements/EditableItemList.js index 50d5a3d10f..34e53906a2 100644 --- a/src/components/views/elements/EditableItemList.js +++ b/src/components/views/elements/EditableItemList.js @@ -16,7 +16,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import {_t} from '../../../languageHandler.js'; +import {_t} from '../../../languageHandler'; import Field from "./Field"; import AccessibleButton from "./AccessibleButton"; diff --git a/src/components/views/toasts/GenericToast.tsx b/src/components/views/toasts/GenericToast.tsx index 9f8885ba47..6cd881b9eb 100644 --- a/src/components/views/toasts/GenericToast.tsx +++ b/src/components/views/toasts/GenericToast.tsx @@ -14,13 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {ReactChild} from "react"; +import React, {ReactNode} from "react"; import FormButton from "../elements/FormButton"; import {XOR} from "../../../@types/common"; export interface IProps { - description: ReactChild; + description: ReactNode; acceptLabel: string; onAccept(); diff --git a/src/contexts/MatrixClientContext.js b/src/contexts/MatrixClientContext.ts similarity index 85% rename from src/contexts/MatrixClientContext.js rename to src/contexts/MatrixClientContext.ts index 54a23ca132..7e8a92064d 100644 --- a/src/contexts/MatrixClientContext.js +++ b/src/contexts/MatrixClientContext.ts @@ -15,7 +15,8 @@ limitations under the License. */ import { createContext } from "react"; +import { MatrixClient } from "matrix-js-sdk/src/client"; -const MatrixClientContext = createContext(undefined); +const MatrixClientContext = createContext(undefined); MatrixClientContext.displayName = "MatrixClientContext"; export default MatrixClientContext; diff --git a/src/createRoom.js b/src/createRoom.ts similarity index 81% rename from src/createRoom.js rename to src/createRoom.ts index affdf196a7..c436196c27 100644 --- a/src/createRoom.js +++ b/src/createRoom.ts @@ -15,6 +15,9 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {MatrixClient} from "matrix-js-sdk/src/client"; +import {Room} from "matrix-js-sdk/src/models/room"; + import {MatrixClientPeg} from './MatrixClientPeg'; import Modal from './Modal'; import * as sdk from './index'; @@ -26,6 +29,56 @@ import {getAddressType} from "./UserAddress"; const E2EE_WK_KEY = "im.vector.riot.e2ee"; +// TODO move these interfaces over to js-sdk once it has been typescripted enough to accept them +enum Visibility { + Public = "public", + Private = "private", +} + +enum Preset { + PrivateChat = "private_chat", + TrustedPrivateChat = "trusted_private_chat", + PublicChat = "public_chat", +} + +interface Invite3PID { + id_server: string; + id_access_token?: string; // this gets injected by the js-sdk + medium: string; + address: string; +} + +interface IStateEvent { + type: string; + state_key?: string; // defaults to an empty string + content: object; +} + +interface ICreateOpts { + visibility?: Visibility; + room_alias_name?: string; + name?: string; + topic?: string; + invite?: string[]; + invite_3pid?: Invite3PID[]; + room_version?: string; + creation_content?: object; + initial_state?: IStateEvent[]; + preset?: Preset; + is_direct?: boolean; + power_level_content_override?: object; +} + +interface IOpts { + dmUserId?: string; + createOpts?: ICreateOpts; + spinner?: boolean; + guestAccess?: boolean; + encryption?: boolean; + inlineErrors?: boolean; + andView?: boolean; +} + /** * Create a new room, and switch to it. * @@ -40,11 +93,12 @@ const E2EE_WK_KEY = "im.vector.riot.e2ee"; * Default: False * @param {bool=} opts.inlineErrors True to raise errors off the promise instead of resolving to null. * Default: False + * @param {bool=} opts.andView True to dispatch an action to view the room once it has been created. * * @returns {Promise} which resolves to the room id, or null if the * action was aborted or failed. */ -export default function createRoom(opts) { +export default function createRoom(opts: IOpts): Promise { opts = opts || {}; if (opts.spinner === undefined) opts.spinner = true; if (opts.guestAccess === undefined) opts.guestAccess = true; @@ -59,12 +113,12 @@ export default function createRoom(opts) { return Promise.resolve(null); } - const defaultPreset = opts.dmUserId ? 'trusted_private_chat' : 'private_chat'; + const defaultPreset = opts.dmUserId ? Preset.TrustedPrivateChat : Preset.PrivateChat; // set some defaults for the creation const createOpts = opts.createOpts || {}; createOpts.preset = createOpts.preset || defaultPreset; - createOpts.visibility = createOpts.visibility || 'private'; + createOpts.visibility = createOpts.visibility || Visibility.Private; if (opts.dmUserId && createOpts.invite === undefined) { switch (getAddressType(opts.dmUserId)) { case 'mx-user-id': @@ -166,7 +220,7 @@ export default function createRoom(opts) { }); } -export function findDMForUser(client, userId) { +export function findDMForUser(client: MatrixClient, userId: string): Room { const roomIds = DMRoomMap.shared().getDMRoomsForUserId(userId); const rooms = roomIds.map(id => client.getRoom(id)); const suitableDMRooms = rooms.filter(r => { @@ -189,7 +243,7 @@ export function findDMForUser(client, userId) { * NOTE: this assumes you've just created the room and there's not been an opportunity * for other code to run, so we shouldn't miss RoomState.newMember when it comes by. */ -export async function _waitForMember(client, roomId, userId, opts = { timeout: 1500 }) { +export async function _waitForMember(client: MatrixClient, roomId: string, userId: string, opts = { timeout: 1500 }) { const { timeout } = opts; let handler; return new Promise((resolve) => { @@ -212,7 +266,7 @@ export async function _waitForMember(client, roomId, userId, opts = { timeout: 1 * Ensure that for every user in a room, there is at least one device that we * can encrypt to. */ -export async function canEncryptToAllUsers(client, userIds) { +export async function canEncryptToAllUsers(client: MatrixClient, userIds: string[]) { const usersDeviceMap = await client.downloadKeys(userIds); // { "@user:host": { "DEVICE": {...}, ... }, ... } return Object.values(usersDeviceMap).every((userDevices) => @@ -221,7 +275,7 @@ export async function canEncryptToAllUsers(client, userIds) { ); } -export async function ensureDMExists(client, userId) { +export async function ensureDMExists(client: MatrixClient, userId: string): Promise { const existingDMRoom = findDMForUser(client, userId); let roomId; if (existingDMRoom) { diff --git a/src/groups.js b/src/groups.js index 860cf71fff..e73af15c79 100644 --- a/src/groups.js +++ b/src/groups.js @@ -15,7 +15,8 @@ limitations under the License. */ import PropTypes from 'prop-types'; -import { _t } from './languageHandler.js'; + +import { _t } from './languageHandler'; export const GroupMemberType = PropTypes.shape({ userId: PropTypes.string.isRequired, diff --git a/src/languageHandler.js b/src/languageHandler.tsx similarity index 87% rename from src/languageHandler.js rename to src/languageHandler.tsx index 79a172015a..91d90d4e6c 100644 --- a/src/languageHandler.js +++ b/src/languageHandler.tsx @@ -1,7 +1,7 @@ /* Copyright 2017 MTRNord and Cooperative EITA Copyright 2017 Vector Creations Ltd. -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Licensed under the Apache License, Version 2.0 (the "License"); @@ -20,10 +20,11 @@ limitations under the License. import request from 'browser-request'; import counterpart from 'counterpart'; import React from 'react'; + import SettingsStore, {SettingLevel} from "./settings/SettingsStore"; import PlatformPeg from "./PlatformPeg"; -// $webapp is a webpack resolve alias pointing to the output directory, see webpack config +// @ts-ignore - $webapp is a webpack resolve alias pointing to the output directory, see webpack config import webpackLangJsonUrl from "$webapp/i18n/languages.json"; const i18nFolder = 'i18n/'; @@ -37,27 +38,31 @@ counterpart.setSeparator('|'); // Fall back to English counterpart.setFallbackLocale('en'); +interface ITranslatableError extends Error { + translatedMessage: string; +} + /** * Helper function to create an error which has an English message * with a translatedMessage property for use by the consumer. * @param {string} message Message to translate. * @returns {Error} The constructed error. */ -export function newTranslatableError(message) { - const error = new Error(message); +export function newTranslatableError(message: string) { + const error = new Error(message) as ITranslatableError; error.translatedMessage = _t(message); return error; } // Function which only purpose is to mark that a string is translatable // Does not actually do anything. It's helpful for automatic extraction of translatable strings -export function _td(s) { +export function _td(s: string): string { return s; } // Wrapper for counterpart's translation function so that it handles nulls and undefineds properly // Takes the same arguments as counterpart.translate() -function safeCounterpartTranslate(text, options) { +function safeCounterpartTranslate(text: string, options?: object) { // Horrible hack to avoid https://github.com/vector-im/riot-web/issues/4191 // The interpolation library that counterpart uses does not support undefined/null // values and instead will throw an error. This is a problem since everywhere else @@ -89,6 +94,13 @@ function safeCounterpartTranslate(text, options) { return translated; } +interface IVariables { + count?: number; + [key: string]: number | string; +} + +type Tags = Record React.ReactNode>; + /* * Translates text and optionally also replaces XML-ish elements in the text with e.g. React components * @param {string} text The untranslated text, e.g "click here now to %(foo)s". @@ -105,7 +117,9 @@ function safeCounterpartTranslate(text, options) { * * @return a React component if any non-strings were used in substitutions, otherwise a string */ -export function _t(text, variables, tags) { +export function _t(text: string, variables?: IVariables): string; +export function _t(text: string, variables: IVariables, tags: Tags): React.ReactNode; +export function _t(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode { // Don't do substitutions in counterpart. We handle it ourselves so we can replace with React components // However, still pass the variables to counterpart so that it can choose the correct plural if count is given // It is enough to pass the count variable, but in the future counterpart might make use of other information too @@ -141,23 +155,25 @@ export function _t(text, variables, tags) { * * @return a React component if any non-strings were used in substitutions, otherwise a string */ -export function substitute(text, variables, tags) { - let result = text; +export function substitute(text: string, variables?: IVariables): string; +export function substitute(text: string, variables: IVariables, tags: Tags): string; +export function substitute(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode { + let result: React.ReactNode | string = text; if (variables !== undefined) { - const regexpMapping = {}; + const regexpMapping: IVariables = {}; for (const variable in variables) { regexpMapping[`%\\(${variable}\\)s`] = variables[variable]; } - result = replaceByRegexes(result, regexpMapping); + result = replaceByRegexes(result as string, regexpMapping); } if (tags !== undefined) { - const regexpMapping = {}; + const regexpMapping: Tags = {}; for (const tag in tags) { regexpMapping[`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`] = tags[tag]; } - result = replaceByRegexes(result, regexpMapping); + result = replaceByRegexes(result as string, regexpMapping); } return result; @@ -172,7 +188,9 @@ export function substitute(text, variables, tags) { * * @return a React component if any non-strings were used in substitutions, otherwise a string */ -export function replaceByRegexes(text, mapping) { +export function replaceByRegexes(text: string, mapping: IVariables): string; +export function replaceByRegexes(text: string, mapping: Tags): React.ReactNode; +export function replaceByRegexes(text: string, mapping: IVariables | Tags): string | React.ReactNode { // We initially store our output as an array of strings and objects (e.g. React components). // This will then be converted to a string or a at the end const output = [text]; @@ -189,7 +207,7 @@ export function replaceByRegexes(text, mapping) { // and everything after the match. Insert all three into the output. We need to do this because we can insert objects. // Otherwise there would be no need for the splitting and we could do simple replacement. let matchFoundSomewhere = false; // If we don't find a match anywhere we want to log it - for (const outputIndex in output) { + for (let outputIndex = 0; outputIndex < output.length; outputIndex++) { const inputText = output[outputIndex]; if (typeof inputText !== 'string') { // We might have inserted objects earlier, don't try to replace them continue; @@ -216,7 +234,7 @@ export function replaceByRegexes(text, mapping) { let replaced; // If substitution is a function, call it if (mapping[regexpString] instanceof Function) { - replaced = mapping[regexpString].apply(null, capturedGroups); + replaced = (mapping as Tags)[regexpString].apply(null, capturedGroups); } else { replaced = mapping[regexpString]; } @@ -277,11 +295,11 @@ export function replaceByRegexes(text, mapping) { // Allow overriding the text displayed when no translation exists // Currently only used in unit tests to avoid having to load // the translations in riot-web -export function setMissingEntryGenerator(f) { +export function setMissingEntryGenerator(f: (value: string) => void) { counterpart.setMissingEntryGenerator(f); } -export function setLanguage(preferredLangs) { +export function setLanguage(preferredLangs: string | string[]) { if (!Array.isArray(preferredLangs)) { preferredLangs = [preferredLangs]; } @@ -358,8 +376,8 @@ export function getLanguageFromBrowser() { * @param {string} language The input language string * @return {string[]} List of normalised languages */ -export function getNormalizedLanguageKeys(language) { - const languageKeys = []; +export function getNormalizedLanguageKeys(language: string) { + const languageKeys: string[] = []; const normalizedLanguage = normalizeLanguageKey(language); const languageParts = normalizedLanguage.split('-'); if (languageParts.length === 2 && languageParts[0] === languageParts[1]) { @@ -380,7 +398,7 @@ export function getNormalizedLanguageKeys(language) { * @param {string} language The language string to be normalized * @returns {string} The normalized language string */ -export function normalizeLanguageKey(language) { +export function normalizeLanguageKey(language: string) { return language.toLowerCase().replace("_", "-"); } @@ -396,7 +414,7 @@ export function getCurrentLanguage() { * @param {string[]} langs List of language codes to pick from * @returns {string} The most appropriate language code from langs */ -export function pickBestLanguage(langs) { +export function pickBestLanguage(langs: string[]): string { const currentLang = getCurrentLanguage(); const normalisedLangs = langs.map(normalizeLanguageKey); @@ -408,13 +426,13 @@ export function pickBestLanguage(langs) { { // Failing that, a different dialect of the same language - const closeLangIndex = normalisedLangs.find((l) => l.substr(0, 2) === currentLang.substr(0, 2)); + const closeLangIndex = normalisedLangs.findIndex((l) => l.substr(0, 2) === currentLang.substr(0, 2)); if (closeLangIndex > -1) return langs[closeLangIndex]; } { // Neither of those? Try an english variant. - const enIndex = normalisedLangs.find((l) => l.startsWith('en')); + const enIndex = normalisedLangs.findIndex((l) => l.startsWith('en')); if (enIndex > -1) return langs[enIndex]; } @@ -422,7 +440,7 @@ export function pickBestLanguage(langs) { return langs[0]; } -function getLangsJson() { +function getLangsJson(): Promise { return new Promise(async (resolve, reject) => { let url; if (typeof(webpackLangJsonUrl) === 'string') { // in Jest this 'url' isn't a URL, so just fall through @@ -443,7 +461,7 @@ function getLangsJson() { }); } -function weblateToCounterpart(inTrs) { +function weblateToCounterpart(inTrs: object): object { const outTrs = {}; for (const key of Object.keys(inTrs)) { @@ -463,7 +481,7 @@ function weblateToCounterpart(inTrs) { return outTrs; } -function getLanguage(langPath) { +function getLanguage(langPath: string): object { return new Promise((resolve, reject) => { request( { method: "GET", url: langPath }, diff --git a/src/utils/promise.ts b/src/utils/promise.ts index c5c1cb9a56..d3ae2c3d1b 100644 --- a/src/utils/promise.ts +++ b/src/utils/promise.ts @@ -15,7 +15,7 @@ limitations under the License. */ // Returns a promise which resolves with a given value after the given number of ms -export function sleep(ms: number, value: T): Promise { +export function sleep(ms: number, value?: T): Promise { return new Promise((resolve => { setTimeout(resolve, ms, value); })); } diff --git a/yarn.lock b/yarn.lock index 98b42a0b29..d8106febab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1257,6 +1257,11 @@ resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.10.tgz#cc658ca319b6355399efc1f5b9e818f1a24bf999" integrity sha512-1UzDldn9GfYYEsWWnn/P4wkTlkZDH7lDb0wBMGbtIQc9zXEQq7FlKBdZUn6OBqD8sKZZ2RQO2mAjGpXiDGoRmQ== +"@types/counterpart@^0.18.1": + version "0.18.1" + resolved "https://registry.yarnpkg.com/@types/counterpart/-/counterpart-0.18.1.tgz#b1b784d9e54d9879f0a8cb12f2caedab65430fe8" + integrity sha512-PRuFlBBkvdDOtxlIASzTmkEFar+S66Ek48NVVTWMUjtJAdn5vyMSN8y6IZIoIymGpR36q2nZbIYazBWyFxL+IQ== + "@types/fbemitter@*": version "2.0.32" resolved "https://registry.yarnpkg.com/@types/fbemitter/-/fbemitter-2.0.32.tgz#8ed204da0f54e9c8eaec31b1eec91e25132d082c"