diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 732865cb..00000000 --- a/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -src/matrix/storage/memory \ No newline at end of file diff --git a/package.json b/package.json index bc4fb969..c7e0cd9f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydrogen-web", - "version": "0.2.5", + "version": "0.2.7", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "main": "index.js", "directories": { diff --git a/scripts/build.mjs b/scripts/build.mjs index 82235cc4..0674cb93 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -114,8 +114,8 @@ async function build({modernOnly, overrideImports, overrideCss}) { await buildManifest(assets); // all assets have been added, create a hash from all assets name to cache unhashed files like index.html assets.addToHashForAll("index.html", devHtml); - let swSource = await fs.readFile(path.join(snowpackOutPath, "service-worker.js"), "utf8"); - assets.addToHashForAll("service-worker.js", swSource); + let swSource = await fs.readFile(path.join(snowpackOutPath, "sw.js"), "utf8"); + assets.addToHashForAll("sw.js", swSource); const globalHash = assets.hashForAll(); @@ -174,7 +174,7 @@ async function buildHtml(doc, version, baseConfig, globalHash, modernOnly, asset const configJSON = JSON.stringify(Object.assign({}, baseConfig, { worker: assets.has("worker.js") ? assets.resolve(`worker.js`) : null, downloadSandbox: assets.resolve("download-sandbox.html"), - serviceWorker: "service-worker.js", + serviceWorker: "sw.js", olm: { wasm: assets.resolve("olm.wasm"), legacyBundle: assets.resolve("olm_legacy.js"), @@ -342,7 +342,7 @@ async function buildServiceWorker(swSource, version, globalHash, assets) { swSource = replaceStringInSource("NOTIFICATION_BADGE_ICON", assets.resolve("icon.png")); // service worker should not have a hashed name as it is polled by the browser for updates - await assets.writeUnhashed("service-worker.js", swSource); + await assets.writeUnhashed("sw.js", swSource); } async function buildCssBundles(buildFn, themes, assets, mainCssFile = null) { diff --git a/snowpack.config.js b/snowpack.config.js index 3de1d723..68f33242 100644 --- a/snowpack.config.js +++ b/snowpack.config.js @@ -17,7 +17,6 @@ module.exports = { '**/scripts/**', '**/target/**', '**/prototypes/**', - '**/src/matrix/storage/memory/**', '**/src/platform/web/legacy-polyfill.js', '**/src/platform/web/worker/polyfill.js' ], diff --git a/src/domain/LoginViewModel.js b/src/domain/LoginViewModel.js deleted file mode 100644 index 1b52e1a5..00000000 --- a/src/domain/LoginViewModel.js +++ /dev/null @@ -1,84 +0,0 @@ -/* -Copyright 2020 Bruno Windels - -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 {SessionLoadViewModel} from "./SessionLoadViewModel.js"; - -export class LoginViewModel extends ViewModel { - constructor(options) { - super(options); - const {ready, defaultHomeServer, createSessionContainer} = options; - this._createSessionContainer = createSessionContainer; - this._ready = ready; - this._defaultHomeServer = defaultHomeServer; - this._sessionContainer = null; - this._loadViewModel = null; - this._loadViewModelSubscription = null; - } - - get defaultHomeServer() { return this._defaultHomeServer; } - - get loadViewModel() {return this._loadViewModel; } - - get isBusy() { - if (!this._loadViewModel) { - return false; - } else { - return this._loadViewModel.loading; - } - } - - async login(username, password, homeserver) { - this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription); - if (this._loadViewModel) { - this._loadViewModel = this.disposeTracked(this._loadViewModel); - } - this._loadViewModel = this.track(new SessionLoadViewModel(this.childOptions({ - createAndStartSessionContainer: () => { - this._sessionContainer = this._createSessionContainer(); - this._sessionContainer.startWithLogin(homeserver, username, password); - return this._sessionContainer; - }, - ready: sessionContainer => { - // make sure we don't delete the session in dispose when navigating away - this._sessionContainer = null; - this._ready(sessionContainer); - }, - homeserver, - }))); - this._loadViewModel.start(); - this.emitChange("loadViewModel"); - this._loadViewModelSubscription = this.track(this._loadViewModel.disposableOn("change", () => { - if (!this._loadViewModel.loading) { - this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription); - } - this.emitChange("isBusy"); - })); - } - - get cancelUrl() { - return this.urlCreator.urlForSegment("session"); - } - - dispose() { - super.dispose(); - if (this._sessionContainer) { - // if we move away before we're done with initial sync - // delete the session - this._sessionContainer.deleteSession(); - } - } -} diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index fca8d779..d9949c77 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -16,7 +16,7 @@ limitations under the License. import {SessionViewModel} from "./session/SessionViewModel.js"; import {SessionLoadViewModel} from "./SessionLoadViewModel.js"; -import {LoginViewModel} from "./LoginViewModel.js"; +import {LoginViewModel} from "./login/LoginViewModel.js"; import {SessionPickerViewModel} from "./SessionPickerViewModel.js"; import {ViewModel} from "./ViewModel.js"; @@ -35,12 +35,14 @@ export class RootViewModel extends ViewModel { async load() { this.track(this.navigation.observe("login").subscribe(() => this._applyNavigation())); this.track(this.navigation.observe("session").subscribe(() => this._applyNavigation())); + this.track(this.navigation.observe("sso").subscribe(() => this._applyNavigation())); this._applyNavigation(true); } async _applyNavigation(shouldRestoreLastUrl) { - const isLogin = this.navigation.observe("login").get(); - const sessionId = this.navigation.observe("session").get(); + const isLogin = this.navigation.path.get("login") + const sessionId = this.navigation.path.get("session")?.value; + const loginToken = this.navigation.path.get("sso")?.value; if (isLogin) { if (this.activeSection !== "login") { this._showLogin(); @@ -65,7 +67,13 @@ export class RootViewModel extends ViewModel { this._showSessionLoader(sessionId); } } - } else { + } else if (loginToken) { + this.urlCreator.normalizeUrl(); + if (this.activeSection !== "login") { + this._showLogin(loginToken); + } + } + else { try { if (!(shouldRestoreLastUrl && this.urlCreator.tryRestoreLastUrl())) { const sessionInfos = await this.platform.sessionInfoStorage.getAll(); @@ -94,10 +102,10 @@ export class RootViewModel extends ViewModel { } } - _showLogin() { + _showLogin(loginToken) { this._setSection(() => { this._loginViewModel = new LoginViewModel(this.childOptions({ - defaultHomeServer: this.platform.config["defaultHomeServer"], + defaultHomeserver: this.platform.config["defaultHomeServer"], createSessionContainer: this._createSessionContainer, ready: sessionContainer => { // we don't want to load the session container again, @@ -111,6 +119,7 @@ export class RootViewModel extends ViewModel { this._pendingSessionContainer = sessionContainer; this.navigation.push("session", sessionContainer.sessionId); }, + loginToken })); }); } @@ -123,13 +132,11 @@ export class RootViewModel extends ViewModel { } _showSessionLoader(sessionId) { + const sessionContainer = this._createSessionContainer(); + sessionContainer.startWithExistingSession(sessionId); this._setSection(() => { this._sessionLoadViewModel = new SessionLoadViewModel(this.childOptions({ - createAndStartSessionContainer: () => { - const sessionContainer = this._createSessionContainer(); - sessionContainer.startWithExistingSession(sessionId); - return sessionContainer; - }, + sessionContainer, ready: sessionContainer => this._showSession(sessionContainer) })); this._sessionLoadViewModel.start(); diff --git a/src/domain/SessionLoadViewModel.js b/src/domain/SessionLoadViewModel.js index 0b785e47..10cbb851 100644 --- a/src/domain/SessionLoadViewModel.js +++ b/src/domain/SessionLoadViewModel.js @@ -14,15 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {LoadStatus, LoginFailure} from "../matrix/SessionContainer.js"; +import {LoadStatus} from "../matrix/SessionContainer.js"; import {SyncStatus} from "../matrix/Sync.js"; import {ViewModel} from "./ViewModel.js"; export class SessionLoadViewModel extends ViewModel { constructor(options) { super(options); - const {createAndStartSessionContainer, ready, homeserver, deleteSessionOnCancel} = options; - this._createAndStartSessionContainer = createAndStartSessionContainer; + const {sessionContainer, ready, homeserver, deleteSessionOnCancel} = options; + this._sessionContainer = sessionContainer; this._ready = ready; this._homeserver = homeserver; this._deleteSessionOnCancel = deleteSessionOnCancel; @@ -38,7 +38,6 @@ export class SessionLoadViewModel extends ViewModel { try { this._loading = true; this.emitChange("loading"); - this._sessionContainer = this._createAndStartSessionContainer(); this._waitHandle = this._sessionContainer.loadStatus.waitFor(s => { this.emitChange("loadLabel"); // wait for initial sync, but not catchup sync @@ -109,22 +108,9 @@ export class SessionLoadViewModel extends ViewModel { return `Something went wrong: ${error && error.message}.`; } + // Statuses related to login are handled by respective login view models if (sc) { switch (sc.loadStatus.get()) { - case LoadStatus.NotLoading: - return `Preparing…`; - case LoadStatus.Login: - return `Checking your login and password…`; - case LoadStatus.LoginFailed: - switch (sc.loginFailure) { - case LoginFailure.LoginFailure: - return `Your username and/or password don't seem to be correct.`; - case LoginFailure.Connection: - return `Can't connect to ${this._homeserver}.`; - case LoginFailure.Unknown: - return `Something went wrong while checking your login and password.`; - } - break; case LoadStatus.SessionSetup: return `Setting up your encryption keys…`; case LoadStatus.Loading: diff --git a/src/domain/login/CompleteSSOLoginViewModel.js b/src/domain/login/CompleteSSOLoginViewModel.js new file mode 100644 index 00000000..7821313a --- /dev/null +++ b/src/domain/login/CompleteSSOLoginViewModel.js @@ -0,0 +1,76 @@ +/* +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 {LoginFailure} from "../../matrix/SessionContainer.js"; + +export class CompleteSSOLoginViewModel extends ViewModel { + constructor(options) { + super(options); + const { + loginToken, + sessionContainer, + attemptLogin, + } = options; + this._loginToken = loginToken; + this._sessionContainer = sessionContainer; + this._attemptLogin = attemptLogin; + this._errorMessage = ""; + this.performSSOLoginCompletion(); + } + + get errorMessage() { return this._errorMessage; } + + _showError(message) { + this._errorMessage = message; + this.emitChange("errorMessage"); + } + + async performSSOLoginCompletion() { + if (!this._loginToken) { + return; + } + const homeserver = await this.platform.settingsStorage.getString("sso_ongoing_login_homeserver"); + let loginOptions; + try { + loginOptions = await this._sessionContainer.queryLogin(homeserver).result; + } + catch (err) { + this._showError(err.message); + return; + } + if (!loginOptions.token) { + this.navigation.push("session"); + return; + } + const status = await this._attemptLogin(loginOptions.token(this._loginToken)); + let error = ""; + switch (status) { + case LoginFailure.Credentials: + error = this.i18n`Your login token is invalid.`; + break; + case LoginFailure.Connection: + error = this.i18n`Can't connect to ${homeserver}.`; + break; + case LoginFailure.Unknown: + error = this.i18n`Something went wrong while checking your login token.`; + break; + } + if (error) { + this._showError(error); + } + } +} diff --git a/src/domain/login/LoginViewModel.js b/src/domain/login/LoginViewModel.js new file mode 100644 index 00000000..9cdf9290 --- /dev/null +++ b/src/domain/login/LoginViewModel.js @@ -0,0 +1,239 @@ +/* +Copyright 2020 Bruno Windels + +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 {PasswordLoginViewModel} from "./PasswordLoginViewModel.js"; +import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js"; +import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js"; +import {LoadStatus} from "../../matrix/SessionContainer.js"; +import {SessionLoadViewModel} from "../SessionLoadViewModel.js"; + +export class LoginViewModel extends ViewModel { + constructor(options) { + super(options); + const {ready, defaultHomeserver, createSessionContainer, loginToken} = options; + this._createSessionContainer = createSessionContainer; + this._ready = ready; + this._loginToken = loginToken; + this._sessionContainer = this._createSessionContainer(); + 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; } + + goBack() { + this.navigation.push("session"); + } + + async _initViewModels() { + if (this._loginToken) { + this._hideHomeserver = true; + this._completeSSOLoginViewModel = this.track(new CompleteSSOLoginViewModel( + this.childOptions( + { + sessionContainer: this._sessionContainer, + attemptLogin: loginMethod => this.attemptLogin(loginMethod), + loginToken: this._loginToken + }))); + this.emitChange("completeSSOLoginViewModel"); + } + else { + await this.queryHomeserver(); + } + } + + _showPasswordLogin() { + this._passwordLoginViewModel = this.track(new PasswordLoginViewModel( + this.childOptions({ + loginOptions: this._loginOptions, + attemptLogin: loginMethod => this.attemptLogin(loginMethod) + }))); + this.emitChange("passwordLoginViewModel"); + } + + _showSSOLogin() { + this._startSSOLoginViewModel = this.track( + new StartSSOLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) + ); + this.emitChange("startSSOLoginViewModel"); + } + + _showError(message) { + this._errorMessage = message; + this.emitChange("errorMessage"); + } + + _setBusy(status) { + this._isBusy = status; + this._passwordLoginViewModel?.setBusy(status); + this._startSSOLoginViewModel?.setBusy(status); + this.emitChange("isBusy"); + } + + async attemptLogin(loginMethod) { + this._setBusy(true); + this._sessionContainer.startWithLogin(loginMethod); + const loadStatus = this._sessionContainer.loadStatus; + const handle = loadStatus.waitFor(status => status !== LoadStatus.Login); + await handle.promise; + this._setBusy(false); + const status = loadStatus.get(); + if (status === LoadStatus.LoginFailed) { + return this._sessionContainer.loginFailure; + } + this._hideHomeserver = true; + this.emitChange("hideHomeserver"); + this._disposeViewModels(); + this._createLoadViewModel(); + return null; + } + + _createLoadViewModel() { + this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription); + this._loadViewModel = this.disposeTracked(this._loadViewModel); + this._loadViewModel = this.track( + new SessionLoadViewModel( + this.childOptions({ + ready: (sessionContainer) => { + // make sure we don't delete the session in dispose when navigating away + this._sessionContainer = null; + this._ready(sessionContainer); + }, + sessionContainer: this._sessionContainer, + homeserver: this._homeserver + }) + ) + ); + this._loadViewModel.start(); + this.emitChange("loadViewModel"); + this._loadViewModelSubscription = this.track( + this._loadViewModel.disposableOn("change", () => { + if (!this._loadViewModel.loading) { + this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription); + } + this._setBusy(false); + }) + ); + } + + _disposeViewModels() { + this._startSSOLoginViewModel = this.disposeTracked(this._ssoLoginViewModel); + this._passwordLoginViewModel = this.disposeTracked(this._passwordLoginViewModel); + this._completeSSOLoginViewModel = this.disposeTracked(this._completeSSOLoginViewModel); + this.emitChange("disposeViewModels"); + } + + async setHomeserver(newHomeserver) { + this._homeserver = newHomeserver; + // clear everything set by queryHomeserver + this._loginOptions = null; + this._queriedHomeserver = null; + this._showError(""); + this._disposeViewModels(); + this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation); + this.emitChange(); // multiple fields changing + // also clear the timeout if it is still running + this.disposeTracked(this._abortHomeserverQueryTimeout); + const timeout = this.clock.createTimeout(1000); + this._abortHomeserverQueryTimeout = this.track(() => timeout.abort()); + try { + await timeout.elapsed(); + } catch (err) { + if (err.name === "AbortError") { + return; // still typing, don't query + } else { + throw err; + } + } + this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout); + this.queryHomeserver(); + } + + async queryHomeserver() { + // don't repeat a query we've just done + if (this._homeserver === this._queriedHomeserver || this._homeserver === "") { + return; + } + this._queriedHomeserver = this._homeserver; + // given that setHomeserver already clears everything set here, + // and that is the only way to change the homeserver, + // we don't need to reset things again here. + // However, clear things set by setHomeserver: + // if query is called before the typing timeout hits (e.g. field lost focus), + // cancel the timeout so we don't query again. + this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout); + // cancel ongoing query operation, if any + this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation); + try { + const queryOperation = this._sessionContainer.queryLogin(this._homeserver); + this._abortQueryOperation = this.track(() => queryOperation.abort()); + this.emitChange("isFetchingLoginOptions"); + this._loginOptions = await queryOperation.result; + this.emitChange("resolvedHomeserver"); + } + catch (e) { + if (e.name === "AbortError") { + return; //aborted, bail out + } else { + this._loginOptions = null; + } + } finally { + this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation); + this.emitChange("isFetchingLoginOptions"); + } + if (this._loginOptions) { + if (this._loginOptions.sso) { this._showSSOLogin(); } + 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() { + super.dispose(); + if (this._sessionContainer) { + // if we move away before we're done with initial sync + // delete the session + this._sessionContainer.deleteSession(); + } + } +} diff --git a/src/domain/login/PasswordLoginViewModel.js b/src/domain/login/PasswordLoginViewModel.js new file mode 100644 index 00000000..7ea239e6 --- /dev/null +++ b/src/domain/login/PasswordLoginViewModel.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 {LoginFailure} from "../../matrix/SessionContainer.js"; + +export class PasswordLoginViewModel extends ViewModel { + constructor(options) { + super(options); + const {loginOptions, attemptLogin} = options; + this._loginOptions = loginOptions; + this._attemptLogin = attemptLogin; + this._isBusy = false; + this._errorMessage = ""; + } + + get isBusy() { return this._isBusy; } + get errorMessage() { return this._errorMessage; } + + setBusy(status) { + this._isBusy = status; + this.emitChange("isBusy"); + } + + _showError(message) { + this._errorMessage = message; + this.emitChange("errorMessage"); + } + + async login(username, password) { + this._errorMessage = ""; + this.emitChange("errorMessage"); + const status = await this._attemptLogin(this._loginOptions.password(username, password)); + let error = ""; + switch (status) { + case LoginFailure.Credentials: + error = this.i18n`Your username and/or password don't seem to be correct.`; + break; + case LoginFailure.Connection: + error = this.i18n`Can't connect to ${this._loginOptions.homeserver}.`; + break; + case LoginFailure.Unknown: + error = this.i18n`Something went wrong while checking your login and password.`; + break; + } + if (error) { + this._showError(error); + } + } +} diff --git a/src/domain/login/StartSSOLoginViewModel.js b/src/domain/login/StartSSOLoginViewModel.js new file mode 100644 index 00000000..54218d22 --- /dev/null +++ b/src/domain/login/StartSSOLoginViewModel.js @@ -0,0 +1,38 @@ +/* +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"; + +export class StartSSOLoginViewModel extends ViewModel{ + constructor(options) { + super(options); + this._sso = options.loginOptions.sso; + this._isBusy = false; + } + + get isBusy() { return this._isBusy; } + + setBusy(status) { + this._isBusy = status; + this.emitChange("isBusy"); + } + + async startSSOLogin() { + await this.platform.settingsStorage.setString("sso_ongoing_login_homeserver", this._sso.homeserver); + const link = this._sso.createSSORedirectURL(this.urlCreator.createSSOCallbackURL()); + this.platform.openUrl(link); + } +} diff --git a/src/domain/navigation/URLRouter.js b/src/domain/navigation/URLRouter.js index 28488129..586eec8a 100644 --- a/src/domain/navigation/URLRouter.js +++ b/src/domain/navigation/URLRouter.js @@ -120,4 +120,14 @@ export class URLRouter { const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${roomId}`; return this._history.pathAsUrl(urlPath); } + + createSSOCallbackURL() { + return window.location.origin; + } + + normalizeUrl() { + // Remove any queryParameters from the URL + // Gets rid of the loginToken after SSO + this._history.replaceUrlSilently(`${window.location.origin}/${window.location.hash}`); + } } diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index dbac16ac..d21bcad4 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"; + return type === "login" || type === "session" || type === "sso"; case "session": return type === "room" || type === "rooms" || type === "settings"; case "rooms": @@ -152,6 +152,10 @@ export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) { const userId = iterator.next().value; if (!userId) { break; } pushRightPanelSegment(segments, type, userId); + } else if (type.includes("loginToken")) { + // Special case for SSO-login with query parameter loginToken= + const loginToken = type.split("=").pop(); + segments.push(new Segment("sso", loginToken)); } else { // might be undefined, which will be turned into true by Segment const value = iterator.next().value; @@ -181,7 +185,8 @@ export function stringifyPath(path) { } break; case "right-panel": - // Ignore right-panel in url + case "sso": + // Do not put these segments in URL continue; default: urlPath += `/${segment.type}`; @@ -228,6 +233,12 @@ export function tests() { const urlPath = stringifyPath(path); assert.equal(urlPath, "/session/1/rooms/a,b,c/1/details"); }, + "Parse loginToken query parameter into SSO segment": assert => { + const segments = parseUrlPath("?loginToken=a1232aSD123"); + assert.equal(segments.length, 1); + assert.equal(segments[0].type, "sso"); + assert.equal(segments[0].value, "a1232aSD123"); + }, "parse grid url path with focused empty tile": assert => { const segments = parseUrlPath("/session/1/rooms/a,b,c/3"); assert.equal(segments.length, 3); diff --git a/src/domain/session/room/timeline/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js index 0acb2859..10062af2 100644 --- a/src/domain/session/room/timeline/TilesCollection.js +++ b/src/domain/session/room/timeline/TilesCollection.js @@ -219,7 +219,7 @@ export class TilesCollection extends BaseObservableList { } } - onMove(fromIdx, toIdx, value) { + onMove(/*fromIdx, toIdx, value*/) { // this ... cannot happen in the timeline? // perhaps we can use this event to support a local echo (in a different fragment) // to be moved to the key of the remote echo, so we don't loose state ... ? diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index 7b2765f5..a08ab060 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -59,7 +59,7 @@ export class TimelineViewModel extends ViewModel { } } - unloadAtTop(tileAmount) { + unloadAtTop(/*tileAmount*/) { // get lowerSortKey for tile at index tileAmount - 1 // tell timeline to unload till there (included given key) } @@ -68,7 +68,7 @@ export class TimelineViewModel extends ViewModel { } - unloadAtBottom(tileAmount) { + unloadAtBottom(/*tileAmount*/) { // get upperSortKey for tile at index tiles.length - tileAmount // tell timeline to unload till there (included given key) } diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index 68037a14..3c370b72 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -101,7 +101,7 @@ export class SimpleTile extends ViewModel { // return whether the tile should be removed // as SimpleTile only has one entry, the tile should be removed - removeEntry(entry) { + removeEntry(/*entry*/) { return true; } @@ -110,12 +110,12 @@ export class SimpleTile extends ViewModel { return false; } // let item know it has a new sibling - updatePreviousSibling(prev) { + updatePreviousSibling(/*prev*/) { } // let item know it has a new sibling - updateNextSibling(next) { + updateNextSibling(/*next*/) { } diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index 221e39da..7120f5fb 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -151,7 +151,7 @@ export class SettingsViewModel extends ViewModel { this.pushNotifications.enabledOnServer = null; this.pushNotifications.serverError = null; try { - this.pushNotifications.enabledOnServer = await this._session.checkPusherEnabledOnHomeServer(); + this.pushNotifications.enabledOnServer = await this._session.checkPusherEnabledOnHomeserver(); this.emitChange("pushNotifications.enabledOnServer"); } catch (err) { this.pushNotifications.serverError = err; diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 5a046117..63aece42 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -46,7 +46,7 @@ const PICKLE_KEY = "DEFAULT_KEY"; const PUSHER_KEY = "pusher"; export class Session { - // sessionInfo contains deviceId, userId and homeServer + // sessionInfo contains deviceId, userId and homeserver constructor({storage, hsApi, sessionInfo, olm, olmWorker, platform, mediaRepository}) { this._platform = platform; this._storage = storage; @@ -636,7 +636,7 @@ export class Session { return !!pusherData; } - async checkPusherEnabledOnHomeServer() { + async checkPusherEnabledOnHomeserver() { const readTxn = await this._storage.readTxn([this._storage.storeNames.session]); const pusherData = await readTxn.session.get(PUSHER_KEY); if (!pusherData) { diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index 07c4a870..f375fdd7 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -1,5 +1,6 @@ /* Copyright 2020 Bruno Windels +Copyright 2020, 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. @@ -15,6 +16,8 @@ limitations under the License. */ import {createEnum} from "../utils/enum.js"; +import {lookupHomeserver} from "./well-known.js"; +import {AbortableOperation} from "../utils/AbortableOperation"; import {ObservableValue} from "../observable/ObservableValue.js"; import {HomeServerApi} from "./net/HomeServerApi.js"; import {Reconnector, ConnectionStatus} from "./net/Reconnector.js"; @@ -23,6 +26,9 @@ import {MediaRepository} from "./net/MediaRepository.js"; import {RequestScheduler} from "./net/RequestScheduler.js"; import {Sync, SyncStatus} from "./Sync.js"; import {Session} from "./Session.js"; +import {PasswordLoginMethod} from "./login/PasswordLoginMethod.js"; +import {TokenLoginMethod} from "./login/TokenLoginMethod.js"; +import {SSOLoginHelper} from "./login/SSOLoginHelper.js"; export const LoadStatus = createEnum( "NotLoading", @@ -42,14 +48,6 @@ export const LoginFailure = createEnum( "Unknown", ); -function normalizeHomeserver(homeServer) { - try { - return new URL(homeServer).origin; - } catch (err) { - return new URL(`https://${homeServer}`).origin; - } -} - export class SessionContainer { constructor({platform, olmPromise, workerPromise}) { this._platform = platform; @@ -97,25 +95,61 @@ export class SessionContainer { }); } - async startWithLogin(homeServer, username, password) { - if (this._status.get() !== LoadStatus.NotLoading) { + _parseLoginOptions(options, homeserver) { + /* + Take server response and return new object which has two props password and sso which + implements LoginMethod + */ + const flows = options.flows; + const result = {homeserver}; + for (const flow of flows) { + if (flow.type === "m.login.password") { + result.password = (username, password) => new PasswordLoginMethod({homeserver, username, password}); + } + else if (flow.type === "m.login.sso" && flows.find(flow => flow.type === "m.login.token")) { + result.sso = new SSOLoginHelper(homeserver); + } + else if (flow.type === "m.login.token") { + result.token = loginToken => new TokenLoginMethod({homeserver, loginToken}); + } + } + return result; + } + + queryLogin(homeserver) { + return new AbortableOperation(async setAbortable => { + homeserver = await lookupHomeserver(homeserver, (url, options) => { + return setAbortable(this._platform.request(url, options)); + }); + const hsApi = new HomeServerApi({homeserver, request: this._platform.request}); + const response = await setAbortable(hsApi.getLoginFlows()).response(); + return this._parseLoginOptions(response, homeserver); + }); + } + + async startWithLogin(loginMethod) { + const currentStatus = this._status.get(); + if (currentStatus !== LoadStatus.LoginFailed && + currentStatus !== LoadStatus.NotLoading && + currentStatus !== LoadStatus.Error) { return; } + this._resetStatus(); await this._platform.logger.run("login", async log => { this._status.set(LoadStatus.Login); - homeServer = normalizeHomeserver(homeServer); const clock = this._platform.clock; let sessionInfo; try { const request = this._platform.request; - const hsApi = new HomeServerApi({homeServer, request}); - const loginData = await hsApi.passwordLogin(username, password, "Hydrogen", {log}).response(); + const hsApi = new HomeServerApi({homeserver: loginMethod.homeserver, request}); + const loginData = await loginMethod.login(hsApi, "Hydrogen", log); const sessionId = this.createNewSessionId(); sessionInfo = { id: sessionId, deviceId: loginData.device_id, userId: loginData.user_id, - homeServer: homeServer, + homeServer: loginMethod.homeserver, // deprecate this over time + homeserver: loginMethod.homeserver, accessToken: loginData.access_token, lastUsed: clock.now() }; @@ -164,7 +198,7 @@ export class SessionContainer { createMeasure: clock.createMeasure }); const hsApi = new HomeServerApi({ - homeServer: sessionInfo.homeServer, + homeserver: sessionInfo.homeServer, accessToken: sessionInfo.accessToken, request: this._platform.request, reconnector: this._reconnector, @@ -176,7 +210,7 @@ export class SessionContainer { id: sessionInfo.id, deviceId: sessionInfo.deviceId, userId: sessionInfo.userId, - homeServer: sessionInfo.homeServer, + homeserver: sessionInfo.homeServer, }; const olm = await this._olmPromise; let olmWorker = null; @@ -186,7 +220,7 @@ export class SessionContainer { this._requestScheduler = new RequestScheduler({hsApi, clock}); this._requestScheduler.start(); const mediaRepository = new MediaRepository({ - homeServer: sessionInfo.homeServer, + homeserver: sessionInfo.homeServer, platform: this._platform, }); this._session = new Session({ @@ -270,6 +304,10 @@ export class SessionContainer { return this._error; } + get loginFailure() { + return this._loginFailure; + } + /** only set at loadStatus InitialSync, CatchupSync or Ready */ get sync() { return this._sync; @@ -319,4 +357,10 @@ export class SessionContainer { this._sessionId = null; } } + + _resetStatus() { + this._status.set(LoadStatus.NotLoading); + this._error = null; + this._loginFailure = null; + } } diff --git a/src/matrix/login/LoginMethod.js b/src/matrix/login/LoginMethod.js new file mode 100644 index 00000000..ece18871 --- /dev/null +++ b/src/matrix/login/LoginMethod.js @@ -0,0 +1,30 @@ +/* +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. +*/ + +export class LoginMethod { + constructor({homeserver}) { + this.homeserver = homeserver; + } + + // eslint-disable-next-line no-unused-vars + async login(hsApi, deviceName, log) { + /* + Regardless of the login method, SessionContainer.startWithLogin() + can do SomeLoginMethod.login() + */ + throw("Not Implemented"); + } +} diff --git a/src/matrix/login/PasswordLoginMethod.js b/src/matrix/login/PasswordLoginMethod.js new file mode 100644 index 00000000..5c90ccf8 --- /dev/null +++ b/src/matrix/login/PasswordLoginMethod.js @@ -0,0 +1,29 @@ +/* +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 {LoginMethod} from "./LoginMethod.js"; + +export class PasswordLoginMethod extends LoginMethod { + constructor(options) { + super(options); + this.username = options.username; + this.password = options.password; + } + + async login(hsApi, deviceName, log) { + return await hsApi.passwordLogin(this.username, this.password, deviceName, {log}).response(); + } +} diff --git a/src/matrix/login/SSOLoginHelper.js b/src/matrix/login/SSOLoginHelper.js new file mode 100644 index 00000000..a15c8ef9 --- /dev/null +++ b/src/matrix/login/SSOLoginHelper.js @@ -0,0 +1,27 @@ +/* +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. +*/ + +export class SSOLoginHelper{ + constructor(homeserver) { + this._homeserver = homeserver; + } + + get homeserver() { return this._homeserver; } + + createSSORedirectURL(returnURL) { + return `${this._homeserver}/_matrix/client/r0/login/sso/redirect?redirectUrl=${returnURL}`; + } +} diff --git a/src/matrix/login/TokenLoginMethod.js b/src/matrix/login/TokenLoginMethod.js new file mode 100644 index 00000000..e55cedcf --- /dev/null +++ b/src/matrix/login/TokenLoginMethod.js @@ -0,0 +1,29 @@ +/* +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 {LoginMethod} from "./LoginMethod.js"; +import {makeTxnId} from "../common.js"; + +export class TokenLoginMethod extends LoginMethod { + constructor(options) { + super(options); + this._loginToken = options.loginToken; + } + + async login(hsApi, deviceName, log) { + return await hsApi.tokenLogin(this._loginToken, makeTxnId(), deviceName, {log}).response(); + } +} diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index a9b63f8e..4b53b28b 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -19,10 +19,10 @@ import {encodeQueryParams, encodeBody} from "./common.js"; import {HomeServerRequest} from "./HomeServerRequest.js"; export class HomeServerApi { - constructor({homeServer, accessToken, request, reconnector}) { + constructor({homeserver, accessToken, request, reconnector}) { // store these both in a closure somehow so it's harder to get at in case of XSS? // one could change the homeserver as well so the token gets sent there, so both must be protected from read/write - this._homeserver = homeServer; + this._homeserver = homeserver; this._accessToken = accessToken; this._requestFn = request; this._reconnector = reconnector; @@ -134,6 +134,10 @@ export class HomeServerApi { return this._get(`/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/${encodeURIComponent(stateKey)}`, {}, null, options); } + getLoginFlows() { + return this._unauthedRequest("GET", this._url("/login"), null, null, null); + } + passwordLogin(username, password, initialDeviceDisplayName, options = null) { return this._unauthedRequest("POST", this._url("/login"), null, { "type": "m.login.password", @@ -146,6 +150,18 @@ export class HomeServerApi { }, options); } + tokenLogin(loginToken, txnId, initialDeviceDisplayName, options = null) { + return this._unauthedRequest("POST", this._url("/login"), null, { + "type": "m.login.token", + "identifier": { + "type": "m.id.user", + }, + "token": loginToken, + "txn_id": txnId, + "initial_device_display_name": initialDeviceDisplayName + }, options); + } + createFilter(userId, filter, options = null) { return this._post(`/user/${encodeURIComponent(userId)}/filter`, null, filter, options); } @@ -218,7 +234,7 @@ export function tests() { "superficial happy path for GET": async assert => { const hsApi = new HomeServerApi({ request: () => new MockRequest().respond(200, 42), - homeServer: "https://hs.tld" + homeserver: "https://hs.tld" }); const result = await hsApi._get("foo", null, null, null).response(); assert.strictEqual(result, 42); diff --git a/src/matrix/net/MediaRepository.js b/src/matrix/net/MediaRepository.js index f7e47cfd..3f718c85 100644 --- a/src/matrix/net/MediaRepository.js +++ b/src/matrix/net/MediaRepository.js @@ -18,8 +18,8 @@ import {encodeQueryParams} from "./common.js"; import {decryptAttachment} from "../e2ee/attachment.js"; export class MediaRepository { - constructor({homeServer, platform}) { - this._homeServer = homeServer; + constructor({homeserver, platform}) { + this._homeserver = homeserver; this._platform = platform; } @@ -27,7 +27,7 @@ export class MediaRepository { const parts = this._parseMxcUrl(url); if (parts) { const [serverName, mediaId] = parts; - const httpUrl = `${this._homeServer}/_matrix/media/r0/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`; + const httpUrl = `${this._homeserver}/_matrix/media/r0/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`; return httpUrl + "?" + encodeQueryParams({width: Math.round(width), height: Math.round(height), method}); } return null; @@ -37,7 +37,7 @@ export class MediaRepository { const parts = this._parseMxcUrl(url); if (parts) { const [serverName, mediaId] = parts; - return `${this._homeServer}/_matrix/media/r0/download/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`; + return `${this._homeserver}/_matrix/media/r0/download/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`; } else { return null; } diff --git a/src/matrix/room/timeline/persistence/RelationWriter.js b/src/matrix/room/timeline/persistence/RelationWriter.js index 4944cc64..afc99e6d 100644 --- a/src/matrix/room/timeline/persistence/RelationWriter.js +++ b/src/matrix/room/timeline/persistence/RelationWriter.js @@ -147,7 +147,7 @@ export class RelationWriter { return true; } - _aggregateAnnotation(annotationEvent, targetStorageEntry, log) { + _aggregateAnnotation(annotationEvent, targetStorageEntry/*, log*/) { // TODO: do we want to verify it is a m.reaction event somehow? const relation = getRelation(annotationEvent); if (!relation) { diff --git a/src/matrix/storage/memory/Storage.js b/src/matrix/storage/memory/Storage.js deleted file mode 100644 index a76c16f3..00000000 --- a/src/matrix/storage/memory/Storage.js +++ /dev/null @@ -1,53 +0,0 @@ -/* -Copyright 2020 Bruno Windels - -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 {Transaction} from "./Transaction.js"; -import { STORE_MAP, STORE_NAMES } from "../common"; - -export class Storage { - constructor(initialStoreValues = {}) { - this._validateStoreNames(Object.keys(initialStoreValues)); - this.storeNames = STORE_MAP; - this._storeValues = STORE_NAMES.reduce((values, name) => { - values[name] = initialStoreValues[name] || null; - }, {}); - } - - _validateStoreNames(storeNames) { - const idx = storeNames.findIndex(name => !STORE_MAP.hasOwnProperty(name)); - if (idx !== -1) { - throw new Error(`Invalid store name ${storeNames[idx]}`); - } - } - - _createTxn(storeNames, writable) { - this._validateStoreNames(storeNames); - const storeValues = storeNames.reduce((values, name) => { - return values[name] = this._storeValues[name]; - }, {}); - return Promise.resolve(new Transaction(storeValues, writable)); - } - - readTxn(storeNames) { - // TODO: avoid concurrency - return this._createTxn(storeNames, false); - } - - readWriteTxn(storeNames) { - // TODO: avoid concurrency - return this._createTxn(storeNames, true); - } -} diff --git a/src/matrix/storage/memory/Transaction.js b/src/matrix/storage/memory/Transaction.js deleted file mode 100644 index 894db805..00000000 --- a/src/matrix/storage/memory/Transaction.js +++ /dev/null @@ -1,73 +0,0 @@ -/* -Copyright 2020 Bruno Windels - -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 {RoomTimelineStore} from "./stores/RoomTimelineStore.js"; - -export class Transaction { - constructor(storeValues, writable) { - this._storeValues = storeValues; - this._txnStoreValues = {}; - this._writable = writable; - } - - _store(name, mapper) { - if (!this._txnStoreValues.hasOwnProperty(name)) { - if (!this._storeValues.hasOwnProperty(name)) { - throw new Error(`Transaction wasn't opened for store ${name}`); - } - const store = mapper(this._storeValues[name]); - const clone = store.cloneStoreValue(); - // extra prevention for writing - if (!this._writable) { - Object.freeze(clone); - } - this._txnStoreValues[name] = clone; - } - return mapper(this._txnStoreValues[name]); - } - - get session() { - throw new Error("not yet implemented"); - // return this._store("session", storeValue => new SessionStore(storeValue)); - } - - get roomSummary() { - throw new Error("not yet implemented"); - // return this._store("roomSummary", storeValue => new RoomSummaryStore(storeValue)); - } - - get roomTimeline() { - return this._store("roomTimeline", storeValue => new RoomTimelineStore(storeValue)); - } - - get roomState() { - throw new Error("not yet implemented"); - // return this._store("roomState", storeValue => new RoomStateStore(storeValue)); - } - - complete() { - for(let name of Object.keys(this._txnStoreValues)) { - this._storeValues[name] = this._txnStoreValues[name]; - } - this._txnStoreValues = null; - return Promise.resolve(); - } - - abort() { - this._txnStoreValues = null; - return Promise.resolve(); - } -} diff --git a/src/matrix/storage/memory/stores/RoomTimelineStore.js b/src/matrix/storage/memory/stores/RoomTimelineStore.js deleted file mode 100644 index 5be20eae..00000000 --- a/src/matrix/storage/memory/stores/RoomTimelineStore.js +++ /dev/null @@ -1,237 +0,0 @@ -/* -Copyright 2020 Bruno Windels - -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 {SortKey} from "../../room/timeline/SortKey.js"; -import {sortedIndex} from "../../../utils/sortedIndex.js"; -import {Store} from "./Store.js"; - -function compareKeys(key, entry) { - if (key.roomId === entry.roomId) { - return key.sortKey.compare(entry.sortKey); - } else { - return key.roomId < entry.roomId ? -1 : 1; - } -} - -class Range { - constructor(timeline, lower, upper, lowerOpen, upperOpen) { - this._timeline = timeline; - this._lower = lower; - this._upper = upper; - this._lowerOpen = lowerOpen; - this._upperOpen = upperOpen; - } - - /** projects the range onto the timeline array */ - project(roomId, maxCount = Number.MAX_SAFE_INTEGER) { - // determine lowest and highest allowed index. - // Important not to bleed into other roomIds here. - const lowerKey = {roomId, sortKey: this._lower || SortKey.minKey }; - // apply lower key being open (excludes given key) - let minIndex = sortedIndex(this._timeline, lowerKey, compareKeys); - if (this._lowerOpen && minIndex < this._timeline.length && compareKeys(lowerKey, this._timeline[minIndex]) === 0) { - minIndex += 1; - } - const upperKey = {roomId, sortKey: this._upper || SortKey.maxKey }; - // apply upper key being open (excludes given key) - let maxIndex = sortedIndex(this._timeline, upperKey, compareKeys); - if (this._upperOpen && maxIndex < this._timeline.length && compareKeys(upperKey, this._timeline[maxIndex]) === 0) { - maxIndex -= 1; - } - // find out from which edge we should grow - // if upper or lower bound - // again, important not to go below minIndex or above maxIndex - // to avoid bleeding into other rooms - let startIndex, endIndex; - if (!this._lower && this._upper) { - startIndex = Math.max(minIndex, maxIndex - maxCount); - endIndex = maxIndex; - } else if (this._lower && !this._upper) { - startIndex = minIndex; - endIndex = Math.min(maxIndex, minIndex + maxCount); - } else { - startIndex = minIndex; - endIndex = maxIndex; - } - - // if startIndex is out of range, make range empty - if (startIndex === this._timeline.length) { - startIndex = endIndex = 0; - } - const count = endIndex - startIndex; - return {startIndex, count}; - } - - select(roomId, maxCount) { - const {startIndex, count} = this.project(roomId, this._timeline, maxCount); - return this._timeline.slice(startIndex, startIndex + count); - } -} - -export class RoomTimelineStore extends Store { - constructor(timeline, writable) { - super(timeline || [], writable); - } - - get _timeline() { - return this._storeValue; - } - - /** Creates a range that only includes the given key - * @param {SortKey} sortKey the key - * @return {Range} the created range - */ - onlyRange(sortKey) { - return new Range(this._timeline, sortKey, sortKey); - } - - /** Creates a range that includes all keys before sortKey, and optionally also the key itself. - * @param {SortKey} sortKey the key - * @param {boolean} [open=false] whether the key is included (false) or excluded (true) from the range at the upper end. - * @return {Range} the created range - */ - upperBoundRange(sortKey, open=false) { - return new Range(this._timeline, undefined, sortKey, undefined, open); - } - - /** Creates a range that includes all keys after sortKey, and optionally also the key itself. - * @param {SortKey} sortKey the key - * @param {boolean} [open=false] whether the key is included (false) or excluded (true) from the range at the lower end. - * @return {Range} the created range - */ - lowerBoundRange(sortKey, open=false) { - return new Range(this._timeline, sortKey, undefined, open); - } - - /** Creates a range that includes all keys between `lower` and `upper`, and optionally the given keys as well. - * @param {SortKey} lower the lower key - * @param {SortKey} upper the upper key - * @param {boolean} [lowerOpen=false] whether the lower key is included (false) or excluded (true) from the range. - * @param {boolean} [upperOpen=false] whether the upper key is included (false) or excluded (true) from the range. - * @return {Range} the created range - */ - boundRange(lower, upper, lowerOpen=false, upperOpen=false) { - return new Range(this._timeline, lower, upper, lowerOpen, upperOpen); - } - - /** Looks up the last `amount` entries in the timeline for `roomId`. - * @param {string} roomId - * @param {number} amount - * @return {Promise} a promise resolving to an array with 0 or more entries, in ascending order. - */ - lastEvents(roomId, amount) { - return this.eventsBefore(roomId, SortKey.maxKey, amount); - } - - /** Looks up the first `amount` entries in the timeline for `roomId`. - * @param {string} roomId - * @param {number} amount - * @return {Promise} a promise resolving to an array with 0 or more entries, in ascending order. - */ - firstEvents(roomId, amount) { - return this.eventsAfter(roomId, SortKey.minKey, amount); - } - - /** Looks up `amount` entries after `sortKey` in the timeline for `roomId`. - * The entry for `sortKey` is not included. - * @param {string} roomId - * @param {SortKey} sortKey - * @param {number} amount - * @return {Promise} a promise resolving to an array with 0 or more entries, in ascending order. - */ - eventsAfter(roomId, sortKey, amount) { - const events = this.lowerBoundRange(sortKey, true).select(roomId, amount); - return Promise.resolve(events); - } - - /** Looks up `amount` entries before `sortKey` in the timeline for `roomId`. - * The entry for `sortKey` is not included. - * @param {string} roomId - * @param {SortKey} sortKey - * @param {number} amount - * @return {Promise} a promise resolving to an array with 0 or more entries, in ascending order. - */ - eventsBefore(roomId, sortKey, amount) { - const events = this.upperBoundRange(sortKey, true).select(roomId, amount); - return Promise.resolve(events); - } - - /** Looks up the first, if any, event entry (so excluding gap entries) after `sortKey`. - * @param {string} roomId - * @param {SortKey} sortKey - * @return {Promise<(?Entry)>} a promise resolving to entry, if any. - */ - nextEvent(roomId, sortKey) { - const searchSpace = this.lowerBoundRange(sortKey, true).select(roomId); - const event = searchSpace.find(entry => !!entry.event); - return Promise.resolve(event); - } - - /** Looks up the first, if any, event entry (so excluding gap entries) before `sortKey`. - * @param {string} roomId - * @param {SortKey} sortKey - * @return {Promise<(?Entry)>} a promise resolving to entry, if any. - */ - previousEvent(roomId, sortKey) { - const searchSpace = this.upperBoundRange(sortKey, true).select(roomId); - const event = searchSpace.reverse().find(entry => !!entry.event); - return Promise.resolve(event); - } - - /** Inserts a new entry into the store. The combination of roomId and sortKey should not exist yet, or an error is thrown. - * @param {Entry} entry the entry to insert - * @return {Promise<>} a promise resolving to undefined if the operation was successful, or a StorageError if not. - * @throws {StorageError} ... - */ - insert(entry) { - this.assertWritable(); - const insertIndex = sortedIndex(this._timeline, entry, compareKeys); - if (insertIndex < this._timeline.length) { - const existingEntry = this._timeline[insertIndex]; - if (compareKeys(entry, existingEntry) === 0) { - return Promise.reject(new Error("entry already exists")); - } - } - this._timeline.splice(insertIndex, 0, entry); - return Promise.resolve(); - } - - /** Updates the entry into the store with the given [roomId, sortKey] combination. - * If not yet present, will insert. Might be slower than add. - * @param {Entry} entry the entry to update. - * @return {Promise<>} a promise resolving to undefined if the operation was successful, or a StorageError if not. - */ - update(entry) { - this.assertWritable(); - let update = false; - const updateIndex = sortedIndex(this._timeline, entry, compareKeys); - if (updateIndex < this._timeline.length) { - const existingEntry = this._timeline[updateIndex]; - if (compareKeys(entry, existingEntry) === 0) { - update = true; - } - } - this._timeline.splice(updateIndex, update ? 1 : 0, entry); - return Promise.resolve(); - } - - get(roomId, sortKey) { - const range = this.onlyRange(sortKey); - const {startIndex, count} = range.project(roomId); - const event = count ? this._timeline[startIndex] : undefined; - return Promise.resolve(event); - } -} diff --git a/src/matrix/storage/memory/stores/Store.js b/src/matrix/storage/memory/stores/Store.js deleted file mode 100644 index c7218ab8..00000000 --- a/src/matrix/storage/memory/stores/Store.js +++ /dev/null @@ -1,43 +0,0 @@ -/* -Copyright 2020 Bruno Windels - -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 class Store { - constructor(storeValue, writable) { - this._storeValue = storeValue; - this._writable = writable; - } - - // makes a copy deep enough that any modifications in the store - // won't affect the original - // used for transactions - cloneStoreValue() { - // assumes 1 level deep is enough, and that values will be replaced - // rather than updated. - if (Array.isArray(this._storeValue)) { - return this._storeValue.slice(); - } else if (typeof this._storeValue === "object") { - return Object.assign({}, this._storeValue); - } else { - return this._storeValue; - } - } - - assertWritable() { - if (!this._writable) { - throw new Error("Tried to write in read-only transaction"); - } - } -} diff --git a/src/matrix/well-known.js b/src/matrix/well-known.js new file mode 100644 index 00000000..00c91f27 --- /dev/null +++ b/src/matrix/well-known.js @@ -0,0 +1,53 @@ +/* +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. +*/ + +function normalizeHomeserver(homeserver) { + try { + return new URL(homeserver).origin; + } catch (err) { + return new URL(`https://${homeserver}`).origin; + } +} + +async function getWellKnownResponse(homeserver, request) { + const requestOptions = {format: "json", timeout: 30000, method: "GET"}; + try { + const wellKnownUrl = `${homeserver}/.well-known/matrix/client`; + return await request(wellKnownUrl, requestOptions).response(); + } catch (err) { + if (err.name === "ConnectionError") { + // don't fail lookup on a ConnectionError, + // there might be a missing CORS header on a 404 response or something, + // which won't be a problem necessarily with homeserver requests later on ... + return null; + } else { + throw err; + } + } +} + +export async function lookupHomeserver(homeserver, request) { + homeserver = normalizeHomeserver(homeserver); + const wellKnownResponse = await getWellKnownResponse(homeserver, request); + if (wellKnownResponse && wellKnownResponse.status === 200) { + const {body} = wellKnownResponse; + const wellKnownHomeserver = body["m.homeserver"]?.["base_url"]; + if (typeof wellKnownHomeserver === "string") { + homeserver = normalizeHomeserver(wellKnownHomeserver); + } + } + return homeserver; +} diff --git a/src/observable/map/MappedMap.js b/src/observable/map/MappedMap.js index 47013df8..2a810058 100644 --- a/src/observable/map/MappedMap.js +++ b/src/observable/map/MappedMap.js @@ -42,7 +42,7 @@ export class MappedMap extends BaseObservableMap { this.emitAdd(key, mappedValue); } - onRemove(key, _value) { + onRemove(key/*, _value*/) { const mappedValue = this._mappedValues.get(key); if (this._mappedValues.delete(key)) { this.emitRemove(key, mappedValue); diff --git a/src/observable/map/ObservableMap.js b/src/observable/map/ObservableMap.js index b72cd039..8f5a0922 100644 --- a/src/observable/map/ObservableMap.js +++ b/src/observable/map/ObservableMap.js @@ -156,7 +156,7 @@ export function tests() { assert.equal(key, 1); assert.deepEqual(value, {value: 5}); }, - onUpdate(key, value, params) { + onUpdate(key, value/*, params*/) { update_fired += 1; assert.equal(key, 1); assert.deepEqual(value, {value: 7}); diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index f1410106..40f47101 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -221,7 +221,7 @@ export class Platform { if (mimeType) { input.setAttribute("accept", mimeType); } - const promise = new Promise((resolve, reject) => { + const promise = new Promise(resolve => { const checkFile = () => { input.removeEventListener("change", checkFile, true); const file = input.files[0]; @@ -240,6 +240,10 @@ export class Platform { return promise; } + openUrl(url) { + location.href = url; + } + parseHTML(html) { return parseHTML(html); } diff --git a/src/platform/web/docroot/index.html b/src/platform/web/docroot/index.html index 90af082a..0b266ff3 100644 --- a/src/platform/web/docroot/index.html +++ b/src/platform/web/docroot/index.html @@ -26,7 +26,7 @@ downloadSandbox: "assets/download-sandbox.html", defaultHomeServer: "matrix.org", // NOTE: uncomment this if you want the service worker for local development - // serviceWorker: "service-worker.js", + // serviceWorker: "sw.js", // NOTE: provide push config if you want push notifs for local development // see assets/config.json for what the config looks like // push: {...}, diff --git a/src/platform/web/docroot/service-worker.js b/src/platform/web/docroot/sw.js similarity index 100% rename from src/platform/web/docroot/service-worker.js rename to src/platform/web/docroot/sw.js diff --git a/src/platform/web/dom/History.js b/src/platform/web/dom/History.js index 92927d3f..68e4ef78 100644 --- a/src/platform/web/dom/History.js +++ b/src/platform/web/dom/History.js @@ -25,6 +25,14 @@ export class History extends BaseObservableValue { } get() { + /* + All URLS in Hydrogen will use /#/segment/value/... + But for SSO, we need to handle /?loginToken= + Handle that as a special case for now. + */ + if (document.location.search.includes("loginToken")) { + return document.location.search; + } return document.location.hash; } diff --git a/src/platform/web/dom/SettingsStorage.js b/src/platform/web/dom/SettingsStorage.js index 4e4c18c7..1590cec5 100644 --- a/src/platform/web/dom/SettingsStorage.js +++ b/src/platform/web/dom/SettingsStorage.js @@ -43,6 +43,14 @@ export class SettingsStorage { return defaultValue; } + async setString(key, value) { + this._set(key, value); + } + + async getString(key) { + return window.localStorage.getItem(`${this._prefix}${key}`); + } + async remove(key) { window.localStorage.removeItem(`${this._prefix}${key}`); } diff --git a/src/platform/web/ui/css/login.css b/src/platform/web/ui/css/login.css index aefdac42..ca376dee 100644 --- a/src/platform/web/ui/css/login.css +++ b/src/platform/web/ui/css/login.css @@ -36,7 +36,7 @@ limitations under the License. align-items: center; } -.SessionPickerView .session-info > :not(:first-child) { +.SessionPickerView .session-info> :not(:first-child) { margin-left: 8px; } @@ -50,19 +50,19 @@ limitations under the License. margin: 0 20px; } -.LoginView { - padding: 0.4em; +.PasswordLoginView { + padding: 0 0.4em 0.4em; } -.SessionLoadStatusView { +.SessionLoadStatusView, .LoginView_query-spinner { display: flex; } -.SessionLoadStatusView > :not(:first-child) { +.SessionLoadStatusView> :not(:first-child), .LoginView_query-spinner> :not(:first-child) { margin-left: 12px; } -.SessionLoadStatusView p { +.SessionLoadStatusView p, .LoginView_query-spinner p { flex: 1; margin: 0; } @@ -70,3 +70,29 @@ limitations under the License. .SessionLoadStatusView .spinner { --size: 20px; } + +.StartSSOLoginView { + display: flex; + flex-direction: column; + padding: 0 0.4em 0; +} + +.StartSSOLoginView_button { + flex: 1; + margin-top: 12px; +} + +.LoginView_separator { + justify-content: center; + display: flex; + margin: 8px; +} + +.CompleteSSOView_title { + display: flex; + justify-content: center; +} + +.LoginView_sso { + padding: 0.4em 0.4em 0; +} diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 08a872b8..1b0bc9e4 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -223,6 +223,31 @@ a.button-action { padding-top: 16px; } +.StartSSOLoginView_button { + border: 1px solid #03B381; + border-radius: 8px; +} + +.LoginView_back { + background-image: url("./icons/chevron-left.svg"); + background-color: transparent; +} + +.LoginView_separator { + font-weight: 500; + font-size: 1.5rem; +} + +.LoginView_forwardInfo { + font-size: 0.9em; + margin-left: 1em; + color: #777; +} + +.CompleteSSOView_title { + font-weight: 500; +} + @media screen and (min-width: 600px) { .PreSessionScreen { box-shadow: 0px 6px 32px rgba(0, 0, 0, 0.1); diff --git a/src/platform/web/ui/general/ListView.js b/src/platform/web/ui/general/ListView.js index 884eedc4..74aa9d87 100644 --- a/src/platform/web/ui/general/ListView.js +++ b/src/platform/web/ui/general/ListView.js @@ -121,7 +121,7 @@ export class ListView { this.onListChanged(); } - onRemove(idx, _value) { + onRemove(idx/*, _value*/) { this.onBeforeListChanged(); const [child] = this._childInstances.splice(idx, 1); child.root().remove(); @@ -129,7 +129,7 @@ export class ListView { this.onListChanged(); } - onMove(fromIdx, toIdx, value) { + onMove(fromIdx, toIdx/*, value*/) { this.onBeforeListChanged(); const [child] = this._childInstances.splice(fromIdx, 1); this._childInstances.splice(toIdx, 0, child); diff --git a/src/platform/web/ui/login/CompleteSSOView.js b/src/platform/web/ui/login/CompleteSSOView.js new file mode 100644 index 00000000..63614acf --- /dev/null +++ b/src/platform/web/ui/login/CompleteSSOView.js @@ -0,0 +1,30 @@ +/* +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 {TemplateView} from "../general/TemplateView.js"; +import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; + +export class CompleteSSOView extends TemplateView { + render(t) { + return t.div({ className: "CompleteSSOView" }, + [ + t.p({ className: "CompleteSSOView_title" }, "Finishing up your SSO Login"), + t.if(vm => vm.errorMessage, (t, vm) => t.p({className: "error"}, vm.i18n(vm.errorMessage))), + t.mapView(vm => vm.loadViewModel, loadViewModel => loadViewModel ? new SessionLoadStatusView(loadViewModel) : null), + ] + ); + } +} diff --git a/src/platform/web/ui/login/LoginView.js b/src/platform/web/ui/login/LoginView.js index 683bf42d..aa89ccca 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -16,61 +16,63 @@ limitations under the License. import {TemplateView} from "../general/TemplateView.js"; import {hydrogenGithubLink} from "./common.js"; +import {PasswordLoginView} from "./PasswordLoginView.js"; +import {CompleteSSOView} from "./CompleteSSOView.js"; import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; +import {spinner} from "../common.js"; export class LoginView extends TemplateView { render(t, vm) { - const disabled = vm => !!vm.isBusy; - const username = t.input({ - id: "username", - type: "text", - placeholder: vm.i18n`Username`, - disabled - }); - const password = t.input({ - id: "password", - type: "password", - placeholder: vm.i18n`Password`, - disabled - }); - const homeserver = t.input({ - id: "homeserver", - type: "text", - placeholder: vm.i18n`Your matrix homeserver`, - value: vm.defaultHomeServer, - disabled - }); - + const disabled = vm => vm.isBusy; + return t.div({className: "PreSessionScreen"}, [ + t.button({ + className: "button-utility LoginView_back", + onClick: () => vm.goBack(), + disabled + }), t.div({className: "logo"}), - t.div({className: "LoginView form"}, [ - t.h1([vm.i18n`Sign In`]), - t.if(vm => vm.error, t => t.div({className: "error"}, vm => vm.error)), - t.form({ - onSubmit: evnt => { - evnt.preventDefault(); - vm.login(username.value, password.value, homeserver.value); - } - }, [ - t.div({className: "form-row"}, [t.label({for: "username"}, vm.i18n`Username`), username]), - t.div({className: "form-row"}, [t.label({for: "password"}, vm.i18n`Password`), password]), - t.div({className: "form-row"}, [t.label({for: "homeserver"}, vm.i18n`Homeserver`), homeserver]), - t.mapView(vm => vm.loadViewModel, loadViewModel => loadViewModel ? new SessionLoadStatusView(loadViewModel) : null), - t.div({className: "button-row"}, [ - t.a({ - className: "button-action secondary", - href: vm.cancelUrl - }, [vm.i18n`Go Back`]), - t.button({ - className: "button-action primary", - type: "submit" - }, vm.i18n`Log In`), - ]), - ]), - // use t.mapView rather than t.if to create a new view when the view model changes too - t.p(hydrogenGithubLink(t)) - ]) + t.h1([vm.i18n`Sign In`]), + t.mapView(vm => vm.completeSSOLoginViewModel, vm => vm ? new CompleteSSOView(vm) : null), + t.if(vm => vm.showHomeserver, (t, vm) => t.div({ className: "LoginView_sso form form-row" }, + [ + t.label({for: "homeserver"}, vm.i18n`Homeserver`), + t.input({ + id: "homeserver", + type: "text", + placeholder: vm.i18n`Your matrix homeserver`, + value: vm.homeserver, + disabled, + onInput: event => vm.setHomeserver(event.target.value), + onChange: () => vm.queryHomeserver(), + }), + t.p({className: { + LoginView_forwardInfo: true, + hidden: vm => !vm.resolvedHomeserver + }}, vm => vm.i18n`You will connect to ${vm.resolvedHomeserver}.`), + t.if(vm => vm.errorMessage, (t, vm) => t.p({className: "error"}, vm.i18n(vm.errorMessage))), + ] + )), + t.if(vm => vm.isFetchingLoginOptions, t => t.div({className: "LoginView_query-spinner"}, [spinner(t), t.p("Fetching available login options...")])), + t.mapView(vm => vm.passwordLoginViewModel, vm => vm ? new PasswordLoginView(vm): null), + t.if(vm => vm.passwordLoginViewModel && vm.startSSOLoginViewModel, t => t.p({className: "LoginView_separator"}, vm.i18n`or`)), + t.mapView(vm => vm.startSSOLoginViewModel, vm => vm ? new StartSSOLoginView(vm) : null), + t.mapView(vm => vm.loadViewModel, loadViewModel => loadViewModel ? new SessionLoadStatusView(loadViewModel) : null), + // use t.mapView rather than t.if to create a new view when the view model changes too + t.p(hydrogenGithubLink(t)) ]); } } +class StartSSOLoginView extends TemplateView { + render(t, vm) { + return t.div({ className: "StartSSOLoginView" }, + t.button({ + className: "StartSSOLoginView_button button-action secondary", + type: "button", + onClick: () => vm.startSSOLogin(), + disabled: vm => vm.isBusy + }, vm.i18n`Log in with SSO`) + ); + } +} diff --git a/src/platform/web/ui/login/PasswordLoginView.js b/src/platform/web/ui/login/PasswordLoginView.js new file mode 100644 index 00000000..130f30ae --- /dev/null +++ b/src/platform/web/ui/login/PasswordLoginView.js @@ -0,0 +1,57 @@ +/* +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 {TemplateView} from "../general/TemplateView.js"; + +export class PasswordLoginView extends TemplateView { + render(t, vm) { + const disabled = vm => !!vm.isBusy; + const username = t.input({ + id: "username", + type: "text", + placeholder: vm.i18n`Username`, + disabled + }); + const password = t.input({ + id: "password", + type: "password", + placeholder: vm.i18n`Password`, + disabled + }); + + return t.div({className: "PasswordLoginView form"}, [ + t.if(vm => vm.error, t => t.div({ className: "error" }, vm => vm.error)), + t.form({ + onSubmit: evnt => { + evnt.preventDefault(); + vm.login(username.value, password.value); + } + }, [ + t.if(vm => vm.errorMessage, (t, vm) => t.p({className: "error"}, vm.i18n(vm.errorMessage))), + t.div({ className: "form-row" }, [t.label({ for: "username" }, vm.i18n`Username`), username]), + t.div({ className: "form-row" }, [t.label({ for: "password" }, vm.i18n`Password`), password]), + t.div({ className: "button-row" }, [ + t.button({ + className: "button-action primary", + type: "submit", + disabled + }, vm.i18n`Log In`), + ]), + ]) + ]); + } +} + diff --git a/src/platform/web/ui/session/room/MessageComposer.js b/src/platform/web/ui/session/room/MessageComposer.js index e1d8f13c..c6e8a1ee 100644 --- a/src/platform/web/ui/session/room/MessageComposer.js +++ b/src/platform/web/ui/session/room/MessageComposer.js @@ -17,7 +17,6 @@ limitations under the License. import {TemplateView} from "../../general/TemplateView.js"; import {Popup} from "../../general/Popup.js"; import {Menu} from "../../general/Menu.js"; -import {TextMessageView} from "./timeline/TextMessageView.js"; import {viewClassForEntry} from "./TimelineList.js" export class MessageComposer extends TemplateView { diff --git a/src/platform/web/ui/session/room/RoomArchivedView.js b/src/platform/web/ui/session/room/RoomArchivedView.js index e5e489ed..80b18a08 100644 --- a/src/platform/web/ui/session/room/RoomArchivedView.js +++ b/src/platform/web/ui/session/room/RoomArchivedView.js @@ -17,7 +17,7 @@ limitations under the License. import {TemplateView} from "../../general/TemplateView.js"; export class RoomArchivedView extends TemplateView { - render(t, vm) { + render(t) { return t.div({className: "RoomArchivedView"}, t.h3(vm => vm.description)); } -} \ No newline at end of file +} diff --git a/src/platform/web/ui/session/room/timeline/TextMessageView.js b/src/platform/web/ui/session/room/timeline/TextMessageView.js index ef4d61d5..fcafaf27 100644 --- a/src/platform/web/ui/session/room/timeline/TextMessageView.js +++ b/src/platform/web/ui/session/room/timeline/TextMessageView.js @@ -97,7 +97,7 @@ const formatFunction = { link: linkPart => tag.a({href: linkPart.url, className: "link", target: "_blank", rel: "noopener" }, renderParts(linkPart.inlines)), pill: renderPill, format: formatPart => tag[formatPart.format](renderParts(formatPart.children)), - rule: rulePart => tag.hr(), + rule: () => tag.hr(), list: renderList, image: renderImage, newline: () => tag.br() diff --git a/src/platform/web/ui/view-gallery.html b/src/platform/web/ui/view-gallery.html index 7887d44f..675b2b5b 100644 --- a/src/platform/web/ui/view-gallery.html +++ b/src/platform/web/ui/view-gallery.html @@ -43,7 +43,7 @@ diff --git a/src/utils/AbortableOperation.ts b/src/utils/AbortableOperation.ts new file mode 100644 index 00000000..0cc49e10 --- /dev/null +++ b/src/utils/AbortableOperation.ts @@ -0,0 +1,40 @@ +/* +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. +*/ + +interface IAbortable { + abort(); +} + +type RunFn = (setAbortable: (a: IAbortable) => typeof a) => T; + +export class AbortableOperation { + public readonly result: T; + private _abortable: IAbortable | null; + + constructor(run: RunFn) { + this._abortable = null; + const setAbortable = abortable => { + this._abortable = abortable; + return abortable; + }; + this.result = run(setAbortable); + } + + abort() { + this._abortable?.abort(); + this._abortable = null; + } +} diff --git a/src/utils/EventEmitter.js b/src/utils/EventEmitter.js index 2d2e4458..5dd56ac3 100644 --- a/src/utils/EventEmitter.js +++ b/src/utils/EventEmitter.js @@ -55,9 +55,9 @@ export class EventEmitter { } } - onFirstSubscriptionAdded(name) {} + onFirstSubscriptionAdded(/* name */) {} - onLastSubscriptionRemoved(name) {} + onLastSubscriptionRemoved(/* name */) {} } export function tests() {