diff --git a/src/domain/LogoutViewModel.js b/src/domain/LogoutViewModel.js new file mode 100644 index 00000000..f22637de --- /dev/null +++ b/src/domain/LogoutViewModel.js @@ -0,0 +1,63 @@ +/* +Copyright 2021 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 {ViewModel} from "./ViewModel.js"; +import {Client} from "../matrix/Client.js"; + +export class LogoutViewModel extends ViewModel { + constructor(options) { + super(options); + this._sessionId = options.sessionId; + this._busy = false; + this._showConfirm = true; + this._error = undefined; + } + + get showConfirm() { + return this._showConfirm; + } + + get busy() { + return this._busy; + } + + get cancelUrl() { + return this.urlCreator.urlForSegment("session", true); + } + + async logout() { + this._busy = true; + this._showConfirm = false; + this.emitChange("busy"); + try { + const client = new Client(this.platform); + await client.startLogout(this._sessionId); + this.navigation.push("session", true); + } catch (err) { + this._error = err; + this._busy = false; + this.emitChange("busy"); + } + } + + get status() { + if (this._error) { + return this.i18n`Could not log out of device: ${this._error.message}`; + } else { + return this.i18n`Logging out… Please don't close the app.`; + } + } +} diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index b754f0ce..70f5b554 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -18,6 +18,7 @@ import {Client} from "../matrix/Client.js"; import {SessionViewModel} from "./session/SessionViewModel.js"; import {SessionLoadViewModel} from "./SessionLoadViewModel.js"; import {LoginViewModel} from "./login/LoginViewModel.js"; +import {LogoutViewModel} from "./LogoutViewModel.js"; import {SessionPickerViewModel} from "./SessionPickerViewModel.js"; import {ViewModel} from "./ViewModel.js"; @@ -28,6 +29,7 @@ export class RootViewModel extends ViewModel { this._sessionPickerViewModel = null; this._sessionLoadViewModel = null; this._loginViewModel = null; + this._logoutViewModel = null; this._sessionViewModel = null; this._pendingClient = null; } @@ -40,13 +42,18 @@ export class RootViewModel extends ViewModel { } async _applyNavigation(shouldRestoreLastUrl) { - const isLogin = this.navigation.path.get("login") + const isLogin = this.navigation.path.get("login"); + const logoutSessionId = this.navigation.path.get("logout")?.value; const sessionId = this.navigation.path.get("session")?.value; const loginToken = this.navigation.path.get("sso")?.value; if (isLogin) { if (this.activeSection !== "login") { this._showLogin(); } + } else if (logoutSessionId) { + if (this.activeSection !== "logout") { + this._showLogout(logoutSessionId); + } } else if (sessionId === true) { if (this.activeSection !== "picker") { this._showPicker(); @@ -123,6 +130,12 @@ export class RootViewModel extends ViewModel { }); } + _showLogout(sessionId) { + this._setSection(() => { + this._logoutViewModel = new LogoutViewModel(this.childOptions({sessionId})); + }); + } + _showSession(client) { this._setSection(() => { this._sessionViewModel = new SessionViewModel(this.childOptions({client})); @@ -149,6 +162,8 @@ export class RootViewModel extends ViewModel { return "session"; } else if (this._loginViewModel) { return "login"; + } else if (this._logoutViewModel) { + return "logout"; } else if (this._sessionPickerViewModel) { return "picker"; } else if (this._sessionLoadViewModel) { @@ -164,12 +179,14 @@ export class RootViewModel extends ViewModel { this._sessionPickerViewModel = this.disposeTracked(this._sessionPickerViewModel); this._sessionLoadViewModel = this.disposeTracked(this._sessionLoadViewModel); this._loginViewModel = this.disposeTracked(this._loginViewModel); + this._logoutViewModel = this.disposeTracked(this._logoutViewModel); this._sessionViewModel = this.disposeTracked(this._sessionViewModel); // now set it again setter(); this._sessionPickerViewModel && this.track(this._sessionPickerViewModel); this._sessionLoadViewModel && this.track(this._sessionLoadViewModel); this._loginViewModel && this.track(this._loginViewModel); + this._logoutViewModel && this.track(this._logoutViewModel); this._sessionViewModel && this.track(this._sessionViewModel); this.emitChange("activeSection"); } @@ -177,6 +194,7 @@ export class RootViewModel extends ViewModel { get error() { return this._error; } get sessionViewModel() { return this._sessionViewModel; } get loginViewModel() { return this._loginViewModel; } + get logoutViewModel() { return this._logoutViewModel; } get sessionPickerViewModel() { return this._sessionPickerViewModel; } get sessionLoadViewModel() { return this._sessionLoadViewModel; } } diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index ffc5ee9b..e209f2e5 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -30,7 +30,7 @@ function allowsChild(parent, child) { switch (parent?.type) { case undefined: // allowed root segments - return type === "login" || type === "session" || type === "sso"; + return type === "login" || type === "session" || type === "sso" || type === "logout"; case "session": return type === "room" || type === "rooms" || type === "settings"; case "rooms": diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index 979409b3..70e507b8 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -50,7 +50,6 @@ export class SettingsViewModel extends ViewModel { this.minSentImageSizeLimit = 400; this.maxSentImageSizeLimit = 4000; this.pushNotifications = new PushNotificationStatus(); - this._isLoggingOut = false; } get _session() { @@ -58,14 +57,9 @@ export class SettingsViewModel extends ViewModel { } async logout() { - this._isLoggingOut = true; - await this._client.logout(); - this.emitChange("isLoggingOut"); - this.navigation.push("session", true); + this.navigation.push("logout", this._client.sessionId); } - get isLoggingOut() { return this._isLoggingOut; } - setSentImageSizeLimit(size) { if (size > this.maxSentImageSizeLimit || size < this.minSentImageSizeLimit) { this.sentImageSizeLimit = null; diff --git a/src/matrix/Client.js b/src/matrix/Client.js index 5f6d5ea1..f070d24c 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -386,10 +386,21 @@ export class Client { return !this._reconnector; } - logout() { + startLogout(sessionId) { return this._platform.logger.run("logout", async log => { + this._sessionId = sessionId; + log.set("id", this._sessionId); + const sessionInfo = await this._platform.sessionInfoStorage.get(this._sessionId); + if (!sessionInfo) { + throw new Error(`Could not find session for id ${this._sessionId}`); + } try { - await this._session?.logout(log); + const hsApi = new HomeServerApi({ + homeserver: sessionInfo.homeServer, + accessToken: sessionInfo.accessToken, + request: this._platform.request + }); + await hsApi.logout({log}).response(); } catch (err) {} await this.deleteSession(log); }); diff --git a/src/matrix/Session.js b/src/matrix/Session.js index ddb3fdb5..8ccc71cc 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -109,11 +109,6 @@ export class Session { return this._sessionInfo.userId; } - /** @internal call Client.logout instead */ - async logout(log = undefined) { - await this._hsApi.logout({log}).response(); - } - // called once this._e2eeAccount is assigned _setupEncryption() { // TODO: this should all go in a wrapper in e2ee/ that is bootstrapped by passing in the account diff --git a/src/platform/web/ui/LogoutView.js b/src/platform/web/ui/LogoutView.js new file mode 100644 index 00000000..9a5201ef --- /dev/null +++ b/src/platform/web/ui/LogoutView.js @@ -0,0 +1,53 @@ +/* +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. +*/ + +import {TemplateView, InlineTemplateView} from "./general/TemplateView"; +import {spinner} from "./common.js"; + +export class LogoutView extends TemplateView { + render(t, vm) { + const confirmView = new InlineTemplateView(vm, t => { + return t.div([ + t.p("Are you sure you want to log out?"), + t.div({ className: "button-row" }, [ + t.a({ + className: "button-action", + type: "submit", + href: vm.cancelUrl, + }, ["Cancel"]), + t.button({ + className: "button-action primary destructive", + type: "submit", + onClick: () => vm.logout(), + }, vm.i18n`Log out`) + ]), + ]); + }); + const progressView = new InlineTemplateView(vm, t => { + return t.p({className: "status", hidden: vm => !vm.showStatus}, [ + spinner(t, {hidden: vm => !vm.busy}), t.span(vm => vm.status) + ]); + }); + + return t.div({className: "LogoutScreen"}, [ + t.div({className: "content"}, [ + t.mapView(vm => vm.showConfirm, showConfirm => { + return showConfirm ? confirmView : progressView; + }) + ]), + ]); + } +} diff --git a/src/platform/web/ui/RootView.js b/src/platform/web/ui/RootView.js index a44100a8..69b327a5 100644 --- a/src/platform/web/ui/RootView.js +++ b/src/platform/web/ui/RootView.js @@ -15,7 +15,8 @@ limitations under the License. */ import {SessionView} from "./session/SessionView.js"; -import {LoginView} from "./login/LoginView.js"; +import {LoginView} from "./login/LoginView"; +import {LogoutView} from "./LogoutView.js"; import {SessionLoadView} from "./login/SessionLoadView.js"; import {SessionPickerView} from "./login/SessionPickerView.js"; import {TemplateView} from "./general/TemplateView"; @@ -36,6 +37,8 @@ export class RootView extends TemplateView { return new SessionView(vm.sessionViewModel); case "login": return new LoginView(vm.loginViewModel); + case "logout": + return new LogoutView(vm.logoutViewModel); case "picker": return new SessionPickerView(vm.sessionPickerViewModel); case "redirecting": diff --git a/src/platform/web/ui/common.js b/src/platform/web/ui/common.js index 15a522c7..5551ed09 100644 --- a/src/platform/web/ui/common.js +++ b/src/platform/web/ui/common.js @@ -20,15 +20,16 @@ export function spinner(t, extraClasses = undefined) { if (container === undefined) { container = document.querySelector(".hydrogen"); } + const classes = Object.assign({"spinner": true}, extraClasses); if (container?.classList.contains("legacy")) { - return t.div({className: "spinner"}, [ + return t.div({className: classes}, [ t.div(), t.div(), t.div(), t.div(), ]); } else { - return t.svg({className: Object.assign({"spinner": true}, extraClasses), viewBox:"0 0 100 100"}, + return t.svg({className: classes, viewBox:"0 0 100 100"}, t.circle({cx:"50%", cy:"50%", r:"45%", pathLength:"100"}) ); } diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 32e048ec..5ed8b4c9 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -1058,3 +1058,20 @@ button.RoomDetailsView_row::after { .LazyListParent { overflow-y: auto; } + +.LogoutScreen { + height: 100vh; +} + +.LogoutScreen .content { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.LogoutScreen .status { + display: flex; + gap: 12px; +} diff --git a/src/platform/web/ui/general/TemplateView.ts b/src/platform/web/ui/general/TemplateView.ts index 93dc8172..ce593f75 100644 --- a/src/platform/web/ui/general/TemplateView.ts +++ b/src/platform/web/ui/general/TemplateView.ts @@ -29,7 +29,6 @@ function objHasFns(obj: ClassNames): obj is { [className: string]: bool return false; } - export type RenderFn = (t: Builder, vm: T) => ViewNode; type EventHandler = ((event: Event) => void); type AttributeStaticValue = string | boolean; @@ -52,20 +51,13 @@ export type Builder = TemplateBuilder & { [tagName in typeof TAG_NAMES[str - add subviews inside the template */ // TODO: should we rename this to BoundView or something? As opposed to StaticView ... -export class TemplateView extends BaseUpdateView { - private _render?: RenderFn; +export abstract class TemplateView extends BaseUpdateView { private _eventListeners?: { node: Element, name: string, fn: EventHandler, useCapture: boolean }[] = undefined; private _bindings?: (() => void)[] = undefined; private _root?: ViewNode = undefined; // public because used by TemplateBuilder _subViews?: IView[] = undefined; - constructor(value: T, render?: RenderFn) { - super(value); - // TODO: can avoid this if we have a separate class for inline templates vs class template views - this._render = render; - } - _attach(): void { if (this._eventListeners) { for (let {node, name, fn, useCapture} of this._eventListeners) { @@ -82,16 +74,12 @@ export class TemplateView extends BaseUpdateView } } + abstract render(t: Builder, value: T): ViewNode; + mount(options?: IMountArgs): ViewNode { const builder = new TemplateBuilder(this) as Builder; try { - if (this._render) { - this._root = this._render(builder, this._value); - } else if (this["render"]) { // overriden in subclass - this._root = this["render"](builder, this._value); - } else { - throw new Error("no render function passed in, or overriden in subclass"); - } + this._root = this.render(builder, this._value); } finally { builder.close(); } @@ -344,7 +332,7 @@ export class TemplateBuilder { // on mappedValue, use `if` or `mapView` map(mapFn: (value: T) => R, renderFn: (mapped: R, t: Builder, vm: T) => ViewNode): ViewNode { return this.mapView(mapFn, mappedValue => { - return new TemplateView(this._value, (t, vm) => { + return new InlineTemplateView(this._value, (t, vm) => { const rootNode = renderFn(mappedValue, t, vm); if (!rootNode) { // TODO: this will confuse mapView which assumes that @@ -366,7 +354,7 @@ export class TemplateBuilder { // creates a conditional subtemplate // use mapView if you need to map to a different view class if(predicate: (value: T) => boolean, renderFn: (t: Builder, vm: T) => ViewNode) { - return this.ifView(predicate, vm => new TemplateView(vm, renderFn)); + return this.ifView(predicate, vm => new InlineTemplateView(vm, renderFn)); } /** You probably are looking for something else, like map or mapView. @@ -398,3 +386,16 @@ for (const [ns, tags] of Object.entries(TAG_NAMES)) { }; } } + +export class InlineTemplateView extends TemplateView { + private _render: RenderFn; + + constructor(value: T, render: RenderFn) { + super(value); + this._render = render; + } + + override render(t: Builder, value: T): ViewNode { + return this._render(t, value); + } +} diff --git a/src/platform/web/ui/session/settings/SessionBackupSettingsView.js b/src/platform/web/ui/session/settings/SessionBackupSettingsView.js index e989f8ea..b8206c55 100644 --- a/src/platform/web/ui/session/settings/SessionBackupSettingsView.js +++ b/src/platform/web/ui/session/settings/SessionBackupSettingsView.js @@ -14,16 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView"; +import {TemplateView, InlineTemplateView} from "../../general/TemplateView"; import {StaticView} from "../../general/StaticView.js"; export class SessionBackupSettingsView extends TemplateView { render(t, vm) { return t.mapView(vm => vm.status, status => { switch (status) { - case "Enabled": return new TemplateView(vm, renderEnabled) - case "SetupKey": return new TemplateView(vm, renderEnableFromKey) - case "SetupPhrase": return new TemplateView(vm, renderEnableFromPhrase) + case "Enabled": return new InlineTemplateView(vm, renderEnabled) + case "SetupKey": return new InlineTemplateView(vm, renderEnableFromKey) + case "SetupPhrase": return new InlineTemplateView(vm, renderEnableFromPhrase) case "Pending": return new StaticView(vm, t => t.p(vm.i18n`Waiting to go online…`)) } }); diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index e969ba40..78ca2007 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -42,11 +42,7 @@ export class SettingsView extends TemplateView { row(t, vm.i18n`Session ID`, vm.deviceId, "code"), row(t, vm.i18n`Session key`, vm.fingerprintKey, "code"), row(t, "", t.button({ - onClick: () => { - if (confirm(vm.i18n`Are you sure you want to log out?`)) { - vm.logout(); - } - }, + onClick: () => vm.logout(), disabled: vm => vm.isLoggingOut }, vm.i18n`Log out`)), );