diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index 849d870aee..af3c35bde8 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -51,7 +51,7 @@ function allowsChild(parent: Segment | undefined, child: Segment { + private _session: Session; + private _joinInProgress: boolean = false; + private _error: Error | undefined; + + constructor(options: Readonly) { + super(options); + this._session = options.session; + } + + async join(roomId: string): Promise { + this._error = undefined; + this._joinInProgress = true; + this.emitChange("joinInProgress"); + try { + const id = await joinRoom(roomId, this._session); + this.navigation.push("room", id); + } + catch (e) { + this._error = e; + this._joinInProgress = false; + this.emitChange("error"); + } + } + + get joinInProgress(): boolean { + return this._joinInProgress; + } + + get status(): string | undefined { + if (this._error) { + return this._error.message; + } + else if(this._joinInProgress){ + return "Joining room"; + } + } +} diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 7226dbdfa1..ecb2b7597c 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -25,6 +25,7 @@ import {SessionStatusViewModel} from "./SessionStatusViewModel.js"; import {RoomGridViewModel} from "./RoomGridViewModel.js"; import {SettingsViewModel} from "./settings/SettingsViewModel.js"; import {CreateRoomViewModel} from "./CreateRoomViewModel.js"; +import {JoinRoomViewModel} from "./JoinRoomViewModel"; import {ViewModel} from "../ViewModel"; import {RoomViewModelObservable} from "./RoomViewModelObservable.js"; import {RightPanelViewModel} from "./rightpanel/RightPanelViewModel.js"; @@ -45,6 +46,7 @@ export class SessionViewModel extends ViewModel { this._roomViewModelObservable = null; this._gridViewModel = null; this._createRoomViewModel = null; + this._joinRoomViewModel = null; this._setupNavigation(); this._setupForcedLogoutOnAccessTokenInvalidation(); } @@ -83,6 +85,12 @@ export class SessionViewModel extends ViewModel { })); this._updateCreateRoom(createRoom.get()); + const joinRoom = this.navigation.observe("join-room"); + this.track(joinRoom.subscribe((joinRoomOpen) => { + this._updateJoinRoom(joinRoomOpen); + })); + this._updateJoinRoom(joinRoom.get()); + const lightbox = this.navigation.observe("lightbox"); this.track(lightbox.subscribe(eventId => { this._updateLightbox(eventId); @@ -121,7 +129,13 @@ export class SessionViewModel extends ViewModel { } get activeMiddleViewModel() { - return this._roomViewModelObservable?.get() || this._gridViewModel || this._settingsViewModel || this._createRoomViewModel; + return ( + this._roomViewModelObservable?.get() || + this._gridViewModel || + this._settingsViewModel || + this._createRoomViewModel || + this._joinRoomViewModel + ); } get roomGridViewModel() { @@ -152,6 +166,10 @@ export class SessionViewModel extends ViewModel { return this._createRoomViewModel; } + get joinRoomViewModel() { + return this._joinRoomViewModel; + } + _updateGrid(roomIds) { const changed = !(this._gridViewModel && roomIds); const currentRoomId = this.navigation.path.get("room"); @@ -286,6 +304,16 @@ export class SessionViewModel extends ViewModel { this.emitChange("activeMiddleViewModel"); } + _updateJoinRoom(joinRoomOpen) { + if (this._joinRoomViewModel) { + this._joinRoomViewModel = this.disposeTracked(this._joinRoomViewModel); + } + if (joinRoomOpen) { + this._joinRoomViewModel = this.track(new JoinRoomViewModel(this.childOptions({session: this._client.session}))); + } + this.emitChange("activeMiddleViewModel"); + } + _updateLightbox(eventId) { if (this._lightboxViewModel) { this._lightboxViewModel = this.disposeTracked(this._lightboxViewModel); diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index 8c8d71a295..ce9aa0ee07 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -34,7 +34,6 @@ export class LeftPanelViewModel extends ViewModel { this._setupNavigation(); this._closeUrl = this.urlCreator.urlForSegment("session"); this._settingsUrl = this.urlCreator.urlForSegment("settings"); - this._createRoomUrl = this.urlCreator.urlForSegment("create-room"); } _mapTileViewModels(roomsBeingCreated, invites, rooms) { @@ -74,8 +73,14 @@ export class LeftPanelViewModel extends ViewModel { return this._settingsUrl; } - get createRoomUrl() { return this._createRoomUrl; } + showCreateRoomView() { + this.navigation.push("create-room"); + } + showJoinRoomView() { + this.navigation.push("join-room"); + } + _setupNavigation() { const roomObservable = this.navigation.observe("room"); this.track(roomObservable.subscribe(roomId => this._open(roomId))); diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 75f90730d4..2328a6bea3 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -23,7 +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 // this is a breaking SDK change though to make this option mandatory import {tileClassForEntry as defaultTileClassForEntry} from "./timeline/tiles/index"; -import {RoomStatus} from "../../../matrix/room/common"; +import {joinRoom} from "../../../matrix/room/joinRoom"; export class RoomViewModel extends ViewModel { constructor(options) { @@ -200,22 +200,11 @@ 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); + const session = this._options.client.session; + const roomId = await joinRoom(roomName, session); 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._sendError = err; this._timelineError = null; this.emitChange("error"); } diff --git a/src/matrix/room/joinRoom.ts b/src/matrix/room/joinRoom.ts new file mode 100644 index 0000000000..7f2ed19eb9 --- /dev/null +++ b/src/matrix/room/joinRoom.ts @@ -0,0 +1,42 @@ +/* +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. +*/ +import type {Session} from "../Session.js"; +import {RoomStatus} from "./common"; + +/** + * Join a room and wait for it to arrive in the next sync + * @param roomId The id of the room to join + * @param session A session instance + */ +export async function joinRoom(roomId: string, session: Session): Promise { + try { + const internalRoomId = await session.joinRoom(roomId); + const roomStatusObservable = await session.observeRoomStatus(internalRoomId); + await roomStatusObservable.waitFor((status: RoomStatus) => status === RoomStatus.Joined); + return internalRoomId; + } + catch (e) { + if ((e.statusCode ?? e.status) === 400) { + throw new Error(`'${roomId}' is not a legal room ID or alias`); + } else if ((e.statusCode ?? e.status) === 404 || (e.statusCode ?? e.status) === 502 || e.message == "Internal Server eor") { + throw new Error(`Room '${roomId}' could not be found`); + } else if ((e.statusCode ?? e.status) === 403) { + throw new Error(`You are not invited to join '${roomId}'`); + } else { + throw e; + } + } +} diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 05681cbb8b..e6f5b7a114 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -1180,7 +1180,7 @@ button.RoomDetailsView_row::after { gap: 12px; } -.CreateRoomView, .RoomBeingCreated_error { +.CreateRoomView, .JoinRoomView, .RoomBeingCreated_error { max-width: 400px; } @@ -1211,3 +1211,14 @@ button.RoomDetailsView_row::after { background-position: center; background-size: 36px; } + +.JoinRoomView_status { + display: flex; + align-items: center; + justify-content: center; + margin-top: 10px; +} + +.JoinRoomView_status .spinner { + margin-right: 5px; +} diff --git a/src/platform/web/ui/session/JoinRoomView.ts b/src/platform/web/ui/session/JoinRoomView.ts new file mode 100644 index 0000000000..90194317b8 --- /dev/null +++ b/src/platform/web/ui/session/JoinRoomView.ts @@ -0,0 +1,63 @@ +/* +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. +*/ + +import {TemplateView} from "../general/TemplateView"; +import type {JoinRoomViewModel} from "../../../../domain/session/JoinRoomViewModel"; +import {spinner} from "../common.js"; + +export class JoinRoomView extends TemplateView { + render(t, vm) { + const input = t.input({ + type: "text", + name: "id", + id: "id", + placeholder: vm.i18n`Enter a room id or alias`, + disabled: vm => vm.joinInProgress, + }); + return t.main({className: "middle"}, + t.div({className: "JoinRoomView centered-column"}, [ + t.h2("Join room"), + t.form({className: "JoinRoomView_detailsForm form", onSubmit: evt => this.onSubmit(evt, input.value)}, [ + t.div({className: "vertical-layout"}, [ + t.div({className: "stretch form-row text"}, [ + t.label({for: "id"}, vm.i18n`Room id`), + input, + ]), + ]), + t.div({className: "button-row"}, [ + t.button({ + className: "button-action primary", + type: "submit", + disabled: vm => vm.joinInProgress + }, vm.i18n`Join`), + ]), + t.map(vm => vm.status, (status, t) => { + return t.div({ className: "JoinRoomView_status" }, [ + spinner(t, { hidden: vm => !vm.joinInProgress }), + t.span(status), + ]); + }) + ]) + ]) + ); + } + + onSubmit(evt, id) { + evt.preventDefault(); + this.value.join(id); + } +} + diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index ef63b29b7e..7bcd8c0f8f 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -29,6 +29,7 @@ import {SettingsView} from "./settings/SettingsView.js"; import {CreateRoomView} from "./CreateRoomView.js"; import {RightPanelView} from "./rightpanel/RightPanelView.js"; import {viewClassForTile} from "./room/common"; +import {JoinRoomView} from "./JoinRoomView"; export class SessionView extends TemplateView { render(t, vm) { @@ -48,6 +49,8 @@ export class SessionView extends TemplateView { return new SettingsView(vm.settingsViewModel); } else if (vm.createRoomViewModel) { return new CreateRoomView(vm.createRoomViewModel); + } else if (vm.joinRoomViewModel) { + return new JoinRoomView(vm.joinRoomViewModel); } else if (vm.currentRoomViewModel) { if (vm.currentRoomViewModel.kind === "invite") { return new InviteView(vm.currentRoomViewModel); diff --git a/src/platform/web/ui/session/leftpanel/LeftPanelView.js b/src/platform/web/ui/session/leftpanel/LeftPanelView.js index c79192be82..fb2feb5711 100644 --- a/src/platform/web/ui/session/leftpanel/LeftPanelView.js +++ b/src/platform/web/ui/session/leftpanel/LeftPanelView.js @@ -17,6 +17,8 @@ limitations under the License. import {ListView} from "../../general/ListView"; import {TemplateView} from "../../general/TemplateView"; import {RoomTileView} from "./RoomTileView.js"; +import {Menu} from "../../general/Menu.js"; +import {Popup} from "../../general/Popup.js"; class FilterField extends TemplateView { render(t, options) { @@ -51,6 +53,11 @@ class FilterField extends TemplateView { } export class LeftPanelView extends TemplateView { + constructor(vm) { + super(vm); + this._createMenuPopup = null; + } + render(t, vm) { const gridButtonLabel = vm => { return vm.gridEnabled ? @@ -90,7 +97,11 @@ export class LeftPanelView extends TemplateView { "aria-label": gridButtonLabel }), t.a({className: "button-utility settings", href: vm.settingsUrl, "aria-label": vm.i18n`Settings`, title: vm.i18n`Settings`}), - t.a({className: "button-utility create", href: vm.createRoomUrl, "aria-label": vm.i18n`Create room`, title: vm.i18n`Create room`}), + t.button({ + className: "button-utility create", + "aria-label": vm.i18n`Create room`, + onClick: evt => this._toggleCreateMenu(evt) + }), ]); return t.div({className: "LeftPanel"}, [ @@ -98,4 +109,18 @@ export class LeftPanelView extends TemplateView { roomList ]); } + + _toggleCreateMenu(evt) { + if (this._createMenuPopup && this._createMenuPopup.isOpen) { + this._createMenuPopup.close(); + } else { + const vm = this.value; + const options = []; + options.push(Menu.option(vm.i18n`Create Room`, () => vm.showCreateRoomView())); + options.push(Menu.option(vm.i18n`Join Room`, () => vm.showJoinRoomView())); + this._createMenuPopup = new Popup(new Menu(options)); + this._createMenuPopup.trackInTemplateView(this); + this._createMenuPopup.showRelativeTo(evt.target, 10); + } + } }