diff --git a/.ts-eslintrc.js b/.ts-eslintrc.js index 1974e07b..cf1fc3bf 100644 --- a/.ts-eslintrc.js +++ b/.ts-eslintrc.js @@ -19,6 +19,7 @@ module.exports = { ], rules: { "@typescript-eslint/no-floating-promises": 2, - "@typescript-eslint/no-misused-promises": 2 + "@typescript-eslint/no-misused-promises": 2, + "semi": ["error", "always"] } }; diff --git a/src/domain/LogoutViewModel.ts b/src/domain/LogoutViewModel.ts index 3edfcad5..b0409edd 100644 --- a/src/domain/LogoutViewModel.ts +++ b/src/domain/LogoutViewModel.ts @@ -14,18 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {Options, ViewModel} from "./ViewModel"; +import {Options as BaseOptions, ViewModel} from "./ViewModel"; import {Client} from "../matrix/Client.js"; -type LogoutOptions = { sessionId: string; } & Options; +type Options = { sessionId: string; } & BaseOptions; -export class LogoutViewModel extends ViewModel { +export class LogoutViewModel extends ViewModel { private _sessionId: string; private _busy: boolean; private _showConfirm: boolean; private _error?: Error; - constructor(options: LogoutOptions) { + constructor(options: Options) { super(options); this._sessionId = options.sessionId; this._busy = false; diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index 2711cd2f..4094d864 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -17,7 +17,7 @@ limitations under the License. import {Client} from "../matrix/Client.js"; import {SessionViewModel} from "./session/SessionViewModel.js"; import {SessionLoadViewModel} from "./SessionLoadViewModel.js"; -import {LoginViewModel} from "./login/LoginViewModel.js"; +import {LoginViewModel} from "./login/LoginViewModel"; import {LogoutViewModel} from "./LogoutViewModel"; import {SessionPickerViewModel} from "./SessionPickerViewModel.js"; import {ViewModel} from "./ViewModel"; @@ -118,7 +118,7 @@ export class RootViewModel extends ViewModel { // but we also want the change of screen to go through the navigation // so we store the session container in a temporary variable that will be // consumed by _applyNavigation, triggered by the navigation change - // + // // Also, we should not call _setSection before the navigation is in the correct state, // as url creation (e.g. in RoomTileViewModel) // won't be using the correct navigation base path. diff --git a/src/domain/ViewModel.ts b/src/domain/ViewModel.ts index 0bc52f6e..00ae847b 100644 --- a/src/domain/ViewModel.ts +++ b/src/domain/ViewModel.ts @@ -58,11 +58,11 @@ export class ViewModel extends EventEmitter<{change return this._options[name]; } - observeNavigation(type: string, onChange: (value: string | true | undefined, type: string) => void) { + observeNavigation(type: string, onChange: (value: string | true | undefined, type: string) => void): void { const segmentObservable = this.navigation.observe(type); const unsubscribe = segmentObservable.subscribe((value: string | true | undefined) => { onChange(value, type); - }) + }); this.track(unsubscribe); } @@ -100,10 +100,10 @@ export class ViewModel extends EventEmitter<{change // TODO: this will need to support binding // if any of the expr is a function, assume the function is a binding, and return a binding function ourselves - // + // // translated string should probably always be bindings, unless we're fine with a refresh when changing the language? // we probably are, if we're using routing with a url, we could just refresh. - i18n(parts: TemplateStringsArray, ...expr: any[]) { + i18n(parts: TemplateStringsArray, ...expr: any[]): string { // just concat for now let result = ""; for (let i = 0; i < parts.length; ++i) { diff --git a/src/domain/login/LoginViewModel.js b/src/domain/login/LoginViewModel.ts similarity index 66% rename from src/domain/login/LoginViewModel.js rename to src/domain/login/LoginViewModel.ts index bf77e624..aaeca54f 100644 --- a/src/domain/login/LoginViewModel.js +++ b/src/domain/login/LoginViewModel.ts @@ -15,101 +15,143 @@ limitations under the License. */ import {Client} from "../../matrix/Client.js"; -import {ViewModel} from "../ViewModel"; +import {Options as BaseOptions, ViewModel} from "../ViewModel"; import {PasswordLoginViewModel} from "./PasswordLoginViewModel.js"; import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js"; import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js"; import {LoadStatus} from "../../matrix/Client.js"; import {SessionLoadViewModel} from "../SessionLoadViewModel.js"; +import type {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod} from "../../matrix/login"; -export class LoginViewModel extends ViewModel { - constructor(options) { +type Options = { + defaultHomeserver: string; + ready: ReadyFn; + loginToken?: string; +} & BaseOptions; + +export class LoginViewModel extends ViewModel { + private _ready: ReadyFn; + private _loginToken?: string; + private _client: Client; + private _loginOptions?: LoginOptions; + private _passwordLoginViewModel?: PasswordLoginViewModel; + private _startSSOLoginViewModel?: StartSSOLoginViewModel; + private _completeSSOLoginViewModel?: CompleteSSOLoginViewModel; + private _loadViewModel?: SessionLoadViewModel; + private _loadViewModelSubscription?: () => void; + private _homeserver: string; + private _queriedHomeserver?: string; + private _abortHomeserverQueryTimeout?: () => void; + private _abortQueryOperation?: () => void; + + private _hideHomeserver: boolean = false; + private _isBusy: boolean = false; + private _errorMessage: string = ""; + + constructor(options: Readonly) { super(options); const {ready, defaultHomeserver, loginToken} = options; this._ready = ready; this._loginToken = loginToken; this._client = new Client(this.platform); - this._loginOptions = null; - this._passwordLoginViewModel = null; - this._startSSOLoginViewModel = null; - this._completeSSOLoginViewModel = null; - this._loadViewModel = null; - this._loadViewModelSubscription = null; this._homeserver = defaultHomeserver; - this._queriedHomeserver = null; - this._errorMessage = ""; - this._hideHomeserver = false; - this._isBusy = false; - this._abortHomeserverQueryTimeout = null; - this._abortQueryOperation = null; this._initViewModels(); } - get passwordLoginViewModel() { return this._passwordLoginViewModel; } - get startSSOLoginViewModel() { return this._startSSOLoginViewModel; } - get completeSSOLoginViewModel(){ return this._completeSSOLoginViewModel; } - get homeserver() { return this._homeserver; } - get resolvedHomeserver() { return this._loginOptions?.homeserver; } - get errorMessage() { return this._errorMessage; } - get showHomeserver() { return !this._hideHomeserver; } - get loadViewModel() {return this._loadViewModel; } - get isBusy() { return this._isBusy; } - get isFetchingLoginOptions() { return !!this._abortQueryOperation; } + get passwordLoginViewModel(): PasswordLoginViewModel { + return this._passwordLoginViewModel; + } - goBack() { + get startSSOLoginViewModel(): StartSSOLoginViewModel { + return this._startSSOLoginViewModel; + } + + get completeSSOLoginViewModel(): CompleteSSOLoginViewModel { + return this._completeSSOLoginViewModel; + } + + get homeserver(): string { + return this._homeserver; + } + + get resolvedHomeserver(): string | undefined { + return this._loginOptions?.homeserver; + } + + get errorMessage(): string { + return this._errorMessage; + } + + get showHomeserver(): boolean { + return !this._hideHomeserver; + } + + get loadViewModel(): SessionLoadViewModel { + return this._loadViewModel; + } + + get isBusy(): boolean { + return this._isBusy; + } + + get isFetchingLoginOptions(): boolean { + return !!this._abortQueryOperation; + } + + goBack(): void { this.navigation.push("session"); } - async _initViewModels() { + private _initViewModels(): void { if (this._loginToken) { this._hideHomeserver = true; this._completeSSOLoginViewModel = this.track(new CompleteSSOLoginViewModel( this.childOptions( { client: this._client, - attemptLogin: loginMethod => this.attemptLogin(loginMethod), + attemptLogin: (loginMethod: TokenLoginMethod) => this.attemptLogin(loginMethod), loginToken: this._loginToken }))); this.emitChange("completeSSOLoginViewModel"); } else { - await this.queryHomeserver(); + void this.queryHomeserver(); } } - _showPasswordLogin() { + private _showPasswordLogin(): void { this._passwordLoginViewModel = this.track(new PasswordLoginViewModel( this.childOptions({ loginOptions: this._loginOptions, - attemptLogin: loginMethod => this.attemptLogin(loginMethod) + attemptLogin: (loginMethod: PasswordLoginMethod) => this.attemptLogin(loginMethod) }))); this.emitChange("passwordLoginViewModel"); } - _showSSOLogin() { + private _showSSOLogin(): void { this._startSSOLoginViewModel = this.track( new StartSSOLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) ); this.emitChange("startSSOLoginViewModel"); } - _showError(message) { + private _showError(message: string): void { this._errorMessage = message; this.emitChange("errorMessage"); } - _setBusy(status) { + private _setBusy(status: boolean): void { this._isBusy = status; this._passwordLoginViewModel?.setBusy(status); this._startSSOLoginViewModel?.setBusy(status); this.emitChange("isBusy"); } - async attemptLogin(loginMethod) { + async attemptLogin(loginMethod: ILoginMethod): Promise { this._setBusy(true); - this._client.startWithLogin(loginMethod, {inspectAccountSetup: true}); + void this._client.startWithLogin(loginMethod, {inspectAccountSetup: true}); const loadStatus = this._client.loadStatus; - const handle = loadStatus.waitFor(status => status !== LoadStatus.Login); + const handle = loadStatus.waitFor((status: LoadStatus) => status !== LoadStatus.Login); await handle.promise; this._setBusy(false); const status = loadStatus.get(); @@ -119,11 +161,11 @@ export class LoginViewModel extends ViewModel { this._hideHomeserver = true; this.emitChange("hideHomeserver"); this._disposeViewModels(); - this._createLoadViewModel(); + void this._createLoadViewModel(); return null; } - _createLoadViewModel() { + private _createLoadViewModel(): void { this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription); this._loadViewModel = this.disposeTracked(this._loadViewModel); this._loadViewModel = this.track( @@ -139,7 +181,7 @@ export class LoginViewModel extends ViewModel { }) ) ); - this._loadViewModel.start(); + void this._loadViewModel.start(); this.emitChange("loadViewModel"); this._loadViewModelSubscription = this.track( this._loadViewModel.disposableOn("change", () => { @@ -151,22 +193,22 @@ export class LoginViewModel extends ViewModel { ); } - _disposeViewModels() { - this._startSSOLoginViewModel = this.disposeTracked(this._ssoLoginViewModel); + private _disposeViewModels(): void { + this._startSSOLoginViewModel = this.disposeTracked(this._startSSOLoginViewModel); this._passwordLoginViewModel = this.disposeTracked(this._passwordLoginViewModel); this._completeSSOLoginViewModel = this.disposeTracked(this._completeSSOLoginViewModel); this.emitChange("disposeViewModels"); } - async setHomeserver(newHomeserver) { + async setHomeserver(newHomeserver: string): Promise { this._homeserver = newHomeserver; // clear everything set by queryHomeserver - this._loginOptions = null; - this._queriedHomeserver = null; + this._loginOptions = undefined; + this._queriedHomeserver = undefined; this._showError(""); this._disposeViewModels(); this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation); - this.emitChange(); // multiple fields changing + this.emitChange("loginViewModels"); // multiple fields changing // also clear the timeout if it is still running this.disposeTracked(this._abortHomeserverQueryTimeout); const timeout = this.clock.createTimeout(1000); @@ -181,10 +223,10 @@ export class LoginViewModel extends ViewModel { } } this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout); - this.queryHomeserver(); + void this.queryHomeserver(); } - - async queryHomeserver() { + + async queryHomeserver(): Promise { // don't repeat a query we've just done if (this._homeserver === this._queriedHomeserver || this._homeserver === "") { return; @@ -210,7 +252,7 @@ export class LoginViewModel extends ViewModel { if (e.name === "AbortError") { return; //aborted, bail out } else { - this._loginOptions = null; + this._loginOptions = undefined; } } finally { this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation); @@ -221,19 +263,29 @@ export class LoginViewModel extends ViewModel { if (this._loginOptions.password) { this._showPasswordLogin(); } if (!this._loginOptions.sso && !this._loginOptions.password) { this._showError("This homeserver supports neither SSO nor password based login flows"); - } + } } else { this._showError(`Could not query login methods supported by ${this.homeserver}`); } } - dispose() { + dispose(): void { super.dispose(); if (this._client) { // if we move away before we're done with initial sync // delete the session - this._client.deleteSession(); + void this._client.deleteSession(); } } } + +type ReadyFn = (client: Client) => void; + +// TODO: move to Client.js when its converted to typescript. +type LoginOptions = { + homeserver: string; + password?: (username: string, password: string) => PasswordLoginMethod; + sso?: SSOLoginHelper; + token?: (loginToken: string) => TokenLoginMethod; +}; diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 94c78286..fcda95fc 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -23,6 +23,7 @@ import {imageToInfo} from "../common.js"; // TODO: remove fallback so default isn't included in bundle for SDK users that have their custom tileClassForEntry // this is a breaking SDK change though to make this option mandatory import {tileClassForEntry as defaultTileClassForEntry} from "./timeline/tiles/index"; +import {RoomStatus} from "../../../matrix/room/common"; export class RoomViewModel extends ViewModel { constructor(options) { @@ -197,18 +198,89 @@ export class RoomViewModel extends ViewModel { } } + async _processCommandJoin(roomName) { + try { + const roomId = await this._options.client.session.joinRoom(roomName); + const roomStatusObserver = await this._options.client.session.observeRoomStatus(roomId); + await roomStatusObserver.waitFor(status => status === RoomStatus.Joined); + this.navigation.push("room", roomId); + } catch (err) { + let exc; + if ((err.statusCode ?? err.status) === 400) { + exc = new Error(`/join : '${roomName}' was not legal room ID or room alias`); + } else if ((err.statusCode ?? err.status) === 404 || (err.statusCode ?? err.status) === 502 || err.message == "Internal Server Error") { + exc = new Error(`/join : room '${roomName}' not found`); + } else if ((err.statusCode ?? err.status) === 403) { + exc = new Error(`/join : you're not invited to join '${roomName}'`); + } else { + exc = err; + } + this._sendError = exc; + this._timelineError = null; + this.emitChange("error"); + } + } + + async _processCommand (message) { + let msgtype; + const [commandName, ...args] = message.substring(1).split(" "); + switch (commandName) { + case "me": + message = args.join(" "); + msgtype = "m.emote"; + break; + case "join": + if (args.length === 1) { + const roomName = args[0]; + await this._processCommandJoin(roomName); + } else { + this._sendError = new Error("join syntax: /join "); + this._timelineError = null; + this.emitChange("error"); + } + break; + case "shrug": + message = "¯\\_(ツ)_/¯ " + args.join(" "); + msgtype = "m.text"; + break; + case "tableflip": + message = "(╯°□°)╯︵ ┻━┻ " + args.join(" "); + msgtype = "m.text"; + break; + case "unflip": + message = "┬──┬ ノ( ゜-゜ノ) " + args.join(" "); + msgtype = "m.text"; + break; + case "lenny": + message = "( ͡° ͜ʖ ͡°) " + args.join(" "); + msgtype = "m.text"; + break; + default: + this._sendError = new Error(`no command name "${commandName}". To send the message instead of executing, please type "/${message}"`); + this._timelineError = null; + this.emitChange("error"); + message = undefined; + } + return {type: msgtype, message: message}; + } + async _sendMessage(message, replyingTo) { if (!this._room.isArchived && message) { + let messinfo = {type : "m.text", message : message}; + if (message.startsWith("//")) { + messinfo.message = message.substring(1).trim(); + } else if (message.startsWith("/")) { + messinfo = await this._processCommand(message); + } try { - let msgtype = "m.text"; - if (message.startsWith("/me ")) { - message = message.substr(4).trim(); - msgtype = "m.emote"; - } - if (replyingTo) { - await replyingTo.reply(msgtype, message); - } else { - await this._room.sendEvent("m.room.message", {msgtype, body: message}); + const msgtype = messinfo.type; + const message = messinfo.message; + if (msgtype && message) { + if (replyingTo) { + await replyingTo.reply(msgtype, message); + } else { + await this._room.sendEvent("m.room.message", {msgtype, body: message}); + } } } catch (err) { console.error(`room.sendMessage(): ${err.message}:\n${err.stack}`); @@ -353,6 +425,11 @@ export class RoomViewModel extends ViewModel { this._composerVM.setReplyingTo(entry); } } + + dismissError(evt) { + this._sendError = null; + this.emitChange("error"); + } } function videoToInfo(video) { diff --git a/src/matrix/Client.js b/src/matrix/Client.js index 21175a7f..44643cc1 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -100,6 +100,8 @@ export class Client { }); } + // TODO: When converted to typescript this should return the same type + // as this._loginOptions is in LoginViewModel.ts (LoginOptions). _parseLoginOptions(options, homeserver) { /* Take server response and return new object which has two props password and sso which @@ -136,7 +138,7 @@ export class Client { const request = this._platform.request; const hsApi = new HomeServerApi({homeserver, request}); const registration = new Registration(hsApi, { - username, + username, password, initialDeviceDisplayName, }, @@ -196,7 +198,7 @@ export class Client { sessionInfo.deviceId = dehydratedDevice.deviceId; } } - await this._platform.sessionInfoStorage.add(sessionInfo); + await this._platform.sessionInfoStorage.add(sessionInfo); // loading the session can only lead to // LoadStatus.Error in case of an error, // so separate try/catch @@ -266,7 +268,7 @@ export class Client { this._status.set(LoadStatus.SessionSetup); await log.wrap("createIdentity", log => this._session.createIdentity(log)); } - + this._sync = new Sync({hsApi: this._requestScheduler.hsApi, storage: this._storage, session: this._session, logger: this._platform.logger}); // notify sync and session when back online this._reconnectSubscription = this._reconnector.connectionStatus.subscribe(state => { @@ -311,7 +313,7 @@ export class Client { this._waitForFirstSyncHandle = this._sync.status.waitFor(s => { if (s === SyncStatus.Stopped) { // keep waiting if there is a ConnectionError - // as the reconnector above will call + // as the reconnector above will call // sync.start again to retry in this case return this._sync.error?.name !== "ConnectionError"; } diff --git a/src/matrix/login/index.ts b/src/matrix/login/index.ts new file mode 100644 index 00000000..ba133a26 --- /dev/null +++ b/src/matrix/login/index.ts @@ -0,0 +1,7 @@ +import {ILoginMethod} from "./LoginMethod"; +import {PasswordLoginMethod} from "./PasswordLoginMethod"; +import {SSOLoginHelper} from "./SSOLoginHelper"; +import {TokenLoginMethod} from "./TokenLoginMethod"; + + +export {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod}; \ No newline at end of file diff --git a/src/matrix/storage/idb/StorageFactory.ts b/src/matrix/storage/idb/StorageFactory.ts index 5cb1b6e5..1f64baf3 100644 --- a/src/matrix/storage/idb/StorageFactory.ts +++ b/src/matrix/storage/idb/StorageFactory.ts @@ -42,6 +42,7 @@ async function requestPersistedStorage(): Promise { await glob.document.requestStorageAccess(); return true; } catch (err) { + console.warn("requestStorageAccess threw an error:", err); return false; } } else { diff --git a/src/platform/types/config.ts b/src/platform/types/config.ts new file mode 100644 index 00000000..8a5eabf2 --- /dev/null +++ b/src/platform/types/config.ts @@ -0,0 +1,64 @@ +/* +Copyright 2022 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. +*/ + +export type Config = { + /** + * The default homeserver used by Hydrogen; auto filled in the login UI. + * eg: https://matrix.org + * REQUIRED + */ + defaultHomeServer: string; + /** + * The submit endpoint for your preferred rageshake server. + * eg: https://element.io/bugreports/submit + * Read more about rageshake at https://github.com/matrix-org/rageshake + * OPTIONAL + */ + bugReportEndpointUrl?: string; + /** + * Paths to theme-manifests + * eg: ["assets/theme-element.json", "assets/theme-awesome.json"] + * REQUIRED + */ + themeManifests: string[]; + /** + * This configures the default theme(s) used by Hydrogen. + * These themes appear as "Default" option in the theme chooser UI and are also + * used as a fallback when other themes fail to load. + * Whether the dark or light variant is used depends on the system preference. + * OPTIONAL + */ + defaultTheme?: { + // id of light theme + light: string; + // id of dark theme + dark: string; + }; + /** + * Configuration for push notifications. + * See https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3pushersset + * and https://github.com/matrix-org/sygnal/blob/main/docs/applications.md#webpush + * OPTIONAL + */ + push?: { + // See app_id in the request body in above link + appId: string; + // The host used for pushing notification + gatewayUrl: string; + // See pushkey in above link + applicationServerKey: string; + }; +}; diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 632d5bc5..05681cbb 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -521,6 +521,62 @@ a { .RoomView_error { color: var(--error-color); + background : #efefef; + height : 0px; + font-weight : bold; + transition : 0.25s all ease-out; + padding-right : 20px; + padding-left : 20px; +} + +.RoomView_error div{ + overflow : hidden; + height: 100%; + width: 100%; + position : relative; + display : flex; + align-items : center; +} + +.RoomView_error:not(:empty) { + height : auto; + padding-top : 20px; + padding-bottom : 20px; +} + +.RoomView_error p { + position : relative; + display : block; + width : 100%; + height : auto; + margin : 0; +} + +.RoomView_error button { + width : 40px; + padding-top : 20px; + padding-bottom : 20px; + background : none; + border : none; + position : relative; + border-radius : 5px; + transition: 0.1s all ease-out; + cursor: pointer; +} + +.RoomView_error button:hover { + background : #cfcfcf; +} + +.RoomView_error button:before { + content:"\274c"; + position : absolute; + top : 15px; + left: 9px; + width : 20px; + height : 10px; + font-size : 10px; + align-self : middle; } .MessageComposer_replyPreview .Timeline_message { diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index d36466dd..e3eb0587 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -46,7 +46,13 @@ export class RoomView extends TemplateView { }) ]), t.div({className: "RoomView_body"}, [ - t.div({className: "RoomView_error"}, vm => vm.error), + t.div({className: "RoomView_error"}, [ + t.if(vm => vm.error, t => t.div( + [ + t.p({}, vm => vm.error), + t.button({ className: "RoomView_error_closerButton", onClick: evt => vm.dismissError(evt) }) + ]) + )]), t.mapView(vm => vm.timelineViewModel, timelineViewModel => { return timelineViewModel ? new TimelineView(timelineViewModel, this._viewClassForTile) : @@ -64,7 +70,7 @@ export class RoomView extends TemplateView { ]) ]); } - + _toggleOptionsMenu(evt) { if (this._optionsPopup && this._optionsPopup.isOpen) { this._optionsPopup.close();