Merge branch 'snowpack-ts-storage-1' into snowpack-ts-storage-2

This commit is contained in:
Danila Fedorin 2021-08-26 17:08:00 -07:00
commit b081aa171c
50 changed files with 995 additions and 630 deletions

View file

@ -1 +0,0 @@
src/matrix/storage/memory

View file

@ -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": {

View file

@ -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) {

View file

@ -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'
],

View file

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

View file

@ -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();

View file

@ -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:

View 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);
}
}
}

View 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();
}
}
}

View file

@ -0,0 +1,63 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {ViewModel} from "../ViewModel.js";
import {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);
}
}
}

View 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);
}
}

View file

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

View file

@ -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);

View file

@ -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 ... ?

View file

@ -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)
}

View file

@ -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*/) {
}

View file

@ -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;

View file

@ -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) {

View file

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

View 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");
}
}

View 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();
}
}

View 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}`;
}
}

View 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();
}
}

View file

@ -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);

View file

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

View file

@ -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) {

View file

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

View file

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

View file

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

View file

@ -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
View 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;
}

View file

@ -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);

View file

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

View file

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

View file

@ -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: {...},

View file

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

View file

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

View file

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

View file

@ -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);

View file

@ -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);

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

View file

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

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

View file

@ -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 {

View file

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

View file

@ -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()

View file

@ -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>

View 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;
}
}

View file

@ -55,9 +55,9 @@ export class EventEmitter {
}
}
onFirstSubscriptionAdded(name) {}
onFirstSubscriptionAdded(/* name */) {}
onLastSubscriptionRemoved(name) {}
onLastSubscriptionRemoved(/* name */) {}
}
export function tests() {