diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 0a77d2a0..f7d28fed 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -1,5 +1,6 @@ /* Copyright 2020 Bruno Windels +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. @@ -14,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {RoomTileViewModel} from "./roomlist/RoomTileViewModel.js"; +import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js"; import {RoomViewModel} from "./room/RoomViewModel.js"; import {SessionStatusViewModel} from "./SessionStatusViewModel.js"; import {ViewModel} from "../ViewModel.js"; @@ -29,22 +30,22 @@ export class SessionViewModel extends ViewModel { reconnector: sessionContainer.reconnector, session: sessionContainer.session, }))); + this._leftPanelViewModel = new LeftPanelViewModel(this.childOptions({ + rooms: this._session.rooms, + openRoom: this._openRoom.bind(this) + })); this._currentRoomTileViewModel = null; this._currentRoomViewModel = null; - const roomTileVMs = this._session.rooms.mapValues((room, emitChange) => { - return new RoomTileViewModel({ - room, - emitChange, - emitOpen: this._openRoom.bind(this) - }); - }); - this._roomList = roomTileVMs.sortValues((a, b) => a.compare(b)); } start() { this._sessionStatusViewModel.start(); } + get leftPanelViewModel() { + return this._leftPanelViewModel; + } + get sessionStatusViewModel() { return this._sessionStatusViewModel; } diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js new file mode 100644 index 00000000..50864b4c --- /dev/null +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -0,0 +1,58 @@ +/* +Copyright 2020 Bruno Windels +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"; +import {RoomTileViewModel} from "./RoomTileViewModel.js"; +import {RoomFilter} from "./RoomFilter.js"; +import {ApplyMap} from "../../../observable/map/ApplyMap.js"; + +export class LeftPanelViewModel extends ViewModel { + constructor(options) { + super(options); + const {rooms, openRoom} = options; + const roomTileVMs = rooms.mapValues((room, emitChange) => { + return new RoomTileViewModel({ + room, + emitChange, + emitOpen: openRoom + }); + }); + this._roomListFilterMap = new ApplyMap(roomTileVMs); + this._roomList = this._roomListFilterMap.sortValues((a, b) => a.compare(b)); + } + + get roomList() { + return this._roomList; + } + + clearFilter() { + this._roomListFilterMap.setApply(null); + this._roomListFilterMap.applyOnce((roomId, vm) => vm.hidden = false); + } + + setFilter(query) { + query = query.trim(); + if (query.length === 0) { + this.clearFilter(); + } else { + const filter = new RoomFilter(query); + this._roomListFilterMap.setApply((roomId, vm) => { + vm.hidden = !filter.matches(vm); + }); + } + } +} diff --git a/src/domain/session/leftpanel/RoomFilter.js b/src/domain/session/leftpanel/RoomFilter.js new file mode 100644 index 00000000..cdbe2cb2 --- /dev/null +++ b/src/domain/session/leftpanel/RoomFilter.js @@ -0,0 +1,26 @@ +/* +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. +*/ + +export class RoomFilter { + constructor(query) { + this._parts = query.split(" ").map(s => s.toLowerCase().trim()); + } + + matches(roomTileVM) { + const name = roomTileVM.name.toLowerCase(); + return this._parts.every(p => name.includes(p)); + } +} diff --git a/src/domain/session/roomlist/RoomTileViewModel.js b/src/domain/session/leftpanel/RoomTileViewModel.js similarity index 93% rename from src/domain/session/roomlist/RoomTileViewModel.js rename to src/domain/session/leftpanel/RoomTileViewModel.js index 76cab067..86f832c4 100644 --- a/src/domain/session/roomlist/RoomTileViewModel.js +++ b/src/domain/session/leftpanel/RoomTileViewModel.js @@ -1,5 +1,6 @@ /* Copyright 2020 Bruno Windels +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. @@ -29,6 +30,18 @@ export class RoomTileViewModel extends ViewModel { this._emitOpen = emitOpen; this._isOpen = false; this._wasUnreadWhenOpening = false; + this._hidden = false; + } + + get hidden() { + return this._hidden; + } + + set hidden(value) { + if (value !== this._hidden) { + this._hidden = value; + this.emitChange("hidden"); + } } // called by parent for now (later should integrate with router) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 3c2f0e8c..708efd29 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -426,7 +426,7 @@ export function tests() { function createStorageMock(session, pendingEvents = []) { return { readTxn() { - return Promise.resolve({ + return { session: { get(key) { return Promise.resolve(session[key]); @@ -442,7 +442,7 @@ export function tests() { return Promise.resolve([]); } } - }); + }; }, storeNames: {} }; diff --git a/src/observable/map/ApplyMap.js b/src/observable/map/ApplyMap.js new file mode 100644 index 00000000..2a2dc19c --- /dev/null +++ b/src/observable/map/ApplyMap.js @@ -0,0 +1,81 @@ +/* +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 {BaseObservableMap} from "./BaseObservableMap.js"; + +export class ApplyMap extends BaseObservableMap { + constructor(source, apply) { + super(); + this._source = source; + this._apply = apply; + this._subscription = null; + } + + setApply(apply) { + this._apply = apply; + if (apply) { + this.applyOnce(this._apply); + } + } + + applyOnce(apply) { + for (const [key, value] of this._source) { + apply(key, value); + } + } + + onAdd(key, value) { + if (this._apply) { + this._apply(key, value); + } + this.emitAdd(key, value); + } + + onRemove(key, value) { + this.emitRemove(key, value); + } + + onUpdate(key, value, params) { + if (this._apply) { + this._apply(key, value, params); + } + this.emitUpdate(key, value, params); + } + + onSubscribeFirst() { + this._subscription = this._source.subscribe(this); + if (this._apply) { + this.applyOnce(this._apply); + } + super.onSubscribeFirst(); + } + + onUnsubscribeLast() { + super.onUnsubscribeLast(); + this._subscription = this._subscription(); + } + + onReset() { + if (this._apply) { + this.applyOnce(this._apply); + } + this.emitReset(); + } + + [Symbol.iterator]() { + return this._source[Symbol.iterator](); + } +} diff --git a/src/observable/map/FilteredMap.js b/src/observable/map/FilteredMap.js index 59d8efa1..d07c3228 100644 --- a/src/observable/map/FilteredMap.js +++ b/src/observable/map/FilteredMap.js @@ -17,56 +17,149 @@ limitations under the License. import {BaseObservableMap} from "./BaseObservableMap.js"; export class FilteredMap extends BaseObservableMap { - constructor(source, mapper, updater) { + constructor(source, filter) { super(); this._source = source; - this._mapper = mapper; - this._updater = updater; - this._mappedValues = new Map(); + this._filter = filter; + /** @type {Map} */ + this._included = null; + this._subscription = null; } - onAdd(key, value) { - const mappedValue = this._mapper(value); - this._mappedValues.set(key, mappedValue); - this.emitAdd(key, mappedValue); + setFilter(filter) { + this._filter = filter; + this.update(); } - onRemove(key, _value) { - const mappedValue = this._mappedValues.get(key); - if (this._mappedValues.delete(key)) { - this.emitRemove(key, mappedValue); + /** + * reapply the filter + */ + update() { + // TODO: need to check if we have a subscriber already? If not, we really should not iterate the source? + if (this._filter) { + const hadFilterBefore = !!this._included; + this._included = this._included || new Map(); + for (const [key, value] of this._source) { + const isIncluded = this._filter(value, key); + const wasIncluded = hadFilterBefore ? this._included.get(key) : true; + this._included.set(key, isIncluded); + this._emitForUpdate(wasIncluded, isIncluded, key, value); + } + } else { // no filter + // did we have a filter before? + if (this._included) { + // add any non-included items again + for (const [key, value] of this._source) { + if (!this._included.get(key)) { + this.emitAdd(key, value); + } + } + } + this._included = null; } } - onChange(key, value, params) { - const mappedValue = this._mappedValues.get(key); - if (mappedValue !== undefined) { - const newParams = this._updater(value, params); - if (newParams !== undefined) { - this.emitChange(key, mappedValue, newParams); + onAdd(key, value) { + if (this._filter) { + const included = this._filter(value, key); + this._included.set(key, included); + if (!included) { + return; } } + this.emitAdd(key, value); + } + + onRemove(key, value) { + if (this._filter && !this._included.get(key)) { + return; + } + this.emitRemove(key, value); + } + + onUpdate(key, value, params) { + if (this._filter) { + const wasIncluded = this._included.get(key); + const isIncluded = this._filter(value, key); + this._included.set(key, isIncluded); + this._emitForUpdate(wasIncluded, isIncluded, key, value, params); + } + this.emitUpdate(key, value, params); + } + + _emitForUpdate(wasIncluded, isIncluded, key, value, params = null) { + if (wasIncluded && !isIncluded) { + this.emitRemove(key, value); + } else if (!wasIncluded && isIncluded) { + this.emitAdd(key, value); + } else if (wasIncluded && isIncluded) { + this.emitUpdate(key, value, params); + } } onSubscribeFirst() { - for (let [key, value] of this._source) { - const mappedValue = this._mapper(value); - this._mappedValues.set(key, mappedValue); - } + this._subscription = this._source.subscribe(this); + this.update(); super.onSubscribeFirst(); } onUnsubscribeLast() { super.onUnsubscribeLast(); - this._mappedValues.clear(); + this._included = null; + this._subscription = this._subscription(); } onReset() { - this._mappedValues.clear(); + this.update(); this.emitReset(); } [Symbol.iterator]() { - return this._mappedValues.entries()[Symbol.iterator]; + return new FilterIterator(this._source, this._included); } } + +class FilterIterator { + constructor(map, _included) { + this._included = _included; + this._sourceIterator = map.entries(); + } + + next() { + // eslint-disable-next-line no-constant-condition + while (true) { + const sourceResult = this._sourceIterator.next(); + if (sourceResult.done) { + return sourceResult; + } + const key = sourceResult.value[1]; + if (this._included.get(key)) { + return sourceResult; + } + } + } +} + +// import {ObservableMap} from "./ObservableMap.js"; +// export function tests() { +// return { +// "filter preloaded list": assert => { +// const source = new ObservableMap(); +// source.add("one", 1); +// source.add("two", 2); +// source.add("three", 3); +// const odds = Array.from(new FilteredMap(source, x => x % 2 !== 0)); +// assert.equal(odds.length, 2); + +// }, +// "filter added values": assert => { + +// }, +// "filter removed values": assert => { + +// }, +// "filter changed values": assert => { + +// }, +// } +// } diff --git a/src/observable/map/MappedMap.js b/src/observable/map/MappedMap.js index 7b7d1583..28b7d1e8 100644 --- a/src/observable/map/MappedMap.js +++ b/src/observable/map/MappedMap.js @@ -67,6 +67,7 @@ export class MappedMap extends BaseObservableMap { } onUnsubscribeLast() { + super.onUnsubscribeLast(); this._subscription = this._subscription(); this._mappedValues.clear(); } diff --git a/src/ui/web/css/left-panel.css b/src/ui/web/css/left-panel.css index cde12111..6833341d 100644 --- a/src/ui/web/css/left-panel.css +++ b/src/ui/web/css/left-panel.css @@ -14,10 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ - .LeftPanel { - overflow-y: auto; - overscroll-behavior: contain; + display: flex; + flex-direction: column; +} + +.LeftPanel .filter { + display: flex; +} + +.LeftPanel .filter input { + display: block; + flex: 1; + box-sizing: border-box; } .LeftPanel ul { @@ -26,19 +35,25 @@ limitations under the License. margin: 0; } -.LeftPanel li { +.RoomList { + flex: 1 0 0; + overflow-y: auto; + overscroll-behavior: contain; +} + +.RoomList li { display: flex; align-items: center; } -.LeftPanel div.description { +.RoomList .description { margin: 0; flex: 1 1 0; min-width: 0; display: flex; } -.LeftPanel .description > .name { +.RoomList .description > .name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; diff --git a/src/ui/web/session/SessionView.js b/src/ui/web/session/SessionView.js index 73e6bf98..a66fcd4b 100644 --- a/src/ui/web/session/SessionView.js +++ b/src/ui/web/session/SessionView.js @@ -14,70 +14,29 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ListView} from "../general/ListView.js"; -import {RoomTile} from "./RoomTile.js"; +import {LeftPanelView} from "./leftpanel/LeftPanelView.js"; import {RoomView} from "./room/RoomView.js"; -import {SwitchView} from "../general/SwitchView.js"; +import {TemplateView} from "../general/TemplateView.js"; import {RoomPlaceholderView} from "./RoomPlaceholderView.js"; import {SessionStatusView} from "./SessionStatusView.js"; -import {tag} from "../general/html.js"; -export class SessionView { - constructor(viewModel) { - this._viewModel = viewModel; - this._middleSwitcher = null; - this._roomList = null; - this._currentRoom = null; - this._root = null; - this._onViewModelChange = this._onViewModelChange.bind(this); - } - - root() { - return this._root; - } - - mount() { - this._viewModel.on("change", this._onViewModelChange); - this._sessionStatusBar = new SessionStatusView(this._viewModel.sessionStatusViewModel); - this._roomList = new ListView( - { - className: "RoomList", - list: this._viewModel.roomList, - onItemClick: (roomTile, event) => roomTile.clicked(event) - }, - (room) => new RoomTile(room) - ); - this._middleSwitcher = new SwitchView(new RoomPlaceholderView()); - - this._root = tag.div({className: "SessionView"}, [ - this._sessionStatusBar.mount(), - tag.div({className: "main"}, [ - tag.div({className: "LeftPanel"}, this._roomList.mount()), - this._middleSwitcher.mount() +export class SessionView extends TemplateView { + render(t, vm) { + return t.div({ + className: "SessionView", + "room-shown": vm => !!vm.currentRoom + }, [ + 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(); + } + }) ]) ]); - - return this._root; } - - unmount() { - this._roomList.unmount(); - this._middleSwitcher.unmount(); - this._viewModel.off("change", this._onViewModelChange); - } - - _onViewModelChange(prop) { - if (prop === "currentRoom") { - if (this._viewModel.currentRoom) { - this._root.classList.add("room-shown"); - this._middleSwitcher.switch(new RoomView(this._viewModel.currentRoom)); - } else { - this._root.classList.remove("room-shown"); - this._middleSwitcher.switch(new RoomPlaceholderView()); - } - } - } - - // changing viewModel not supported for now - update() {} } diff --git a/src/ui/web/session/leftpanel/LeftPanelView.js b/src/ui/web/session/leftpanel/LeftPanelView.js new file mode 100644 index 00000000..f262e954 --- /dev/null +++ b/src/ui/web/session/leftpanel/LeftPanelView.js @@ -0,0 +1,55 @@ +/* +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 {ListView} from "../../general/ListView.js"; +import {TemplateView} from "../../general/TemplateView.js"; +import {RoomTileView} from "./RoomTileView.js"; + +export class LeftPanelView extends TemplateView { + render(t, vm) { + 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), + onKeydown: event => { + if (event.key === "Escape" || event.key === "Esc") { + filterInput.value = ""; + vm.clearFilter(); + } + } + }); + return t.div({className: "LeftPanel"}, [ + t.div({className: "filter"}, [ + filterInput, + t.button({onClick: () => { + filterInput.value = ""; + vm.clearFilter(); + }}, vm.i18n`Clear`) + ]), + t.view(new ListView( + { + className: "RoomList", + list: vm.roomList, + onItemClick: (roomTile, event) => roomTile.clicked(event) + }, + roomTileVM => new RoomTileView(roomTileVM) + )) + ]); + } +} diff --git a/src/ui/web/session/RoomTile.js b/src/ui/web/session/leftpanel/RoomTileView.js similarity index 58% rename from src/ui/web/session/RoomTile.js rename to src/ui/web/session/leftpanel/RoomTileView.js index 0486bfe2..31c49b66 100644 --- a/src/ui/web/session/RoomTile.js +++ b/src/ui/web/session/leftpanel/RoomTileView.js @@ -1,5 +1,6 @@ /* Copyright 2020 Bruno Windels +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. @@ -14,16 +15,26 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../general/TemplateView.js"; -import {renderAvatar} from "../common.js"; +import {TemplateView} from "../../general/TemplateView.js"; +import {renderAvatar} from "../../common.js"; -export class RoomTile extends TemplateView { +export class RoomTileView extends TemplateView { render(t, vm) { - return t.li({"className": {"active": vm => vm.isOpen}}, [ + const classes = { + "active": vm => vm.isOpen, + "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.div({ + className: { + "badge": true, + highlighted: vm => vm.isHighlighted, + hidden: vm => !vm.badgeCount + } + }, vm => vm.badgeCount), ]) ]); }