Merge pull request #870 from vector-im/implement-room-join-ui

Implement room join UI
This commit is contained in:
Bruno Windels 2022-09-20 08:51:45 +00:00 committed by GitHub
commit 664038b946
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 250 additions and 21 deletions

View File

@ -51,7 +51,7 @@ function allowsChild(parent: Segment<SegmentType> | undefined, child: Segment<Se
// allowed root segments // allowed root segments
return type === "login" || type === "session" || type === "sso" || type === "logout"; return type === "login" || type === "session" || type === "sso" || type === "logout";
case "session": case "session":
return type === "room" || type === "rooms" || type === "settings" || type === "create-room"; return type === "room" || type === "rooms" || type === "settings" || type === "create-room" || type === "join-room";
case "rooms": case "rooms":
// downside of the approach: both of these will control which tile is selected // downside of the approach: both of these will control which tile is selected
return type === "room" || type === "empty-grid-tile"; return type === "room" || type === "empty-grid-tile";

View File

@ -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 {ViewModel, Options as BaseOptions} from "../ViewModel";
import {SegmentType} from "../navigation/index";
import type {Session} from "../../matrix/Session.js";
import {joinRoom} from "../../matrix/room/joinRoom";
type Options = BaseOptions & {
session: Session;
};
export class JoinRoomViewModel extends ViewModel<SegmentType, Options> {
private _session: Session;
private _joinInProgress: boolean = false;
private _error: Error | undefined;
constructor(options: Readonly<Options>) {
super(options);
this._session = options.session;
}
async join(roomId: string): Promise<void> {
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";
}
}
}

View File

