Merge branch 'vector-im:master' into restore_last

This commit is contained in:
Kaki In 2022-07-29 12:06:49 +02:00 committed by GitHub
commit 09bc77073b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 345 additions and 79 deletions

View file

@ -19,6 +19,7 @@ module.exports = {
], ],
rules: { rules: {
"@typescript-eslint/no-floating-promises": 2, "@typescript-eslint/no-floating-promises": 2,
"@typescript-eslint/no-misused-promises": 2 "@typescript-eslint/no-misused-promises": 2,
"semi": ["error", "always"]
} }
}; };

View file

@ -14,18 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {Options, ViewModel} from "./ViewModel"; import {Options as BaseOptions, ViewModel} from "./ViewModel";
import {Client} from "../matrix/Client.js"; import {Client} from "../matrix/Client.js";
type LogoutOptions = { sessionId: string; } & Options; type Options = { sessionId: string; } & BaseOptions;
export class LogoutViewModel extends ViewModel<LogoutOptions> { export class LogoutViewModel extends ViewModel<Options> {
private _sessionId: string; private _sessionId: string;
private _busy: boolean; private _busy: boolean;
private _showConfirm: boolean; private _showConfirm: boolean;
private _error?: Error; private _error?: Error;
constructor(options: LogoutOptions) { constructor(options: Options) {
super(options); super(options);
this._sessionId = options.sessionId; this._sessionId = options.sessionId;
this._busy = false; this._busy = false;

View file

@ -17,7 +17,7 @@ limitations under the License.
import {Client} from "../matrix/Client.js"; import {Client} from "../matrix/Client.js";
import {SessionViewModel} from "./session/SessionViewModel.js"; import {SessionViewModel} from "./session/SessionViewModel.js";
import {SessionLoadViewModel} from "./SessionLoadViewModel.js"; import {SessionLoadViewModel} from "./SessionLoadViewModel.js";
import {LoginViewModel} from "./login/LoginViewModel.js"; import {LoginViewModel} from "./login/LoginViewModel";
import {LogoutViewModel} from "./LogoutViewModel"; import {LogoutViewModel} from "./LogoutViewModel";
import {SessionPickerViewModel} from "./SessionPickerViewModel.js"; import {SessionPickerViewModel} from "./SessionPickerViewModel.js";
import {ViewModel} from "./ViewModel"; import {ViewModel} from "./ViewModel";

View file

@ -58,11 +58,11 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
return this._options[name]; return this._options[name];
} }
observeNavigation(type: string, onChange: (value: string | true | undefined, type: string) => void) { observeNavigation(type: string, onChange: (value: string | true | undefined, type: string) => void): void {
const segmentObservable = this.navigation.observe(type); const segmentObservable = this.navigation.observe(type);
const unsubscribe = segmentObservable.subscribe((value: string | true | undefined) => { const unsubscribe = segmentObservable.subscribe((value: string | true | undefined) => {
onChange(value, type); onChange(value, type);
}) });
this.track(unsubscribe); this.track(unsubscribe);
} }
@ -103,7 +103,7 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
// //
// translated string should probably always be bindings, unless we're fine with a refresh when changing the language? // translated string should probably always be bindings, unless we're fine with a refresh when changing the language?
// we probably are, if we're using routing with a url, we could just refresh. // we probably are, if we're using routing with a url, we could just refresh.
i18n(parts: TemplateStringsArray, ...expr: any[]) { i18n(parts: TemplateStringsArray, ...expr: any[]): string {
// just concat for now // just concat for now
let result = ""; let result = "";
for (let i = 0; i < parts.length; ++i) { for (let i = 0; i < parts.length; ++i) {

View file

@ -15,101 +15,143 @@ limitations under the License.
*/ */
import {Client} from "../../matrix/Client.js"; import {Client} from "../../matrix/Client.js";
import {ViewModel} from "../ViewModel"; import {Options as BaseOptions, ViewModel} from "../ViewModel";
import {PasswordLoginViewModel} from "./PasswordLoginViewModel.js"; import {PasswordLoginViewModel} from "./PasswordLoginViewModel.js";
import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js"; import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js";
import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js"; import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js";
import {LoadStatus} from "../../matrix/Client.js"; import {LoadStatus} from "../../matrix/Client.js";
import {SessionLoadViewModel} from "../SessionLoadViewModel.js"; import {SessionLoadViewModel} from "../SessionLoadViewModel.js";
import type {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod} from "../../matrix/login";
export class LoginViewModel extends ViewModel { type Options = {
constructor(options) { defaultHomeserver: string;
ready: ReadyFn;
loginToken?: string;
} & BaseOptions;
export class LoginViewModel extends ViewModel<Options> {
private _ready: ReadyFn;
private _loginToken?: string;
private _client: Client;
private _loginOptions?: LoginOptions;
private _passwordLoginViewModel?: PasswordLoginViewModel;
private _startSSOLoginViewModel?: StartSSOLoginViewModel;
private _completeSSOLoginViewModel?: CompleteSSOLoginViewModel;
private _loadViewModel?: SessionLoadViewModel;
private _loadViewModelSubscription?: () => void;
private _homeserver: string;
private _queriedHomeserver?: string;
private _abortHomeserverQueryTimeout?: () => void;
private _abortQueryOperation?: () => void;
private _hideHomeserver: boolean = false;
private _isBusy: boolean = false;
private _errorMessage: string = "";
constructor(options: Readonly<Options>) {
super(options); super(options);
const {ready, defaultHomeserver, loginToken} = options; const {ready, defaultHomeserver, loginToken} = options;
this._ready = ready; this._ready = ready;
this._loginToken = loginToken; this._loginToken = loginToken;
this._client = new Client(this.platform); this._client = new Client(this.platform);
this._loginOptions = null;
this._passwordLoginViewModel = null;
this._startSSOLoginViewModel = null;
this._completeSSOLoginViewModel = null;
this._loadViewModel = null;
this._loadViewModelSubscription = null;
this._homeserver = defaultHomeserver; this._homeserver = defaultHomeserver;
this._queriedHomeserver = null;
this._errorMessage = "";
this._hideHomeserver = false;
this._isBusy = false;
this._abortHomeserverQueryTimeout = null;
this._abortQueryOperation = null;
this._initViewModels(); this._initViewModels();
} }
get passwordLoginViewModel() { return this._passwordLoginViewModel; } get passwordLoginViewModel(): PasswordLoginViewModel {
get startSSOLoginViewModel() { return this._startSSOLoginViewModel; } return this._passwordLoginViewModel;
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() { get startSSOLoginViewModel(): StartSSOLoginViewModel {
return this._startSSOLoginViewModel;
}
get completeSSOLoginViewModel(): CompleteSSOLoginViewModel {
return this._completeSSOLoginViewModel;
}
get homeserver(): string {
return this._homeserver;
}
get resolvedHomeserver(): string | undefined {
return this._loginOptions?.homeserver;
}
get errorMessage(): string {
return this._errorMessage;
}
get showHomeserver(): boolean {
return !this._hideHomeserver;
}
get loadViewModel(): SessionLoadViewModel {
return this._loadViewModel;
}
get isBusy(): boolean {
return this._isBusy;
}
get isFetchingLoginOptions(): boolean {
return !!this._abortQueryOperation;
}
goBack(): void {
this.navigation.push("session"); this.navigation.push("session");
} }
async _initViewModels() { private _initViewModels(): void {
if (this._loginToken) { if (this._loginToken) {
this._hideHomeserver = true; this._hideHomeserver = true;
this._completeSSOLoginViewModel = this.track(new CompleteSSOLoginViewModel( this._completeSSOLoginViewModel = this.track(new CompleteSSOLoginViewModel(
this.childOptions( this.childOptions(
{ {
client: this._client, client: this._client,
attemptLogin: loginMethod => this.attemptLogin(loginMethod), attemptLogin: (loginMethod: TokenLoginMethod) => this.attemptLogin(loginMethod),
loginToken: this._loginToken loginToken: this._loginToken
}))); })));
this.emitChange("completeSSOLoginViewModel"); this.emitChange("completeSSOLoginViewModel");
} }
else { else {
await this.queryHomeserver(); void this.queryHomeserver();
} }
} }
_showPasswordLogin() { private _showPasswordLogin(): void {
this._passwordLoginViewModel = this.track(new PasswordLoginViewModel( this._passwordLoginViewModel = this.track(new PasswordLoginViewModel(
this.childOptions({ this.childOptions({
loginOptions: this._loginOptions, loginOptions: this._loginOptions,
attemptLogin: loginMethod => this.attemptLogin(loginMethod) attemptLogin: (loginMethod: PasswordLoginMethod) => this.attemptLogin(loginMethod)
}))); })));
this.emitChange("passwordLoginViewModel"); this.emitChange("passwordLoginViewModel");
} }
_showSSOLogin() { private _showSSOLogin(): void {
this._startSSOLoginViewModel = this.track( this._startSSOLoginViewModel = this.track(
new StartSSOLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) new StartSSOLoginViewModel(this.childOptions({loginOptions: this._loginOptions}))
); );
this.emitChange("startSSOLoginViewModel"); this.emitChange("startSSOLoginViewModel");
} }
_showError(message) { private _showError(message: string): void {
this._errorMessage = message; this._errorMessage = message;
this.emitChange("errorMessage"); this.emitChange("errorMessage");
} }
_setBusy(status) { private _setBusy(status: boolean): void {
this._isBusy = status; this._isBusy = status;
this._passwordLoginViewModel?.setBusy(status); this._passwordLoginViewModel?.setBusy(status);
this._startSSOLoginViewModel?.setBusy(status); this._startSSOLoginViewModel?.setBusy(status);
this.emitChange("isBusy"); this.emitChange("isBusy");
} }
async attemptLogin(loginMethod) { async attemptLogin(loginMethod: ILoginMethod): Promise<null> {
this._setBusy(true); this._setBusy(true);
this._client.startWithLogin(loginMethod, {inspectAccountSetup: true}); void this._client.startWithLogin(loginMethod, {inspectAccountSetup: true});
const loadStatus = this._client.loadStatus; const loadStatus = this._client.loadStatus;
const handle = loadStatus.waitFor(status => status !== LoadStatus.Login); const handle = loadStatus.waitFor((status: LoadStatus) => status !== LoadStatus.Login);
await handle.promise; await handle.promise;
this._setBusy(false); this._setBusy(false);
const status = loadStatus.get(); const status = loadStatus.get();
@ -119,11 +161,11 @@ export class LoginViewModel extends ViewModel {
this._hideHomeserver = true; this._hideHomeserver = true;
this.emitChange("hideHomeserver"); this.emitChange("hideHomeserver");
this._disposeViewModels(); this._disposeViewModels();
this._createLoadViewModel(); void this._createLoadViewModel();
return null; return null;
} }
_createLoadViewModel() { private _createLoadViewModel(): void {
this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription); this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription);
this._loadViewModel = this.disposeTracked(this._loadViewModel); this._loadViewModel = this.disposeTracked(this._loadViewModel);
this._loadViewModel = this.track( this._loadViewModel = this.track(
@ -139,7 +181,7 @@ export class LoginViewModel extends ViewModel {
}) })
) )
); );
this._loadViewModel.start(); void this._loadViewModel.start();
this.emitChange("loadViewModel"); this.emitChange("loadViewModel");
this._loadViewModelSubscription = this.track( this._loadViewModelSubscription = this.track(
this._loadViewModel.disposableOn("change", () => { this._loadViewModel.disposableOn("change", () => {
@ -151,22 +193,22 @@ export class LoginViewModel extends ViewModel {
); );
} }
_disposeViewModels() { private _disposeViewModels(): void {
this._startSSOLoginViewModel = this.disposeTracked(this._ssoLoginViewModel); this._startSSOLoginViewModel = this.disposeTracked(this._startSSOLoginViewModel);
this._passwordLoginViewModel = this.disposeTracked(this._passwordLoginViewModel); this._passwordLoginViewModel = this.disposeTracked(this._passwordLoginViewModel);
this._completeSSOLoginViewModel = this.disposeTracked(this._completeSSOLoginViewModel); this._completeSSOLoginViewModel = this.disposeTracked(this._completeSSOLoginViewModel);
this.emitChange("disposeViewModels"); this.emitChange("disposeViewModels");
} }
async setHomeserver(newHomeserver) { async setHomeserver(newHomeserver: string): Promise<void> {
this._homeserver = newHomeserver; this._homeserver = newHomeserver;
// clear everything set by queryHomeserver // clear everything set by queryHomeserver
this._loginOptions = null; this._loginOptions = undefined;
this._queriedHomeserver = null; this._queriedHomeserver = undefined;
this._showError(""); this._showError("");
this._disposeViewModels(); this._disposeViewModels();
this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation); this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation);
this.emitChange(); // multiple fields changing this.emitChange("loginViewModels"); // multiple fields changing
// also clear the timeout if it is still running // also clear the timeout if it is still running
this.disposeTracked(this._abortHomeserverQueryTimeout); this.disposeTracked(this._abortHomeserverQueryTimeout);
const timeout = this.clock.createTimeout(1000); const timeout = this.clock.createTimeout(1000);
@ -181,10 +223,10 @@ export class LoginViewModel extends ViewModel {
} }
} }
this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout); this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout);
this.queryHomeserver(); void this.queryHomeserver();
} }
async queryHomeserver() { async queryHomeserver(): Promise<void> {
// don't repeat a query we've just done // don't repeat a query we've just done
if (this._homeserver === this._queriedHomeserver || this._homeserver === "") { if (this._homeserver === this._queriedHomeserver || this._homeserver === "") {
return; return;
@ -210,7 +252,7 @@ export class LoginViewModel extends ViewModel {
if (e.name === "AbortError") { if (e.name === "AbortError") {
return; //aborted, bail out return; //aborted, bail out
} else { } else {
this._loginOptions = null; this._loginOptions = undefined;
} }
} finally { } finally {
this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation); this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation);
@ -228,12 +270,22 @@ export class LoginViewModel extends ViewModel {
} }
} }
dispose() { dispose(): void {
super.dispose(); super.dispose();
if (this._client) { if (this._client) {
// if we move away before we're done with initial sync // if we move away before we're done with initial sync
// delete the session // delete the session
this._client.deleteSession(); void this._client.deleteSession();
} }
} }
} }
type ReadyFn = (client: Client) => void;
// TODO: move to Client.js when its converted to typescript.
type LoginOptions = {
homeserver: string;
password?: (username: string, password: string) => PasswordLoginMethod;
sso?: SSOLoginHelper;
token?: (loginToken: string) => TokenLoginMethod;
};

