Merge branch 'snowpack-ts-storage-1' into snowpack-ts-storage-2
This commit is contained in:
commit
b081aa171c
50 changed files with 995 additions and 630 deletions
|
@ -1 +0,0 @@
|
|||
src/matrix/storage/memory
|
|
@ -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": {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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'
|
||||
],
|
||||
|
|
|
@ -1,84 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
this._setSection(() => {
|
||||
this._sessionLoadViewModel = new SessionLoadViewModel(this.childOptions({
|
||||
createAndStartSessionContainer: () => {
|
||||
const sessionContainer = this._createSessionContainer();
|
||||
sessionContainer.startWithExistingSession(sessionId);
|
||||
return sessionContainer;
|
||||
},
|
||||
this._setSection(() => {
|
||||
this._sessionLoadViewModel = new SessionLoadViewModel(this.childOptions({
|
||||
sessionContainer,
|
||||
ready: sessionContainer => this._showSession(sessionContainer)
|
||||
}));
|
||||
this._sessionLoadViewModel.start();
|
||||
|
|
|
@ -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:
|
||||
|
|
76
src/domain/login/CompleteSSOLoginViewModel.js
Normal file
76
src/domain/login/CompleteSSOLoginViewModel.js
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
239
src/domain/login/LoginViewModel.js
Normal file
239
src/domain/login/LoginViewModel.js
Normal file
|
@ -0,0 +1,239 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
63
src/domain/login/PasswordLoginViewModel.js
Normal file
63
src/domain/login/PasswordLoginViewModel.js
Normal 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 {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);
|
||||
}
|
||||
}
|
||||
}
|
38
src/domain/login/StartSSOLoginViewModel.js
Normal file
38
src/domain/login/StartSSOLoginViewModel.js
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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=<token>
|
||||
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);
|
||||
|
|
|
@ -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 ... ?
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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*/) {
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
30
src/matrix/login/LoginMethod.js
Normal file
30
src/matrix/login/LoginMethod.js
Normal file
|
@ -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");
|
||||
}
|
||||
}
|
29
src/matrix/login/PasswordLoginMethod.js
Normal file
29
src/matrix/login/PasswordLoginMethod.js
Normal file
|
@ -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();
|
||||
}
|
||||
}
|
27
src/matrix/login/SSOLoginHelper.js
Normal file
27
src/matrix/login/SSOLoginHelper.js
Normal file
|
@ -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}`;
|
||||
}
|
||||
}
|
29
src/matrix/login/TokenLoginMethod.js
Normal file
29
src/matrix/login/TokenLoginMethod.js
Normal file
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -1,53 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
|
@ -1,73 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
|
@ -1,237 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
|
||||
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<Entry[]>} 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<Entry[]>} 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<Entry[]>} 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<Entry[]>} 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);
|
||||
}
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
53
src/matrix/well-known.js
Normal file
53
src/matrix/well-known.js
Normal file
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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: {...},
|
||||
|
|
|
@ -25,6 +25,14 @@ export class History extends BaseObservableValue {
|
|||
}
|
||||
|
||||
get() {
|
||||
/*
|
||||
All URLS in Hydrogen will use <root>/#/segment/value/...
|
||||
But for SSO, we need to handle <root>/?loginToken=<TOKEN>
|
||||
Handle that as a special case for now.
|
||||
*/
|
||||
if (document.location.search.includes("loginToken")) {
|
||||
return document.location.search;
|
||||
}
|
||||
return document.location.hash;
|
||||
}
|
||||
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
30
src/platform/web/ui/login/CompleteSSOView.js
Normal file
30
src/platform/web/ui/login/CompleteSSOView.js
Normal file
|
@ -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),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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`,
|
||||
const disabled = vm => vm.isBusy;
|
||||
|
||||
return t.div({className: "PreSessionScreen"}, [
|
||||
t.button({
|
||||
className: "button-utility LoginView_back",
|
||||
onClick: () => vm.goBack(),
|
||||
disabled
|
||||
});
|
||||
const password = t.input({
|
||||
id: "password",
|
||||
type: "password",
|
||||
placeholder: vm.i18n`Password`,
|
||||
disabled
|
||||
});
|
||||
const homeserver = t.input({
|
||||
}),
|
||||
t.div({className: "logo"}),
|
||||
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.defaultHomeServer,
|
||||
disabled
|
||||
});
|
||||
|
||||
return t.div({className: "PreSessionScreen"}, [
|
||||
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]),
|
||||
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),
|
||||
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))
|
||||
])
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
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`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
57
src/platform/web/ui/login/PasswordLoginView.js
Normal file
57
src/platform/web/ui/login/PasswordLoginView.js
Normal file
|
@ -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`),
|
||||
]),
|
||||
])
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -43,7 +43,7 @@
|
|||
<script id="main" type="module">
|
||||
import {LoginView} from "./login/LoginView.js";
|
||||
const view = new LoginView(vm({
|
||||
defaultHomeServer: "https://hs.tld",
|
||||
defaultHomeserver: "https://hs.tld",
|
||||
login: () => alert("Logging in!"),
|
||||
cancelUrl: "#/session"
|
||||
}));
|
||||
|
@ -60,7 +60,7 @@
|
|||
loading: true,
|
||||
}),
|
||||
cancelUrl: "#/session",
|
||||
defaultHomeServer: "https://hs.tld",
|
||||
defaultHomeserver: "https://hs.tld",
|
||||
}));
|
||||
document.getElementById("login-loading").appendChild(view.mount());
|
||||
</script>
|
||||
|
|
40
src/utils/AbortableOperation.ts
Normal file
40
src/utils/AbortableOperation.ts
Normal file
|
@ -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<T> = (setAbortable: (a: IAbortable) => typeof a) => T;
|
||||
|
||||
export class AbortableOperation<T> {
|
||||
public readonly result: T;
|
||||
private _abortable: IAbortable | null;
|
||||
|
||||
constructor(run: RunFn<T>) {
|
||||
this._abortable = null;
|
||||
const setAbortable = abortable => {
|
||||
this._abortable = abortable;
|
||||
return abortable;
|
||||
};
|
||||
this.result = run(setAbortable);
|
||||
}
|
||||
|
||||
abort() {
|
||||
this._abortable?.abort();
|
||||
this._abortable = null;
|
||||
}
|
||||
}
|
|
@ -55,9 +55,9 @@ export class EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
onFirstSubscriptionAdded(name) {}
|
||||
onFirstSubscriptionAdded(/* name */) {}
|
||||
|
||||
onLastSubscriptionRemoved(name) {}
|
||||
onLastSubscriptionRemoved(/* name */) {}
|
||||
}
|
||||
|
||||
export function tests() {
|
||||
|
|
Reference in a new issue