From b2d6b7014b786211bfaf7d7e78c144c41d3b075e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 12 Oct 2020 17:49:06 +0200 Subject: [PATCH] first draft of url navigation for grid --- src/domain/RootViewModel.js | 2 +- src/domain/navigation/Navigation.js | 51 ++++- src/domain/navigation/URLRouter.js | 87 ++++---- src/domain/navigation/index.js | 206 ++++++++++++++---- src/domain/session/RoomGridViewModel.js | 147 ++++++++++--- src/domain/session/SessionViewModel.js | 146 +++++++++---- .../session/leftpanel/LeftPanelViewModel.js | 37 +++- .../session/leftpanel/RoomTileViewModel.js | 2 +- src/ui/web/dom/History.js | 31 +-- src/ui/web/session/RoomGridView.js | 4 +- src/ui/web/session/leftpanel/RoomTileView.js | 27 +-- 11 files changed, 539 insertions(+), 201 deletions(-) diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index 3b6c24cd..a5d0b0f2 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -98,7 +98,7 @@ export class RootViewModel extends ViewModel { createSessionContainer: this._createSessionContainer, ready: sessionContainer => { const url = this.urlRouter.urlForSegment("session", sessionContainer.sessionId); - this.urlRouter.replaceUrl(url); + this.urlRouter.history.replaceUrl(url); this._showSession(sessionContainer); }, })); diff --git a/src/domain/navigation/Navigation.js b/src/domain/navigation/Navigation.js index 6520114c..70c79a01 100644 --- a/src/domain/navigation/Navigation.js +++ b/src/domain/navigation/Navigation.js @@ -28,10 +28,26 @@ export class Navigation { } applyPath(path) { + const oldPath = this._path; this._path = path; - for (const [type, observable] of this._observables) { - // if the value did not change, this won't emit - observable.set(this._path.get(type)?.value); + // clear values not in the new path in reverse order of path + for (let i = oldPath.segments.length - 1; i >= 0; i -= 1) { + const segment = oldPath[i]; + if (!this._path.get(segment.type)) { + const observable = this._observables.get(segment.type); + if (observable) { + observable.set(segment.type, undefined); + } + } + } + // change values in order of path + for (const segment of this._path.segments) { + const observable = this._observables.get(segment.type); + if (observable) { + if (!segmentValueEqual(segment?.value, observable.get())) { + observable.set(segment.type, segment.value); + } + } } } @@ -55,6 +71,27 @@ export class Navigation { } return new Path(segments, this._allowsChild); } + + segment(type, value) { + return new Segment(type, value); + } +} + +function segmentValueEqual(a, b) { + if (a === b) { + return true; + } + // allow (sparse) arrays + if (Array.isArray(a) && Array.isArray(b)) { + const len = Math.max(a.length, b.length); + for (let i = 0; i < len; i += 1) { + if (a[i] !== b[i]) { + return false; + } + } + return true; + } + return false; } export class Segment { @@ -89,6 +126,14 @@ class Path { return null; } + until(type) { + const index = this._segments.findIndex(s => s.type === type); + if (index !== -1) { + return new Path(this._segments.slice(0, index + 1), this._allowsChild) + } + return new Path([], this._allowsChild); + } + get(type) { return this._segments.find(s => s.type === type); } diff --git a/src/domain/navigation/URLRouter.js b/src/domain/navigation/URLRouter.js index 05da4ea7..6d7dc385 100644 --- a/src/domain/navigation/URLRouter.js +++ b/src/domain/navigation/URLRouter.js @@ -17,69 +17,78 @@ limitations under the License. import {Segment} from "./Navigation.js"; export class URLRouter { - constructor({history, navigation, redirect}) { + constructor({history, navigation, parseUrlPath, stringifyPath}) { this._subscription = null; this._history = history; this._navigation = navigation; - this._redirect = redirect; + this._parseUrlPath = parseUrlPath; + this._stringifyPath = stringifyPath; } attach() { this._subscription = this._history.subscribe(url => { - this.applyUrl(url); + const redirectedUrl = this.applyUrl(url); + if (redirectedUrl !== url) { + this._history.replaceUrl(redirectedUrl); + } }); this.applyUrl(this._history.get()); } - applyUrl(url) { - const segments = this._segmentsFromUrl(url); - const path = this._redirect(segments, this._navigation); - this._navigation.applyPath(path); - } - - stop() { + dispose() { this._subscription = this._subscription(); } - _segmentsFromUrl(url) { - const path = this._history.urlAsPath(url); - const parts = path.split("/").filter(p => !!p); - let index = 0; - const segments = []; - while (index < parts.length) { - const type = parts[index]; - if ((index + 1) < parts.length) { - index += 1; - const value = parts[index]; - segments.push(new Segment(type, value)); - } else { - segments.push(new Segment(type)); - } - index += 1; - } - return segments; + applyUrl(url) { + const urlPath = this._history.urlAsPath(url) + const navPath = this._navigation.pathFrom(this._parseUrlPath(urlPath)); + this._navigation.applyPath(navPath); + return this._history.pathAsUrl(this._stringifyPath(navPath)); } get history() { return this._history; } - urlForSegment(type, value) { - const path = this._navigation.path.with(new Segment(type, value)); - if (path) { - return this.urlForPath(path); + urlForSegments(segments) { + let path = this._navigation.path; + for (const segment of segments) { + path = path.with(segment); + if (!path) { + return; + } } + return this.urlForPath(path); + } + + urlForSegment(type, value) { + return this.urlForSegments([this._navigation.segment(type, value)]); } urlForPath(path) { - let urlPath = ""; - for (const {type, value} of path.segments) { - if (typeof value === "boolean") { - urlPath += `/${type}`; - } else { - urlPath += `/${type}/${value}`; - } - } + return this.history.pathAsUrl(this._stringifyPath(path)); + } + + openRoomActionUrl(roomId) { + // not a segment to navigation knowns about, so append it manually + const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${roomId}`; return this._history.pathAsUrl(urlPath); } + + disableGridUrl() { + + } + + enableGridUrl() { + let path = this._navigation.path.until("session"); + const room = this._navigation.get("room"); + if (room) { + path = path.with(this._navigation.segment("rooms", [room.value])); + path = path.with(room); + } else { + path = path.with(this._navigation.segment("rooms", [])); + path = path.with(this._navigation.segment("empty-grid-tile", 0)); + } + return this.urlForPath(path); + } } diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index 36627763..1a635293 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -18,49 +18,30 @@ import {Navigation, Segment} from "./Navigation.js"; import {URLRouter} from "./URLRouter.js"; export function createNavigation() { - return new Navigation(function allowsChild(parent, child) { - const {type} = child; - switch (parent?.type) { - case undefined: - // allowed root segments - return type === "login" || type === "session"; - case "session": - return type === "room" || type === "rooms" || type === "settings"; - case "rooms": - // downside of the approach: both of these will control which tile is selected - return type === "room" || type === "empty-grid-tile"; - default: - return false; - } - }); + return new Navigation(allowsChild); } export function createRouter({history, navigation}) { - return new URLRouter({history, navigation, redirect}); + return new URLRouter({history, navigation, stringifyPath, parseUrlPath}); } -function redirect(urlParts, navigation) { - const {path} = navigation; - const segments = urlParts.reduce((output, s) => { - // redirect open-room action to grid/non-grid url - if (s.type === "open-room") { - const rooms = path.get("rooms"); - if (rooms) { - output = output.concat(roomsSegmentWithRoom(rooms, s.value, path)); - } - return rooms.concat(new Segment("room", s.value)); - } - return output.concat(s); - }, []); - return navigation.pathFrom(segments); +function allowsChild(parent, child) { + const {type} = child; + switch (parent?.type) { + case undefined: + // allowed root segments + return type === "login" || type === "session"; + case "session": + return type === "room" || type === "rooms" || type === "settings"; + case "rooms": + // downside of the approach: both of these will control which tile is selected + return type === "room" || type === "empty-grid-tile"; + default: + return false; + } } function roomsSegmentWithRoom(rooms, roomId, path) { - // find the index of either the current room, - // or the current selected empty tile, - // to put the new room in - - // TODO: is rooms.value a string or an array? const room = path.get("room"); let index = 0; if (room) { @@ -71,20 +52,157 @@ function roomsSegmentWithRoom(rooms, roomId, path) { index = emptyGridTile.value; } } - const newRooms = rooms.slice(); + const newRooms = rooms.value.slice(); newRooms[index] = roomId; return new Segment("rooms", newRooms); } -function parseUrlValue(type, iterator) { - if (type === "rooms") { - const roomIds = iterator.next().value.split(","); - const selectedIndex = parseInt(iterator.next().value, 10); - const roomId = roomIds[selectedIndex]; - if (roomId) { - return [new Segment(type, roomIds), new Segment("room", roomId)]; +export function parseUrlPath(urlPath, currentNavPath) { + // substr(1) to take of initial / + const parts = urlPath.substr(1).split("/"); + const iterator = parts[Symbol.iterator](); + const segments = []; + let next; + while (!(next = iterator.next()).done) { + const type = next.value; + if (type === "rooms") { + const roomsValue = iterator.next().value; + if (!roomsValue) { break; } + const roomIds = roomsValue.split(","); + segments.push(new Segment(type, roomIds)); + const selectedIndex = parseInt(iterator.next().value || "0", 10); + const roomId = roomIds[selectedIndex]; + if (roomId) { + segments.push(new Segment("room", roomId)); + } else { + segments.push(new Segment("empty-grid-tile", selectedIndex)); + } + } else if (type === "open-room") { + const roomId = iterator.next().value; + if (!roomId) { break; } + const rooms = currentNavPath.get("rooms"); + if (rooms) { + segments.push(roomsSegmentWithRoom(rooms, roomId, currentNavPath)); + } + segments.push(new Segment("room", roomId)); } else { - return [new Segment(type, roomIds), new Segment("empty-grid-tile", selectedIndex)]; + // might be undefined, which will be turned into true by Segment + const value = iterator.next().value; + segments.push(new Segment(type, value)); + } + } + return segments; +} + +export function stringifyPath(path) { + let urlPath = ""; + let prevSegment; + for (const segment of path.segments) { + switch (segment.type) { + case "rooms": + urlPath += `/rooms/${segment.value.join(",")}`; + break; + case "empty-grid-tile": + urlPath += `/${segment.value}`; + break; + case "room": + if (prevSegment?.type === "rooms") { + const index = prevSegment.value.indexOf(segment.value); + urlPath += `/${index}`; + } else { + urlPath += `/${segment.type}/${segment.value}`; + } + break; + default: + urlPath += `/${segment.type}`; + if (segment.value && segment.value !== true) { + urlPath += `/${segment.value}`; + } + } + prevSegment = segment; + } + return urlPath; +} + +export function tests() { + return { + "stringify grid url with focused empty tile": assert => { + const nav = new Navigation(allowsChild); + const path = nav.pathFrom([ + new Segment("session", 1), + new Segment("rooms", ["a", "b", "c"]), + new Segment("empty-grid-tile", 3) + ]); + const urlPath = stringifyPath(path); + assert.equal(urlPath, "/session/1/rooms/a,b,c/3"); + }, + "stringify grid url with focused room": assert => { + const nav = new Navigation(allowsChild); + const path = nav.pathFrom([ + new Segment("session", 1), + new Segment("rooms", ["a", "b", "c"]), + new Segment("room", "b") + ]); + const urlPath = stringifyPath(path); + assert.equal(urlPath, "/session/1/rooms/a,b,c/1"); + }, + "parse grid url path with focused empty tile": assert => { + const segments = parseUrlPath("/session/1/rooms/a,b,c/3"); + assert.equal(segments.length, 3); + assert.equal(segments[0].type, "session"); + assert.equal(segments[0].value, "1"); + assert.equal(segments[1].type, "rooms"); + assert.deepEqual(segments[1].value, ["a", "b", "c"]); + assert.equal(segments[2].type, "empty-grid-tile"); + assert.equal(segments[2].value, 3); + }, + "parse grid url path with focused room": assert => { + const segments = parseUrlPath("/session/1/rooms/a,b,c/1"); + assert.equal(segments.length, 3); + assert.equal(segments[0].type, "session"); + assert.equal(segments[0].value, "1"); + assert.equal(segments[1].type, "rooms"); + assert.deepEqual(segments[1].value, ["a", "b", "c"]); + assert.equal(segments[2].type, "room"); + assert.equal(segments[2].value, "b"); + }, + "parse open-room action replacing the current focused room": assert => { + const nav = new Navigation(allowsChild); + const path = nav.pathFrom([ + new Segment("session", 1), + new Segment("rooms", ["a", "b", "c"]), + new Segment("room", "b") + ]); + const segments = parseUrlPath("/session/1/open-room/d", path); + assert.equal(segments.length, 3); + assert.equal(segments[0].type, "session"); + assert.equal(segments[0].value, "1"); + assert.equal(segments[1].type, "rooms"); + assert.deepEqual(segments[1].value, ["a", "d", "c"]); + assert.equal(segments[2].type, "room"); + assert.equal(segments[2].value, "d"); + }, + "parse open-room action setting a room in an empty tile": assert => { + const nav = new Navigation(allowsChild); + const path = nav.pathFrom([ + new Segment("session", 1), + new Segment("rooms", ["a", "b", "c"]), + new Segment("empty-grid-tile", 4) + ]); + const segments = parseUrlPath("/session/1/open-room/d", path); + assert.equal(segments.length, 3); + assert.equal(segments[0].type, "session"); + assert.equal(segments[0].value, "1"); + assert.equal(segments[1].type, "rooms"); + assert.deepEqual(segments[1].value, ["a", "b", "c", , "d"]); //eslint-disable-line no-sparse-arrays + assert.equal(segments[2].type, "room"); + assert.equal(segments[2].value, "d"); + }, + "parse session url path without id": assert => { + const segments = parseUrlPath("/session"); + assert.equal(segments.length, 1); + assert.equal(segments[0].type, "session"); + assert.strictEqual(segments[0].value, true); } } } diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index 13fbcd44..67cbf04c 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -19,10 +19,46 @@ import {ViewModel} from "../ViewModel.js"; export class RoomGridViewModel extends ViewModel { constructor(options) { super(options); + this._width = options.width; this._height = options.height; + this._createRoomViewModel = options.createRoomViewModel; + this._selectedIndex = 0; - this._viewModels = []; + this._viewModels = (options.roomIds || []).map(roomId => { + if (roomId) { + const vm = this._createRoomViewModel(roomId); + if (vm) { + return this.track(vm); + } + } + }); + this._setupNavigation(); + } + + _setupNavigation() { + const focusTileIndex = this.navigation.observe("empty-grid-tile"); + this.track(focusTileIndex.subscribe(index => { + if (typeof index === "number") { + this._setFocusIndex(index); + } + })); + if (typeof focusTileIndex.get() === "number") { + this._selectedIndex = focusTileIndex.get(); + } + + const focusedRoom = this.navigation.get("room"); + this.track(focusedRoom.subscribe(roomId => { + if (roomId) { + this._openRoom(roomId); + } + })); + if (focusedRoom.get()) { + const index = this._viewModels.findIndex(vm => vm && vm.id === focusedRoom.get()); + if (index >= 0) { + this._selectedIndex = index; + } + } } roomViewModelAt(i) { @@ -33,15 +69,6 @@ export class RoomGridViewModel extends ViewModel { return this._selectedIndex; } - setFocusIndex(idx) { - if (idx === this._selectedIndex) { - return; - } - this._selectedIndex = idx; - const vm = this._viewModels[this._selectedIndex]; - vm?.focus(); - this.emitChange("focusedIndex"); - } get width() { return this._width; } @@ -50,41 +77,91 @@ export class RoomGridViewModel extends ViewModel { return this._height; } - /** - * Sets a pair of room and room tile view models at the current index - * @param {RoomViewModel} vm - * @package - */ - setRoomViewModel(vm) { - const old = this._viewModels[this._selectedIndex]; - this.disposeTracked(old); - this._viewModels[this._selectedIndex] = this.track(vm); - this.emitChange(`${this._selectedIndex}`); + focusTile(index) { + if (index === this._selectedIndex) { + return; + } + let path = this.navigation.path; + const vm = this._viewModels[index]; + if (vm) { + path = path.with(this.navigation.segment("room", vm.id)); + } else { + path = path.with(this.navigation.segment("empty-grid-tile", index)); + } + let url = this.urlRouter.urlForPath(path); + url = this.urlRouter.applyUrl(url); + this.urlRouter.history.pushUrl(url); } - /** - * @package - */ - tryFocusRoom(roomId) { + /** called from SessionViewModel */ + setRoomIds(roomIds) { + let changed = false; + const len = this._height * this._width; + for (let i = 0; i < len; i += 1) { + const newId = roomIds[i]; + const vm = this._viewModels[i]; + if (newId && !vm) { + this._viewModels[i] = this.track(this._createRoomViewModel(newId)); + changed = true; + } else if (newId !== vm?.id) { + this._viewModels[i] = this.disposeTracked(this._viewModels[i]); + if (newId) { + this._viewModels[i] = this.track(this._createRoomViewModel(newId)); + } + changed = true; + } + } + if (changed) { + this.emitChange(); + } + } + + /** called from SessionViewModel */ + transferRoomViewModel(index, roomVM) { + const oldVM = this._viewModels[index]; + this.disposeTracked(oldVM); + this._viewModels[index] = this.track(roomVM); + } + + /** called from SessionViewModel */ + releaseRoomViewModel(roomId) { + const index = this._viewModels.findIndex(vm => vm.id === roomId); + if (index !== -1) { + const vm = this._viewModels[index]; + this.untrack(vm); + this._viewModels[index] = null; + return vm; + } + } + + _setFocusIndex(idx) { + if (idx === this._selectedIndex || idx >= (this._width * this._height)) { + return; + } + this._selectedIndex = idx; + const vm = this._viewModels[this._selectedIndex]; + vm?.focus(); + this.emitChange("focusedIndex"); + } + + _setFocusRoom(roomId) { const index = this._viewModels.findIndex(vm => vm.id === roomId); if (index >= 0) { - this.setFocusIndex(index); + this._setFocusIndex(index); return true; } return false; } - - /** - * Returns the first set of room vm, - * and untracking it so it is not owned by this view model anymore. - * @package - */ - getAndUntrackFirst() { - for (const vm of this._viewModels) { + + _openRoom(roomId) { + if (!this._setFocusRoom(roomId)) { + // replace vm at focused index + const vm = this._viewModels[this._selectedIndex]; if (vm) { - this.untrack(vm); - return vm; + this.disposeTracked(vm); } + this._viewModels[this._selectedIndex] = this.track(this._createRoomViewModel(roomId)); + this.emitChange(); } } } diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 8acab44a..56a22b75 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -32,27 +32,33 @@ export class SessionViewModel extends ViewModel { session: sessionContainer.session, }))); this._leftPanelViewModel = new LeftPanelViewModel(this.childOptions({ - rooms: this._sessionContainer.session.rooms, - // this will go over navigation as well - gridEnabled: { - get: () => !!this._gridViewModel, - set: value => this._enabledGrid(value) - } + rooms: this._sessionContainer.session.rooms })); this._currentRoomViewModel = null; this._gridViewModel = null; - // this gives us the active room, also in the grid? - this.track(this.navigator.observe("room").subscribe(roomId => { + this._setupNavigation(); + } - })); + _setupNavigation() { + const gridRooms = this.navigation.observe("rooms"); // this gives us a set of room ids in the grid - this.track(this.navigator.observe("rooms").subscribe(value => { - if (value) { - const roomIds = typeof value === "string" ? value.split(",") : []; - // also update grid - this._enabledGrid(roomIds); + this.track(gridRooms.subscribe(roomIds => { + this._updateGrid(roomIds); + })); + if (gridRooms.get()) { + this._updateGrid(gridRooms.get()); + } + + const currentRoomId = this.navigation.observe("room"); + // this gives us the active room + this.track(currentRoomId.subscribe(roomId => { + if (!this._gridViewModel) { + this._openRoom(roomId); } })); + if (currentRoomId.get() && !this._gridViewModel) { + this._openRoom(currentRoomId.get()); + } } start() { @@ -88,53 +94,109 @@ export class SessionViewModel extends ViewModel { return this._currentRoomViewModel; } - // TODO: this should also happen based on URLs - _enabledGrid(enabled) { - if (enabled) { - this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({width: 3, height: 2}))); - // transfer current room - if (this._currentRoomViewModel) { - this.untrack(this._currentRoomViewModel); - this._gridViewModel.setRoomViewModel(this._currentRoomViewModel); - this._currentRoomViewModel = null; + // _transitionToGrid() { + // if (this._gridViewModel) { + // return; + // } + // this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({width: 3, height: 2}))); + // let path; + // if (this._currentRoomViewModel) { + // this.untrack(this._currentRoomViewModel); + // this._gridViewModel.transferRoomViewModel(0, this._currentRoomViewModel); + // const roomId = this._currentRoomViewModel.id; + // this._currentRoomViewModel = null; + // path = this.navigation.path + // .with(this.navigation.segment("rooms", [roomId])) + // .with(this.navigation.segment("room", roomId)); + // } else { + // path = this.navigation.path + // .with(this.navigation.segment("rooms", [])) + // .with(this.navigation.segment("empty-grid-tile", 0)); + // } + // const url = this.urlRouter.urlForPath(path); + // this.urlRouter.history.pushUrl(url); + // this.emitChange("middlePanelViewType"); + // this.navigation.applyPath(path); + // } + + // _transitionFromGrid() { + // if (!this._gridViewModel) { + // return; + // } + // const vm = this._gridViewModel.releaseFirstRoomViewModel(); + // let path = this.navigation.path.until("session"); + // if (vm) { + // path = path.with(this.navigation.segment("room", vm.id)); + // this._currentRoomViewModel = this.track(vm); + // } + // this._gridViewModel = this.disposeTracked(this._gridViewModel); + + // const url = this.urlRouter.urlForPath(path); + // this.urlRouter.history.pushUrl(url); + // this.emitChange("middlePanelViewType"); + // this.navigation.applyPath(path); + // } + + _updateGrid(roomIds) { + const changed = !(this._gridViewModel && roomIds); + const currentRoomId = this.navigation.path.get("room"); + if (roomIds) { + if (!this._gridViewModel) { + this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({ + width: 3, + height: 2, + createRoomViewModel: roomId => this._createRoomViewModel(roomId), + roomIds: roomIds + }))); + const vm = this._currentRoomViewModel; + const index = roomIds.indexOf(vm.id); + if (vm && index !== -1) { + this.untrack(vm); + this._gridViewModel.transferRoomViewModel(index, vm); + this._currentRoomViewModel = null; + } + } else { + this._gridViewModel.setRoomIds(roomIds); } - } else { - const vm = this._gridViewModel.getAndUntrackFirst(); - if (vm) { + } else if (this._gridViewModel && !roomIds) { + if (currentRoomId) { + const vm = this._gridViewModel.releaseRoomViewModel(currentRoomId.value); this._currentRoomViewModel = this.track(vm); } this._gridViewModel = this.disposeTracked(this._gridViewModel); } - this.emitChange("middlePanelViewType"); + if (changed) { + this.emitChange("middlePanelViewType"); + } } - _openRoom(roomId) { - // already open? - if (this._gridViewModel?.tryFocusRoom(roomId)) { - return; - } else if (this._currentRoomViewModel?.id === roomId) { - return; - } + _createRoomViewModel(roomId) { const room = this._session.rooms.get(roomId); - // not found? close current room and show placeholder if (!room) { - if (this._gridViewModel) { - this._gridViewModel.setRoomViewModel(null); - } else { - this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); - } - return; + return null; } const roomVM = new RoomViewModel(this.childOptions({ room, ownUserId: this._sessionContainer.session.user.id, })); roomVM.load(); + return roomVM; + } + + _openRoom(roomId) { + // already open? + if (this._currentRoomViewModel?.id === roomId) { + return; + } + this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); + const roomVM = this._createRoomViewModel(roomId); + if (roomVM) { + this._currentRoomViewModel = this.track(roomVM); + } if (this._gridViewModel) { this._gridViewModel.setRoomViewModel(roomVM); } else { this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); - this._currentRoomViewModel = this.track(roomVM); this.emitChange("currentRoom"); } } diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index 50569b2e..6dfce6e1 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -34,15 +34,44 @@ export class LeftPanelViewModel extends ViewModel { }); this._roomListFilterMap = new ApplyMap(roomTileVMs); this._roomList = this._roomListFilterMap.sortValues((a, b) => a.compare(b)); + this._currentTileVM = null; + this._setupNavigation(); } - get gridEnabled() { - return this._gridEnabled.get(); + _setupNavigation() { + const roomObservable = this.navigation.observe("room"); + this.track(roomObservable.subscribe(roomId => this._open(roomId))); + this._open(roomObservable.get()); + + const gridObservable = this.navigation.observe("rooms"); + this.gridEnabled = !!gridObservable.get(); + this.track(gridObservable.subscribe(roomIds => { + const changed = this.gridEnabled ^ !!roomIds; + this.gridEnabled = !!roomIds; + if (changed) { + this.emitChange("gridEnabled"); + } + })); + } + + _open(roomId) { + this._currentTileVM?.close(); + this._currentTileVM = null; + if (roomId) { + this._currentTileVM = this._roomListFilterMap.get(roomId); + this._currentTileVM?.open(); + } } toggleGrid() { - this._gridEnabled.set(!this._gridEnabled.get()); - this.emitChange("gridEnabled"); + let url; + if (this._gridEnabled) { + url = this.urlRouter.disableGridUrl(); + } else { + url = this.urlRouter.enableGridUrl(); + } + url = this.urlRouter.applyUrl(url); + this.urlRouter.history.pushUrl(url); } get roomList() { diff --git a/src/domain/session/leftpanel/RoomTileViewModel.js b/src/domain/session/leftpanel/RoomTileViewModel.js index 112e1ffa..9b4260ae 100644 --- a/src/domain/session/leftpanel/RoomTileViewModel.js +++ b/src/domain/session/leftpanel/RoomTileViewModel.js @@ -31,7 +31,7 @@ export class RoomTileViewModel extends ViewModel { this._isOpen = false; this._wasUnreadWhenOpening = false; this._hidden = false; - this._url = this.urlRouter.urlForSegment("room", this._room.id); + this._url = this.urlRouter.openRoomActionUrl(this._room.id); } get hidden() { diff --git a/src/ui/web/dom/History.js b/src/ui/web/dom/History.js index 797f108f..5a5794ae 100644 --- a/src/ui/web/dom/History.js +++ b/src/ui/web/dom/History.js @@ -35,26 +35,27 @@ export class History extends BaseObservableValue { return document.location.hash; } + /** does not emit */ replaceUrl(url) { window.history.replaceState(null, null, url); - // replaceState does not cause hashchange - this.emit(url); } + /** does not emit */ pushUrl(url) { - const hash = this.urlAsPath(url); - // important to check before we expect an echo - // as setting the hash to it's current value doesn't - // trigger onhashchange - if (hash === document.location.hash) { - return; - } - // this operation is silent, - // so avoid emitting on echo hashchange event - if (this._boundOnHashChange) { - this._expectSetEcho = true; - } - document.location.hash = hash; + window.history.pushState(null, null, url); + // const hash = this.urlAsPath(url); + // // important to check before we expect an echo + // // as setting the hash to it's current value doesn't + // // trigger onhashchange + // if (hash === document.location.hash) { + // return; + // } + // // this operation is silent, + // // so avoid emitting on echo hashchange event + // if (this._boundOnHashChange) { + // this._expectSetEcho = true; + // } + // document.location.hash = hash; } urlAsPath(url) { diff --git a/src/ui/web/session/RoomGridView.js b/src/ui/web/session/RoomGridView.js index 29eeb329..88e3e9ab 100644 --- a/src/ui/web/session/RoomGridView.js +++ b/src/ui/web/session/RoomGridView.js @@ -23,8 +23,8 @@ export class RoomGridView extends TemplateView { const children = []; for (let i = 0; i < (vm.height * vm.width); i+=1) { children.push(t.div({ - onClick: () => vm.setFocusIndex(i), - onFocusin: () => vm.setFocusIndex(i), + onClick: () => vm.focusTile(i), + onFocusin: () => vm.focusTile(i), className: { "container": true, [`tile${i}`]: true, diff --git a/src/ui/web/session/leftpanel/RoomTileView.js b/src/ui/web/session/leftpanel/RoomTileView.js index 31c49b66..fde02c25 100644 --- a/src/ui/web/session/leftpanel/RoomTileView.js +++ b/src/ui/web/session/leftpanel/RoomTileView.js @@ -25,22 +25,19 @@ export class RoomTileView extends TemplateView { "hidden": vm => vm.hidden }; return t.li({"className": classes}, [ - renderAvatar(t, vm, 32), - t.div({className: "description"}, [ - t.div({className: {"name": true, unread: vm => vm.isUnread}}, vm => vm.name), - t.div({ - className: { - "badge": true, - highlighted: vm => vm.isHighlighted, - hidden: vm => !vm.badgeCount - } - }, vm => vm.badgeCount), + t.a({href: vm.url}, [ + renderAvatar(t, vm, 32), + t.div({className: "description"}, [ + t.div({className: {"name": true, unread: vm => vm.isUnread}}, vm => vm.name), + t.div({ + className: { + "badge": true, + highlighted: vm => vm.isHighlighted, + hidden: vm => !vm.badgeCount + } + }, vm => vm.badgeCount), + ]) ]) ]); } - - // called from ListView - clicked() { - this.value.open(); - } }