View file

@ -23,6 +23,7 @@ import {imageToInfo} from "../common.js";
// TODO: remove fallback so default isn't included in bundle for SDK users that have their custom tileClassForEntry // TODO: remove fallback so default isn't included in bundle for SDK users that have their custom tileClassForEntry
// this is a breaking SDK change though to make this option mandatory // this is a breaking SDK change though to make this option mandatory
import {tileClassForEntry as defaultTileClassForEntry} from "./timeline/tiles/index"; import {tileClassForEntry as defaultTileClassForEntry} from "./timeline/tiles/index";
import {RoomStatus} from "../../../matrix/room/common";
export class RoomViewModel extends ViewModel { export class RoomViewModel extends ViewModel {
constructor(options) { constructor(options) {
@ -197,19 +198,90 @@ export class RoomViewModel extends ViewModel {
} }
} }
async _processCommandJoin(roomName) {
try {
const roomId = await this._options.client.session.joinRoom(roomName);
const roomStatusObserver = await this._options.client.session.observeRoomStatus(roomId);
await roomStatusObserver.waitFor(status => status === RoomStatus.Joined);
this.navigation.push("room", roomId);
} catch (err) {
let exc;
if ((err.statusCode ?? err.status) === 400) {
exc = new Error(`/join : '${roomName}' was not legal room ID or room alias`);
} else if ((err.statusCode ?? err.status) === 404 || (err.statusCode ?? err.status) === 502 || err.message == "Internal Server Error") {
exc = new Error(`/join : room '${roomName}' not found`);
} else if ((err.statusCode ?? err.status) === 403) {
exc = new Error(`/join : you're not invited to join '${roomName}'`);
} else {
exc = err;
}
this._sendError = exc;
this._timelineError = null;
this.emitChange("error");
}
}
async _processCommand (message) {
let msgtype;
const [commandName, ...args] = message.substring(1).split(" ");
switch (commandName) {
case "me":
message = args.join(" ");
msgtype = "m.emote";
break;
case "join":
if (args.length === 1) {
const roomName = args[0];
await this._processCommandJoin(roomName);
} else {
this._sendError = new Error("join syntax: /join <room-id>");
this._timelineError = null;
this.emitChange("error");
}
break;
case "shrug":
message = "¯\\_(ツ)_/¯ " + args.join(" ");
msgtype = "m.text";
break;
case "tableflip":
message = "(╯°□°)╯︵ ┻━┻ " + args.join(" ");
msgtype = "m.text";
break;
case "unflip":
message = "┬──┬ ( ゜-゜ノ) " + args.join(" ");
msgtype = "m.text";
break;
case "lenny":
message = "( ͡° ͜ʖ ͡°) " + args.join(" ");
msgtype = "m.text";
break;
default:
this._sendError = new Error(`no command name "${commandName}". To send the message instead of executing, please type "/${message}"`);
this._timelineError = null;
this.emitChange("error");
message = undefined;
}
return {type: msgtype, message: message};
}
async _sendMessage(message, replyingTo) { async _sendMessage(message, replyingTo) {
if (!this._room.isArchived && message) { if (!this._room.isArchived && message) {
try { let messinfo = {type : "m.text", message : message};
let msgtype = "m.text"; if (message.startsWith("//")) {
if (message.startsWith("/me ")) { messinfo.message = message.substring(1).trim();
message = message.substr(4).trim(); } else if (message.startsWith("/")) {
msgtype = "m.emote"; messinfo = await this._processCommand(message);
} }
try {
const msgtype = messinfo.type;
const message = messinfo.message;
if (msgtype && message) {
if (replyingTo) { if (replyingTo) {
await replyingTo.reply(msgtype, message); await replyingTo.reply(msgtype, message);
} else { } else {
await this._room.sendEvent("m.room.message", {msgtype, body: message}); await this._room.sendEvent("m.room.message", {msgtype, body: message});
} }
}
} catch (err) { } catch (err) {
console.error(`room.sendMessage(): ${err.message}:\n${err.stack}`); console.error(`room.sendMessage(): ${err.message}:\n${err.stack}`);
this._sendError = err; this._sendError = err;
@ -353,6 +425,11 @@ export class RoomViewModel extends ViewModel {
this._composerVM.setReplyingTo(entry); this._composerVM.setReplyingTo(entry);
} }
} }
dismissError(evt) {
this._sendError = null;
this.emitChange("error");
}
} }
function videoToInfo(video) { function videoToInfo(video) {

View file

@ -100,6 +100,8 @@ export class Client {
}); });
} }
// TODO: When converted to typescript this should return the same type
// as this._loginOptions is in LoginViewModel.ts (LoginOptions).
_parseLoginOptions(options, homeserver) { _parseLoginOptions(options, homeserver) {
/* /*
Take server response and return new object which has two props password and sso which Take server response and return new object which has two props password and sso which

View file

@ -0,0 +1,7 @@
import {ILoginMethod} from "./LoginMethod";
import {PasswordLoginMethod} from "./PasswordLoginMethod";
import {SSOLoginHelper} from "./SSOLoginHelper";
import {TokenLoginMethod} from "./TokenLoginMethod";
export {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod};

View file

@ -42,6 +42,7 @@ async function requestPersistedStorage(): Promise<boolean> {
await glob.document.requestStorageAccess(); await glob.document.requestStorageAccess();
return true; return true;
} catch (err) { } catch (err) {
console.warn("requestStorageAccess threw an error:", err);
return false; return false;
} }
} else { } else {

View file

@ -0,0 +1,64 @@
/*
Copyright 2022 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 type Config = {
/**
* The default homeserver used by Hydrogen; auto filled in the login UI.
* eg: https://matrix.org
* REQUIRED
*/
defaultHomeServer: string;
/**
* The submit endpoint for your preferred rageshake server.
* eg: https://element.io/bugreports/submit
* Read more about rageshake at https://github.com/matrix-org/rageshake
* OPTIONAL
*/
bugReportEndpointUrl?: string;
/**
* Paths to theme-manifests
* eg: ["assets/theme-element.json", "assets/theme-awesome.json"]
* REQUIRED
*/
themeManifests: string[];
/**
* This configures the default theme(s) used by Hydrogen.
* These themes appear as "Default" option in the theme chooser UI and are also
* used as a fallback when other themes fail to load.
* Whether the dark or light variant is used depends on the system preference.
* OPTIONAL
*/
defaultTheme?: {
// id of light theme
light: string;
// id of dark theme
dark: string;
};
/**
* Configuration for push notifications.
* See https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3pushersset
* and https://github.com/matrix-org/sygnal/blob/main/docs/applications.md#webpush
* OPTIONAL
*/
push?: {
// See app_id in the request body in above link
appId: string;
// The host used for pushing notification
gatewayUrl: string;
// See pushkey in above link
applicationServerKey: string;
};
};

View file

@ -521,6 +521,62 @@ a {
.RoomView_error { .RoomView_error {
color: var(--error-color); color: var(--error-color);
background : #efefef;
height : 0px;
font-weight : bold;
transition : 0.25s all ease-out;
padding-right : 20px;
padding-left : 20px;
}
.RoomView_error div{
overflow : hidden;
height: 100%;
width: 100%;
position : relative;
display : flex;
align-items : center;
}
.RoomView_error:not(:empty) {
height : auto;
padding-top : 20px;
padding-bottom : 20px;
}
.RoomView_error p {
position : relative;
display : block;
width : 100%;
height : auto;
margin : 0;
}
.RoomView_error button {
width : 40px;
padding-top : 20px;
padding-bottom : 20px;
background : none;
border : none;
position : relative;
border-radius : 5px;
transition: 0.1s all ease-out;
cursor: pointer;
}
.RoomView_error button:hover {
background : #cfcfcf;
}
.RoomView_error button:before {
content:"\274c";
position : absolute;
top : 15px;
left: 9px;
width : 20px;
height : 10px;
font-size : 10px;
align-self : middle;
} }
.MessageComposer_replyPreview .Timeline_message { .MessageComposer_replyPreview .Timeline_message {

View file

@ -46,7 +46,13 @@ export class RoomView extends TemplateView {
}) })
]), ]),
t.div({className: "RoomView_body"}, [ t.div({className: "RoomView_body"}, [
t.div({className: "RoomView_error"}, vm => vm.error), t.div({className: "RoomView_error"}, [
t.if(vm => vm.error, t => t.div(
[
t.p({}, vm => vm.error),
t.button({ className: "RoomView_error_closerButton", onClick: evt => vm.dismissError(evt) })
])
)]),
t.mapView(vm => vm.timelineViewModel, timelineViewModel => { t.mapView(vm => vm.timelineViewModel, timelineViewModel => {
return timelineViewModel ? return timelineViewModel ?
new TimelineView(timelineViewModel, this._viewClassForTile) : new TimelineView(timelineViewModel, this._viewClassForTile) :