Merge pull request #643 from vector-im/bwindels/separate-logout-view

Show logout in separate view so it's clear something is happening
This commit is contained in:
Bruno Windels 2022-01-17 16:40:49 +01:00 committed by GitHub
commit 454d2d3666
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 198 additions and 46 deletions

View file

@ -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.`;
}
}
}

View file

@ -18,6 +18,7 @@ import {Client} from "../matrix/Client.js";
import {SessionViewModel} from "./session/SessionViewModel.js"; import {SessionViewModel} from "./session/SessionViewModel.js";
import {SessionLoadViewModel} from "./SessionLoadViewModel.js"; import {SessionLoadViewModel} from "./SessionLoadViewModel.js";
import {LoginViewModel} from "./login/LoginViewModel.js"; import {LoginViewModel} from "./login/LoginViewModel.js";
import {LogoutViewModel} from "./LogoutViewModel.js";
import {SessionPickerViewModel} from "./SessionPickerViewModel.js"; import {SessionPickerViewModel} from "./SessionPickerViewModel.js";
import {ViewModel} from "./ViewModel.js"; import {ViewModel} from "./ViewModel.js";
@ -28,6 +29,7 @@ export class RootViewModel extends ViewModel {
this._sessionPickerViewModel = null; this._sessionPickerViewModel = null;
this._sessionLoadViewModel = null; this._sessionLoadViewModel = null;
this._loginViewModel = null; this._loginViewModel = null;
this._logoutViewModel = null;
this._sessionViewModel = null; this._sessionViewModel = null;
this._pendingClient = null; this._pendingClient = null;
} }
@ -40,13 +42,18 @@ export class RootViewModel extends ViewModel {
} }
async _applyNavigation(shouldRestoreLastUrl) { 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 sessionId = this.navigation.path.get("session")?.value;
const loginToken = this.navigation.path.get("sso")?.value; const loginToken = this.navigation.path.get("sso")?.value;
if (isLogin) { if (isLogin) {
if (this.activeSection !== "login") { if (this.activeSection !== "login") {
this._showLogin(); this._showLogin();
} }
} else if (logoutSessionId) {
if (this.activeSection !== "logout") {
this._showLogout(logoutSessionId);
}
} else if (sessionId === true) { } else if (sessionId === true) {
if (this.activeSection !== "picker") { if (this.activeSection !== "picker") {
this._showPicker(); this._showPicker();
@ -123,6 +130,12 @@ export class RootViewModel extends ViewModel {
}); });
} }
_showLogout(sessionId) {
this._setSection(() => {
this._logoutViewModel = new LogoutViewModel(this.childOptions({sessionId}));
});
}
_showSession(client) { _showSession(client) {
this._setSection(() => { this._setSection(() => {
this._sessionViewModel = new SessionViewModel(this.childOptions({client})); this._sessionViewModel = new SessionViewModel(this.childOptions({client}));
@ -149,6 +162,8 @@ export class RootViewModel extends ViewModel {
return "session"; return "session";
} else if (this._loginViewModel) { } else if (this._loginViewModel) {
return "login"; return "login";
} else if (this._logoutViewModel) {
return "logout";
} else if (this._sessionPickerViewModel) { } else if (this._sessionPickerViewModel) {
return "picker"; return "picker";
} else if (this._sessionLoadViewModel) { } else if (this._sessionLoadViewModel) {
@ -164,12 +179,14 @@ export class RootViewModel extends ViewModel {
this._sessionPickerViewModel = this.disposeTracked(this._sessionPickerViewModel); this._sessionPickerViewModel = this.disposeTracked(this._sessionPickerViewModel);
this._sessionLoadViewModel = this.disposeTracked(this._sessionLoadViewModel); this._sessionLoadViewModel = this.disposeTracked(this._sessionLoadViewModel);
this._loginViewModel = this.disposeTracked(this._loginViewModel); this._loginViewModel = this.disposeTracked(this._loginViewModel);
this._logoutViewModel = this.disposeTracked(this._logoutViewModel);
this._sessionViewModel = this.disposeTracked(this._sessionViewModel); this._sessionViewModel = this.disposeTracked(this._sessionViewModel);
// now set it again // now set it again
setter(); setter();
this._sessionPickerViewModel && this.track(this._sessionPickerViewModel); this._sessionPickerViewModel && this.track(this._sessionPickerViewModel);
this._sessionLoadViewModel && this.track(this._sessionLoadViewModel); this._sessionLoadViewModel && this.track(this._sessionLoadViewModel);
this._loginViewModel && this.track(this._loginViewModel); this._loginViewModel && this.track(this._loginViewModel);
this._logoutViewModel && this.track(this._logoutViewModel);
this._sessionViewModel && this.track(this._sessionViewModel); this._sessionViewModel && this.track(this._sessionViewModel);
this.emitChange("activeSection"); this.emitChange("activeSection");
} }
@ -177,6 +194,7 @@ export class RootViewModel extends ViewModel {
get error() { return this._error; } get error() { return this._error; }
get sessionViewModel() { return this._sessionViewModel; } get sessionViewModel() { return this._sessionViewModel; }
get loginViewModel() { return this._loginViewModel; } get loginViewModel() { return this._loginViewModel; }
get logoutViewModel() { return this._logoutViewModel; }
get sessionPickerViewModel() { return this._sessionPickerViewModel; } get sessionPickerViewModel() { return this._sessionPickerViewModel; }
get sessionLoadViewModel() { return this._sessionLoadViewModel; } get sessionLoadViewModel() { return this._sessionLoadViewModel; }
} }

View file

@ -30,7 +30,7 @@ function allowsChild(parent, child) {
switch (parent?.type) { switch (parent?.type) {
case undefined: case undefined:
// allowed root segments // allowed root segments
return type === "login" || type === "session" || type === "sso"; return type === "login" || type === "session" || type === "sso" || type === "logout";
case "session": case "session":
return type === "room" || type === "rooms" || type === "settings"; return type === "room" || type === "rooms" || type === "settings";
case "rooms": case "rooms":

View file

@ -50,7 +50,6 @@ export class SettingsViewModel extends ViewModel {
this.minSentImageSizeLimit = 400; this.minSentImageSizeLimit = 400;
this.maxSentImageSizeLimit = 4000; this.maxSentImageSizeLimit = 4000;
this.pushNotifications = new PushNotificationStatus(); this.pushNotifications = new PushNotificationStatus();
this._isLoggingOut = false;
} }
get _session() { get _session() {
@ -58,14 +57,9 @@ export class SettingsViewModel extends ViewModel {
} }
async logout() { async logout() {
this._isLoggingOut = true; this.navigation.push("logout", this._client.sessionId);
await this._client.logout();
this.emitChange("isLoggingOut");
this.navigation.push("session", true);
} }
get isLoggingOut() { return this._isLoggingOut; }
setSentImageSizeLimit(size) { setSentImageSizeLimit(size) {
if (size > this.maxSentImageSizeLimit || size < this.minSentImageSizeLimit) { if (size > this.maxSentImageSizeLimit || size < this.minSentImageSizeLimit) {
this.sentImageSizeLimit = null; this.sentImageSizeLimit = null;

View file

@ -386,10 +386,21 @@ export class Client {
return !this._reconnector; return !this._reconnector;
} }
logout() { startLogout(sessionId) {
return this._platform.logger.run("logout", async log => { 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 { 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) {} } catch (err) {}
await this.deleteSession(log); await this.deleteSession(log);
}); });

View file

@ -109,11 +109,6 @@ export class Session {
return this._sessionInfo.userId; 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 // called once this._e2eeAccount is assigned
_setupEncryption() { _setupEncryption() {
// TODO: this should all go in a wrapper in e2ee/ that is bootstrapped by passing in the account // TODO: this should all go in a wrapper in e2ee/ that is bootstrapped by passing in the account

View file

@ -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;
})
]),
]);
}
}

View file

@ -15,7 +15,8 @@ limitations under the License.
*/ */
import {SessionView} from "./session/SessionView.js"; 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 {SessionLoadView} from "./login/SessionLoadView.js";
import {SessionPickerView} from "./login/SessionPickerView.js"; import {SessionPickerView} from "./login/SessionPickerView.js";
import {TemplateView} from "./general/TemplateView"; import {TemplateView} from "./general/TemplateView";
@ -36,6 +37,8 @@ export class RootView extends TemplateView {
return new SessionView(vm.sessionViewModel); return new SessionView(vm.sessionViewModel);
case "login": case "login":
return new LoginView(vm.loginViewModel); return new LoginView(vm.loginViewModel);
case "logout":
return new LogoutView(vm.logoutViewModel);
case "picker": case "picker":
return new SessionPickerView(vm.sessionPickerViewModel); return new SessionPickerView(vm.sessionPickerViewModel);
case "redirecting": case "redirecting":

View file

@ -20,15 +20,16 @@ export function spinner(t, extraClasses = undefined) {
if (container === undefined) { if (container === undefined) {
container = document.querySelector(".hydrogen"); container = document.querySelector(".hydrogen");
} }
const classes = Object.assign({"spinner": true}, extraClasses);
if (container?.classList.contains("legacy")) { if (container?.classList.contains("legacy")) {
return t.div({className: "spinner"}, [ return t.div({className: classes}, [
t.div(), t.div(),
t.div(), t.div(),
t.div(), t.div(),
t.div(), t.div(),
]); ]);
} else { } 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"}) t.circle({cx:"50%", cy:"50%", r:"45%", pathLength:"100"})
); );
} }

View file

@ -1058,3 +1058,20 @@ button.RoomDetailsView_row::after {
.LazyListParent { .LazyListParent {
overflow-y: auto; 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;
}

View file

@ -29,7 +29,6 @@ function objHasFns(obj: ClassNames<unknown>): obj is { [className: string]: bool
return false; return false;
} }
export type RenderFn<T> = (t: Builder<T>, vm: T) => ViewNode; export type RenderFn<T> = (t: Builder<T>, vm: T) => ViewNode;
type EventHandler = ((event: Event) => void); type EventHandler = ((event: Event) => void);
type AttributeStaticValue = string | boolean; type AttributeStaticValue = string | boolean;
@ -52,20 +51,13 @@ export type Builder<T> = TemplateBuilder<T> & { [tagName in typeof TAG_NAMES[str
- add subviews inside the template - add subviews inside the template
*/ */
// TODO: should we rename this to BoundView or something? As opposed to StaticView ... // TODO: should we rename this to BoundView or something? As opposed to StaticView ...
export class TemplateView<T extends IObservableValue> extends BaseUpdateView<T> { export abstract class TemplateView<T extends IObservableValue> extends BaseUpdateView<T> {
private _render?: RenderFn<T>;
private _eventListeners?: { node: Element, name: string, fn: EventHandler, useCapture: boolean }[] = undefined; private _eventListeners?: { node: Element, name: string, fn: EventHandler, useCapture: boolean }[] = undefined;
private _bindings?: (() => void)[] = undefined; private _bindings?: (() => void)[] = undefined;
private _root?: ViewNode = undefined; private _root?: ViewNode = undefined;
// public because used by TemplateBuilder // public because used by TemplateBuilder
_subViews?: IView[] = undefined; _subViews?: IView[] = undefined;
constructor(value: T, render?: RenderFn<T>) {
super(value);
// TODO: can avoid this if we have a separate class for inline templates vs class template views
this._render = render;
}
_attach(): void { _attach(): void {
if (this._eventListeners) { if (this._eventListeners) {
for (let {node, name, fn, useCapture} of this._eventListeners) { for (let {node, name, fn, useCapture} of this._eventListeners) {
@ -82,16 +74,12 @@ export class TemplateView<T extends IObservableValue> extends BaseUpdateView<T>
} }
} }
abstract render(t: Builder<T>, value: T): ViewNode;
mount(options?: IMountArgs): ViewNode { mount(options?: IMountArgs): ViewNode {
const builder = new TemplateBuilder(this) as Builder<T>; const builder = new TemplateBuilder(this) as Builder<T>;
try { try {
if (this._render) { this._root = this.render(builder, this._value);
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");
}
} finally { } finally {
builder.close(); builder.close();
} }
@ -344,7 +332,7 @@ export class TemplateBuilder<T extends IObservableValue> {
// on mappedValue, use `if` or `mapView` // on mappedValue, use `if` or `mapView`
map<R>(mapFn: (value: T) => R, renderFn: (mapped: R, t: Builder<T>, vm: T) => ViewNode): ViewNode { map<R>(mapFn: (value: T) => R, renderFn: (mapped: R, t: Builder<T>, vm: T) => ViewNode): ViewNode {
return this.mapView(mapFn, mappedValue => { 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); const rootNode = renderFn(mappedValue, t, vm);
if (!rootNode) { if (!rootNode) {
// TODO: this will confuse mapView which assumes that // TODO: this will confuse mapView which assumes that
@ -366,7 +354,7 @@ export class TemplateBuilder<T extends IObservableValue> {
// creates a conditional subtemplate // creates a conditional subtemplate
// use mapView if you need to map to a different view class // use mapView if you need to map to a different view class
if(predicate: (value: T) => boolean, renderFn: (t: Builder<T>, vm: T) => ViewNode) { if(predicate: (value: T) => boolean, renderFn: (t: Builder<T>, 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. /** 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<T> extends TemplateView<T> {
private _render: RenderFn<T>;
constructor(value: T, render: RenderFn<T>) {
super(value);
this._render = render;
}
override render(t: Builder<T>, value: T): ViewNode {
return this._render(t, value);
}
}

View file

@ -14,16 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {TemplateView} from "../../general/TemplateView"; import {TemplateView, InlineTemplateView} from "../../general/TemplateView";
import {StaticView} from "../../general/StaticView.js"; import {StaticView} from "../../general/StaticView.js";
export class SessionBackupSettingsView extends TemplateView { export class SessionBackupSettingsView extends TemplateView {
render(t, vm) { render(t, vm) {
return t.mapView(vm => vm.status, status => { return t.mapView(vm => vm.status, status => {
switch (status) { switch (status) {
case "Enabled": return new TemplateView(vm, renderEnabled) case "Enabled": return new InlineTemplateView(vm, renderEnabled)
case "SetupKey": return new TemplateView(vm, renderEnableFromKey) case "SetupKey": return new InlineTemplateView(vm, renderEnableFromKey)
case "SetupPhrase": return new TemplateView(vm, renderEnableFromPhrase) case "SetupPhrase": return new InlineTemplateView(vm, renderEnableFromPhrase)
case "Pending": return new StaticView(vm, t => t.p(vm.i18n`Waiting to go online…`)) case "Pending": return new StaticView(vm, t => t.p(vm.i18n`Waiting to go online…`))
} }
}); });

View file

@ -42,11 +42,7 @@ export class SettingsView extends TemplateView {
row(t, vm.i18n`Session ID`, vm.deviceId, "code"), row(t, vm.i18n`Session ID`, vm.deviceId, "code"),
row(t, vm.i18n`Session key`, vm.fingerprintKey, "code"), row(t, vm.i18n`Session key`, vm.fingerprintKey, "code"),
row(t, "", t.button({ row(t, "", t.button({
onClick: () => { onClick: () => vm.logout(),
if (confirm(vm.i18n`Are you sure you want to log out?`)) {
vm.logout();
}
},
disabled: vm => vm.isLoggingOut disabled: vm => vm.isLoggingOut
}, vm.i18n`Log out`)), }, vm.i18n`Log out`)),
); );