diff --git a/scripts/build.mjs b/scripts/build.mjs index f8f08e39..45a668c1 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -302,7 +302,6 @@ async function buildCssLegacy(entryPath, urlMapper = null) { const preCss = await fs.readFile(entryPath, "utf8"); const options = [ postcssImport, - cssvariables(), flexbugsFixes() ]; if (urlMapper) { diff --git a/src/domain/ViewModel.js b/src/domain/ViewModel.js index 7f973ad7..bb651d87 100644 --- a/src/domain/ViewModel.js +++ b/src/domain/ViewModel.js @@ -40,6 +40,12 @@ export class ViewModel extends EventEmitter { return this.disposables.track(disposable); } + untrack(disposable) { + if (this.disposables) { + return this.disposables.untrack(disposable); + } + } + dispose() { if (this.disposables) { this.disposables.dispose(); diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js new file mode 100644 index 00000000..40b7192c --- /dev/null +++ b/src/domain/session/RoomGridViewModel.js @@ -0,0 +1,121 @@ +/* +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. +*/ + +import {ViewModel} from "../ViewModel.js"; + +export class RoomGridViewModel extends ViewModel { + constructor(options) { + super(options); + this._width = options.width; + this._height = options.height; + this._selectedIndex = 0; + this._viewModels = []; + } + + _posToIdx(x, y) { + return (y * this.width) + x; + } + + _idxToX(idx) { + return idx % this.width; + } + + _idxToY(idx) { + return Math.floor(idx / this.width); + } + + roomViewModelAt(x, y) { + return this._viewModels[this._posToIdx(x, y)]?.vm; + } + + get focusX() { + return this._idxToX(this._selectedIndex); + } + + get focusY() { + return this._idxToY(this._selectedIndex); + } + + isFocusAt(x, y) { + return this._posToIdx(x, y) === this._selectedIndex; + } + + setFocusAt(x, y) { + this._setFocusedIndex(this._posToIdx(x, y)); + } + + _setFocusedIndex(idx) { + if (idx === this._selectedIndex) { + return; + } + const oldItem = this._viewModels[this._selectedIndex]; + oldItem?.tileVM?.close(); + this._selectedIndex = idx; + const newItem = this._viewModels[this._selectedIndex]; + if (newItem) { + newItem.vm.focus(); + newItem.tileVM.open(); + } + this.emitChange("focusedIndex"); + } + get width() { + return this._width; + } + + get height() { + return this._height; + } + + /** + * Sets a pair of room and room tile view models at the current index + * @param {RoomViewModel} vm + * @param {RoomTileViewModel} tileVM + * @package + */ + setRoomViewModel(vm, tileVM) { + const old = this._viewModels[this._selectedIndex]; + this.disposeTracked(old?.vm); + old?.tileVM?.close(); + this._viewModels[this._selectedIndex] = {vm: this.track(vm), tileVM}; + this.emitChange(`${this._selectedIndex}`); + } + + /** + * @package + */ + tryFocusRoom(roomId) { + const index = this._viewModels.findIndex(vms => vms?.vm._room.id === roomId); + if (index >= 0) { + this._setFocusedIndex(index); + return true; + } + return false; + } + + /** + * Returns the first set of room and room tile vm, + * and untracking them so they are not owned by this view model anymore. + * @package + */ + getAndUntrackFirst() { + for (const item of this._viewModels) { + if (item) { + this.untrack(item.vm); + return item; + } + } + } +} diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index f7d28fed..beb35872 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -18,6 +18,7 @@ limitations under the License. import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js"; import {RoomViewModel} from "./room/RoomViewModel.js"; import {SessionStatusViewModel} from "./SessionStatusViewModel.js"; +import {RoomGridViewModel} from "./RoomGridViewModel.js"; import {ViewModel} from "../ViewModel.js"; export class SessionViewModel extends ViewModel { @@ -32,16 +33,34 @@ export class SessionViewModel extends ViewModel { }))); this._leftPanelViewModel = new LeftPanelViewModel(this.childOptions({ rooms: this._session.rooms, - openRoom: this._openRoom.bind(this) + openRoom: this._openRoom.bind(this), + gridEnabled: { + get: () => !!this._gridViewModel, + set: value => this._enabledGrid(value) + } })); this._currentRoomTileViewModel = null; this._currentRoomViewModel = null; + this._gridViewModel = null; } start() { this._sessionStatusViewModel.start(); } + get selectionId() { + if (this._currentRoomViewModel) { + return this._currentRoomViewModel.id; + } else if (this._gridViewModel) { + return "roomgrid"; + } + return "placeholder"; + } + + get roomGridViewModel() { + return this._gridViewModel; + } + get leftPanelViewModel() { return this._leftPanelViewModel; } @@ -58,24 +77,60 @@ export class SessionViewModel extends ViewModel { return this._currentRoomViewModel; } + _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._currentRoomTileViewModel); + this._currentRoomViewModel = null; + this._currentRoomTileViewModel = null; + } + } else { + const VMs = this._gridViewModel.getAndUntrackFirst(); + if (VMs) { + this._currentRoomViewModel = this.track(VMs.vm); + this._currentRoomTileViewModel = VMs.tileVM; + this._currentRoomTileViewModel.open(); + } + this._gridViewModel = this.disposeTracked(this._gridViewModel); + } + this.emitChange("middlePanelViewType"); + } + _closeCurrentRoom() { - this._currentRoomTileViewModel?.close(); - this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); + // no closing in grid for now as it is disabled on narrow viewports + if (!this._gridViewModel) { + this._currentRoomTileViewModel?.close(); + this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); + return true; + } } _openRoom(room, roomTileVM) { - this._closeCurrentRoom(); - this._currentRoomTileViewModel = roomTileVM; - this._currentRoomViewModel = this.track(new RoomViewModel(this.childOptions({ + if (this._gridViewModel?.tryFocusRoom(room.id)) { + return; + } else if (this._currentRoomViewModel?.id === room.id) { + return; + } + const roomVM = new RoomViewModel(this.childOptions({ room, ownUserId: this._session.user.id, closeCallback: () => { - this._closeCurrentRoom(); - this.emitChange("currentRoom"); + if (this._closeCurrentRoom()) { + this.emitChange("currentRoom"); + } }, - }))); - this._currentRoomViewModel.load(); - this.emitChange("currentRoom"); + })); + roomVM.load(); + if (this._gridViewModel) { + this._gridViewModel.setRoomViewModel(roomVM, roomTileVM); + } else { + this._closeCurrentRoom(); + this._currentRoomTileViewModel = roomTileVM; + 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 50864b4c..fe5ce05b 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -23,7 +23,8 @@ import {ApplyMap} from "../../../observable/map/ApplyMap.js"; export class LeftPanelViewModel extends ViewModel { constructor(options) { super(options); - const {rooms, openRoom} = options; + const {rooms, openRoom, gridEnabled} = options; + this._gridEnabled = gridEnabled; const roomTileVMs = rooms.mapValues((room, emitChange) => { return new RoomTileViewModel({ room, @@ -35,6 +36,15 @@ export class LeftPanelViewModel extends ViewModel { this._roomList = this._roomListFilterMap.sortValues((a, b) => a.compare(b)); } + get gridEnabled() { + return this._gridEnabled.get(); + } + + toggleGrid() { + this._gridEnabled.set(!this._gridEnabled.get()); + this.emitChange("gridEnabled"); + } + get roomList() { return this._roomList; } diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index ab453e01..7c8df7bc 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -51,10 +51,18 @@ export class RoomViewModel extends ViewModel { this._timelineError = err; this.emitChange("error"); } + this._clearUnreadAfterDelay(); + } + + async _clearUnreadAfterDelay() { + if (this._clearUnreadTimout) { + return; + } this._clearUnreadTimout = this.clock.createTimeout(2000); try { await this._clearUnreadTimout.elapsed(); await this._room.clearUnread(); + this._clearUnreadTimout = null; } catch (err) { if (err.name !== "AbortError") { throw err; @@ -62,6 +70,10 @@ export class RoomViewModel extends ViewModel { } } + focus() { + this._clearUnreadAfterDelay(); + } + dispose() { super.dispose(); if (this._clearUnreadTimout) { @@ -86,6 +98,10 @@ export class RoomViewModel extends ViewModel { return this._room.name || this.i18n`Empty Room`; } + get id() { + return this._room.id; + } + get timelineViewModel() { return this._timelineVM; } diff --git a/src/ui/web/css/form.css b/src/ui/web/css/form.css index 741d2bfd..e9771611 100644 --- a/src/ui/web/css/form.css +++ b/src/ui/web/css/form.css @@ -20,3 +20,13 @@ limitations under the License. width: 100%; box-sizing: border-box; } + +.FilterField { + display: flex; +} + +.FilterField input { + display: block; + flex: 1; + min-width: 0; +} diff --git a/src/ui/web/css/layout.css b/src/ui/web/css/layout.css index c1a47eb2..7a30381b 100644 --- a/src/ui/web/css/layout.css +++ b/src/ui/web/css/layout.css @@ -48,8 +48,11 @@ html { /* mobile layout */ @media screen and (max-width: 800px) { + /* show back button */ .RoomHeader button.back { display: block; } - div.RoomView, div.RoomPlaceholderView { display: none; } + /* hide grid button */ + .LeftPanel button.grid { display: none; } + div.RoomView, div.RoomPlaceholderView, div.RoomGridView { display: none; } div.LeftPanel {flex-grow: 1;} div.room-shown div.RoomView { display: flex; } div.room-shown div.LeftPanel { display: none; } @@ -61,7 +64,7 @@ html { min-width: 0; } -.RoomPlaceholderView, .RoomView { +.RoomPlaceholderView, .RoomView, .RoomGridView { flex: 1 0 0; min-width: 0; } @@ -88,3 +91,22 @@ html { .RoomHeader { display: flex; } + +.RoomGridView { + display: grid; + grid-template-columns: repeat(var(--columns), 1fr); + grid-template-rows: repeat(var(--rows), 1fr); +} + +.RoomGridView > div { + display: flex; + min-width: 0; + min-height: 0; + grid-column: var(--column); + grid-row: var(--row); +} + +.RoomGridView > div.focus-ring { + z-index: 1; + pointer-events: none; +} diff --git a/src/ui/web/css/left-panel.css b/src/ui/web/css/left-panel.css index 6833341d..5a7ae221 100644 --- a/src/ui/web/css/left-panel.css +++ b/src/ui/web/css/left-panel.css @@ -19,14 +19,12 @@ limitations under the License. flex-direction: column; } -.LeftPanel .filter { +.LeftPanel .utilities { display: flex; } -.LeftPanel .filter input { - display: block; +.LeftPanel .utilities .FilterField { flex: 1; - box-sizing: border-box; } .LeftPanel ul { diff --git a/src/ui/web/css/themes/element/icons/clear.svg b/src/ui/web/css/themes/element/icons/clear.svg new file mode 100644 index 00000000..9227cf4d --- /dev/null +++ b/src/ui/web/css/themes/element/icons/clear.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/web/css/themes/element/icons/disable-grid.svg b/src/ui/web/css/themes/element/icons/disable-grid.svg new file mode 100644 index 00000000..1be4ae6a --- /dev/null +++ b/src/ui/web/css/themes/element/icons/disable-grid.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/web/css/themes/element/icons/enable-grid.svg b/src/ui/web/css/themes/element/icons/enable-grid.svg new file mode 100644 index 00000000..776fec35 --- /dev/null +++ b/src/ui/web/css/themes/element/icons/enable-grid.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/web/css/themes/element/theme.css b/src/ui/web/css/themes/element/theme.css index 3e627eb7..41a9b82f 100644 --- a/src/ui/web/css/themes/element/theme.css +++ b/src/ui/web/css/themes/element/theme.css @@ -117,6 +117,68 @@ button.styled { font-weight: 500; } +button.utility { + width: 32px; + height: 32px; + background-position: center; + background-color: #e1e3e6; + background-repeat: no-repeat; + border: none; + border-radius: 100%; +} + +button.utility.grid { + background-image: url('icons/enable-grid.svg'); +} + +button.utility.grid.on { + background-image: url('icons/disable-grid.svg'); +} + +.FilterField { + background-color: #e1e3e6; + border-radius: 16px; + height: 32px; + align-items: center; + padding: 0 8px; + box-sizing: border-box; +} + +.FilterField :not(:first-child) { + margin-left: 8px; +} + +.FilterField:focus-within { + border: 1px #e1e3e6 solid; + background-color: white; +} + +/*.FilterField:not(:focus-within) button { + display: none; +}*/ + +.FilterField input { + font-family: "Inter"; + font-size: 1.3rem; + font-weight: 500; + line-height: 1.573rem; + outline: none; + border: none; + background-color: transparent; + height: 100%; +} + +.FilterField button { + width: 16px; + height: 16px; + background-position: center; + background-color: #e1e3e6; + background-repeat: no-repeat; + background-image: url('icons/clear.svg'); + border: none; + border-radius: 100%; +} + .PreSessionScreen { padding: 30px; } @@ -141,6 +203,15 @@ button.styled { .LeftPanel { background: rgba(245, 245, 245, 0.90); font-size: 1.5rem; + padding: 12px 0 0 8px; +} + +.LeftPanel .utilities { + margin-right: 8px; +} + +.LeftPanel .utilities :not(:first-child) { + margin-left: 8px; } .LeftPanel .filter { @@ -158,8 +229,8 @@ button.styled { } .LeftPanel li { - margin: 3px 10px; - padding: 5px; + margin: 12px 0; + padding-right: 5px; /* vertical align */ align-items: center; } @@ -211,6 +282,7 @@ a { background-color: #3D88FA; color: white; border-radius: 10px; + z-index: 2; } .room-shown .SessionStatusView { @@ -220,6 +292,8 @@ a { .RoomPlaceholderView { align-items: center; justify-content: center; + text-align: center; + padding: 20px; } .SessionPickerView li { @@ -258,6 +332,16 @@ a { color: #FF4B55; } +.RoomGridView > div.container { + border-right: 1px solid rgba(245, 245, 245, 0.90); + border-bottom: 1px solid rgba(245, 245, 245, 0.90); +} + +.RoomGridView > div.focus-ring { + border: 2px solid rgba(134, 193, 165, 1); + border-radius: 12px; +} + .RoomHeader { background: rgba(245, 245, 245, 0.90); padding: 10px; diff --git a/src/ui/web/session/RoomGridView.js b/src/ui/web/session/RoomGridView.js new file mode 100644 index 00000000..235af6da --- /dev/null +++ b/src/ui/web/session/RoomGridView.js @@ -0,0 +1,49 @@ +/* +Copyright 2020 Bruno Windels + +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 {RoomView} from "./room/RoomView.js"; +import {RoomPlaceholderView} from "./RoomPlaceholderView.js"; +import {TemplateView} from "../general/TemplateView.js"; + +export class RoomGridView extends TemplateView { + render(t, vm) { + const children = []; + for (let y = 0; y < vm.height; y+=1) { + for (let x = 0; x < vm.width; x+=1) { + children.push(t.div({ + onClick: () => vm.setFocusAt(x, y), + onFocusin: () => vm.setFocusAt(x, y), + className: { + "container": true, + "focused": vm => vm.isFocusAt(x, y) + }, + style: `--column: ${x + 1}; --row: ${y + 1}` + },t.mapView(vm => vm.roomViewModelAt(x, y), roomVM => { + if (roomVM) { + return new RoomView(roomVM); + } else { + return new RoomPlaceholderView(); + } + }))); + } + } + children.push(t.div({className: "focus-ring", style: vm => `--column: ${vm.focusX + 1}; --row: ${vm.focusY + 1}`})); + return t.div({ + className: "RoomGridView", + style: `--columns: ${vm.width}; --rows: ${vm.height}` + }, children); + } +} diff --git a/src/ui/web/session/SessionView.js b/src/ui/web/session/SessionView.js index eb8d3153..f997c0d0 100644 --- a/src/ui/web/session/SessionView.js +++ b/src/ui/web/session/SessionView.js @@ -19,23 +19,27 @@ import {RoomView} from "./room/RoomView.js"; import {TemplateView} from "../general/TemplateView.js"; import {RoomPlaceholderView} from "./RoomPlaceholderView.js"; import {SessionStatusView} from "./SessionStatusView.js"; +import {RoomGridView} from "./RoomGridView.js"; export class SessionView extends TemplateView { render(t, vm) { return t.div({ className: { "SessionView": true, - "room-shown": vm => !!vm.currentRoom + "room-shown": vm => vm.selectionId !== "placeholder" }, }, [ t.view(new SessionStatusView(vm.sessionStatusViewModel)), t.div({className: "main"}, [ t.view(new LeftPanelView(vm.leftPanelViewModel)), - t.mapView(vm => vm.currentRoom, currentRoom => { - if (currentRoom) { - return new RoomView(currentRoom); - } else { - return new RoomPlaceholderView(); + t.mapView(vm => vm.selectionId, selectionId => { + switch (selectionId) { + case "roomgrid": + return new RoomGridView(vm.roomGridViewModel); + case "placeholder": + return new RoomPlaceholderView(); + default: //room id + return new RoomView(vm.currentRoom); } }) ]) diff --git a/src/ui/web/session/leftpanel/LeftPanelView.js b/src/ui/web/session/leftpanel/LeftPanelView.js index f262e954..3eeaaada 100644 --- a/src/ui/web/session/leftpanel/LeftPanelView.js +++ b/src/ui/web/session/leftpanel/LeftPanelView.js @@ -18,30 +18,67 @@ import {ListView} from "../../general/ListView.js"; import {TemplateView} from "../../general/TemplateView.js"; import {RoomTileView} from "./RoomTileView.js"; -export class LeftPanelView extends TemplateView { - render(t, vm) { +class FilterField extends TemplateView { + render(t, options) { + const clear = () => { + filterInput.value = ""; + filterInput.blur(); + clearButton.blur(); + options.clear(); + }; const filterInput = t.input({ type: "text", - placeholder: vm.i18n`Filter rooms…`, - "aria-label": vm.i18n`Filter rooms by name`, - autocomplete: true, - name: "room-filter", - onInput: event => vm.setFilter(event.target.value), + placeholder: options?.label, + "aria-label": options?.label, + autocomplete: options?.autocomplete, + name: options?.name, + onInput: event => options.set(event.target.value), onKeydown: event => { if (event.key === "Escape" || event.key === "Esc") { - filterInput.value = ""; - vm.clearFilter(); + clear(); } - } + }, + onFocus: () => filterInput.select() }); + const clearButton = t.button({ + onClick: clear, + title: options.i18n`Clear`, + "aria-label": options.i18n`Clear` + }); + return t.div({className: "FilterField"}, [filterInput, clearButton]); + } +} + +export class LeftPanelView extends TemplateView { + render(t, vm) { + const gridButtonLabel = vm => { + return vm.gridEnabled ? + vm.i18n`Show single room` : + vm.i18n`Enable grid layout`; + }; + const utilitiesRow = t.div({className: "utilities"}, [ + t.view(new FilterField({ + i18n: vm.i18n, + label: vm.i18n`Filter rooms…`, + name: "room-filter", + autocomplete: true, + set: query => vm.setFilter(query), + clear: () => vm.clearFilter() + })), + t.button({ + onClick: () => vm.toggleGrid(), + className: { + utility: true, + grid: true, + on: vm => vm.gridEnabled + }, + title: gridButtonLabel, + "aria-label": gridButtonLabel + }) + ]); + return t.div({className: "LeftPanel"}, [ - t.div({className: "filter"}, [ - filterInput, - t.button({onClick: () => { - filterInput.value = ""; - vm.clearFilter(); - }}, vm.i18n`Clear`) - ]), + utilitiesRow, t.view(new ListView( { className: "RoomList", diff --git a/src/utils/Disposables.js b/src/utils/Disposables.js index efc49897..bd13abc2 100644 --- a/src/utils/Disposables.js +++ b/src/utils/Disposables.js @@ -35,6 +35,13 @@ export class Disposables { return disposable; } + untrack(disposable) { + const idx = this._disposables.indexOf(disposable); + if (idx >= 0) { + this._disposables.splice(idx, 1); + } + } + dispose() { if (this._disposables) { for (const d of this._disposables) {