@ -25,6 +25,7 @@ import {SessionStatusViewModel} from "./SessionStatusViewModel.js";
import {RoomGridViewModel} from "./RoomGridViewModel.js"; import {RoomGridViewModel} from "./RoomGridViewModel.js";
import {SettingsViewModel} from "./settings/SettingsViewModel.js"; import {SettingsViewModel} from "./settings/SettingsViewModel.js";
import {CreateRoomViewModel} from "./CreateRoomViewModel.js"; import {CreateRoomViewModel} from "./CreateRoomViewModel.js";
import {JoinRoomViewModel} from "./JoinRoomViewModel";
import {ViewModel} from "../ViewModel"; import {ViewModel} from "../ViewModel";
import {RoomViewModelObservable} from "./RoomViewModelObservable.js"; import {RoomViewModelObservable} from "./RoomViewModelObservable.js";
import {RightPanelViewModel} from "./rightpanel/RightPanelViewModel.js"; import {RightPanelViewModel} from "./rightpanel/RightPanelViewModel.js";
@ -45,6 +46,7 @@ export class SessionViewModel extends ViewModel {
this._roomViewModelObservable = null; this._roomViewModelObservable = null;
this._gridViewModel = null; this._gridViewModel = null;
this._createRoomViewModel = null; this._createRoomViewModel = null;
this._joinRoomViewModel = null;
this._setupNavigation(); this._setupNavigation();
this._setupForcedLogoutOnAccessTokenInvalidation(); this._setupForcedLogoutOnAccessTokenInvalidation();
} }
@ -83,6 +85,12 @@ export class SessionViewModel extends ViewModel {
})); }));
this._updateCreateRoom(createRoom.get()); 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"); const lightbox = this.navigation.observe("lightbox");
this.track(lightbox.subscribe(eventId => { this.track(lightbox.subscribe(eventId => {
this._updateLightbox(eventId); this._updateLightbox(eventId);
@ -121,7 +129,13 @@ export class SessionViewModel extends ViewModel {
} }
get activeMiddleViewModel() { 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() { get roomGridViewModel() {
@ -152,6 +166,10 @@ export class SessionViewModel extends ViewModel {
return this._createRoomViewModel; return this._createRoomViewModel;
} }
get joinRoomViewModel() {
return this._joinRoomViewModel;
}
_updateGrid(roomIds) { _updateGrid(roomIds) {
const changed = !(this._gridViewModel && roomIds); const changed = !(this._gridViewModel && roomIds);
const currentRoomId = this.navigation.path.get("room"); const currentRoomId = this.navigation.path.get("room");
@ -286,6 +304,16 @@ export class SessionViewModel extends ViewModel {
this.emitChange("activeMiddleViewModel"); 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) { _updateLightbox(eventId) {
if (this._lightboxViewModel) { if (this._lightboxViewModel) {
this._lightboxViewModel = this.disposeTracked(this._lightboxViewModel); this._lightboxViewModel = this.disposeTracked(this._lightboxViewModel);

View File

@ -34,7 +34,6 @@ export class LeftPanelViewModel extends ViewModel {
this._setupNavigation(); this._setupNavigation();
this._closeUrl = this.urlCreator.urlForSegment("session"); this._closeUrl = this.urlCreator.urlForSegment("session");
this._settingsUrl = this.urlCreator.urlForSegment("settings"); this._settingsUrl = this.urlCreator.urlForSegment("settings");
this._createRoomUrl = this.urlCreator.urlForSegment("create-room");
} }
_mapTileViewModels(roomsBeingCreated, invites, rooms) { _mapTileViewModels(roomsBeingCreated, invites, rooms) {
@ -74,8 +73,14 @@ export class LeftPanelViewModel extends ViewModel {
return this._settingsUrl; return this._settingsUrl;
} }
get createRoomUrl() { return this._createRoomUrl; } showCreateRoomView() {
this.navigation.push("create-room");
}
showJoinRoomView() {
this.navigation.push("join-room");
}
_setupNavigation() { _setupNavigation() {
const roomObservable = this.navigation.observe("room"); const roomObservable = this.navigation.observe("room");
this.track(roomObservable.subscribe(roomId => this._open(roomId))); this.track(roomObservable.subscribe(roomId => this._open(roomId)));

View File

@ -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 // 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"; import {joinRoom} from "../../../matrix/room/joinRoom";
export class RoomViewModel extends ViewModel { export class RoomViewModel extends ViewModel {
constructor(options) { constructor(options) {
@ -200,22 +200,11 @@ export class RoomViewModel extends ViewModel {
async _processCommandJoin(roomName) { async _processCommandJoin(roomName) {
try { try {
const roomId = await this._options.client.session.joinRoom(roomName); const session = this._options.client.session;
const roomStatusObserver = await this._options.client.session.observeRoomStatus(roomId); const roomId = await joinRoom(roomName, session);
await roomStatusObserver.waitFor(status => status === RoomStatus.Joined);
this.navigation.push("room", roomId); this.navigation.push("room", roomId);
} catch (err) { } catch (err) {
let exc; this._sendError = err;
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._timelineError = null;
this.emitChange("error"); this.emitChange("error");
} }

View File

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

View File

@ -1180,7 +1180,7 @@ button.RoomDetailsView_row::after {
gap: 12px; gap: 12px;
} }
.CreateRoomView, .RoomBeingCreated_error { .CreateRoomView, .JoinRoomView, .RoomBeingCreated_error {
max-width: 400px; max-width: 400px;
} }
@ -1211,3 +1211,14 @@ button.RoomDetailsView_row::after {
background-position: center; background-position: center;
background-size: 36px; background-size: 36px;
} }
.JoinRoomView_status {
display: flex;
align-items: center;
justify-content: center;
margin-top: 10px;
}
.JoinRoomView_status .spinner {
margin-right: 5px;
}

View File

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

View File

@ -29,6 +29,7 @@ import {SettingsView} from "./settings/SettingsView.js";
import {CreateRoomView} from "./CreateRoomView.js"; import {CreateRoomView} from "./CreateRoomView.js";
import {RightPanelView} from "./rightpanel/RightPanelView.js"; import {RightPanelView} from "./rightpanel/RightPanelView.js";
import {viewClassForTile} from "./room/common"; import {viewClassForTile} from "./room/common";
import {JoinRoomView} from "./JoinRoomView";
export class SessionView extends TemplateView { export class SessionView extends TemplateView {
render(t, vm) { render(t, vm) {
@ -48,6 +49,8 @@ export class SessionView extends TemplateView {
return new SettingsView(vm.settingsViewModel); return new SettingsView(vm.settingsViewModel);
} else if (vm.createRoomViewModel) { } else if (vm.createRoomViewModel) {
return new CreateRoomView(vm.createRoomViewModel); return new CreateRoomView(vm.createRoomViewModel);
} else if (vm.joinRoomViewModel) {
return new JoinRoomView(vm.joinRoomViewModel);
} else if (vm.currentRoomViewModel) { } else if (vm.currentRoomViewModel) {
if (vm.currentRoomViewModel.kind === "invite") { if (vm.currentRoomViewModel.kind === "invite") {
return new InviteView(vm.currentRoomViewModel); return new InviteView(vm.currentRoomViewModel);

View File

@ -17,6 +17,8 @@ limitations under the License.
import {ListView} from "../../general/ListView"; import {ListView} from "../../general/ListView";
import {TemplateView} from "../../general/TemplateView"; import {TemplateView} from "../../general/TemplateView";
import {RoomTileView} from "./RoomTileView.js"; import {RoomTileView} from "./RoomTileView.js";
import {Menu} from "../../general/Menu.js";
import {Popup} from "../../general/Popup.js";
class FilterField extends TemplateView { class FilterField extends TemplateView {
render(t, options) { render(t, options) {
@ -51,6 +53,11 @@ class FilterField extends TemplateView {
} }
export class LeftPanelView extends TemplateView { export class LeftPanelView extends TemplateView {
constructor(vm) {
super(vm);
this._createMenuPopup = null;
}
render(t, vm) { render(t, vm) {
const gridButtonLabel = vm => { const gridButtonLabel = vm => {
return vm.gridEnabled ? return vm.gridEnabled ?
@ -90,7 +97,11 @@ export class LeftPanelView extends TemplateView {
"aria-label": gridButtonLabel "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 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"}, [ return t.div({className: "LeftPanel"}, [
@ -98,4 +109,18 @@ export class LeftPanelView extends TemplateView {
roomList 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);
}
}
} }