Merge pull request #138 from vector-im/bwindels/filter-room-list

Room list filtering
This commit is contained in:
Bruno Windels 2020-10-06 11:08:39 +00:00 committed by GitHub
commit a7db44eabf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 419 additions and 106 deletions

View File

@ -1,5 +1,6 @@
/* /*
Copyright 2020 Bruno Windels <bruno@windels.cloud> Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
import {RoomTileViewModel} from "./roomlist/RoomTileViewModel.js"; import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js";
import {RoomViewModel} from "./room/RoomViewModel.js"; import {RoomViewModel} from "./room/RoomViewModel.js";
import {SessionStatusViewModel} from "./SessionStatusViewModel.js"; import {SessionStatusViewModel} from "./SessionStatusViewModel.js";
import {ViewModel} from "../ViewModel.js"; import {ViewModel} from "../ViewModel.js";
@ -29,22 +30,22 @@ export class SessionViewModel extends ViewModel {
reconnector: sessionContainer.reconnector, reconnector: sessionContainer.reconnector,
session: sessionContainer.session, session: sessionContainer.session,
}))); })));
this._leftPanelViewModel = new LeftPanelViewModel(this.childOptions({
rooms: this._session.rooms,
openRoom: this._openRoom.bind(this)
}));
this._currentRoomTileViewModel = null; this._currentRoomTileViewModel = null;
this._currentRoomViewModel = 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() { start() {
this._sessionStatusViewModel.start(); this._sessionStatusViewModel.start();
} }
get leftPanelViewModel() {
return this._leftPanelViewModel;
}
get sessionStatusViewModel() { get sessionStatusViewModel() {
return this._sessionStatusViewModel; return this._sessionStatusViewModel;
} }

View File

@ -0,0 +1,58 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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);
});
}
}
}

View File

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

View File

@ -1,5 +1,6 @@
/* /*
Copyright 2020 Bruno Windels <bruno@windels.cloud> Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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._emitOpen = emitOpen;
this._isOpen = false; this._isOpen = false;
this._wasUnreadWhenOpening = 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) // called by parent for now (later should integrate with router)

View File

@ -426,7 +426,7 @@ export function tests() {
function createStorageMock(session, pendingEvents = []) { function createStorageMock(session, pendingEvents = []) {
return { return {
readTxn() { readTxn() {
return Promise.resolve({ return {
session: { session: {
get(key) { get(key) {
return Promise.resolve(session[key]); return Promise.resolve(session[key]);
@ -442,7 +442,7 @@ export function tests() {
return Promise.resolve([]); return Promise.resolve([]);
} }
} }
}); };
}, },
storeNames: {} storeNames: {}
}; };

View File

@ -0,0 +1,81 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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]();
}
}

View File

@ -17,56 +17,149 @@ limitations under the License.
import {BaseObservableMap} from "./BaseObservableMap.js"; import {BaseObservableMap} from "./BaseObservableMap.js";
export class FilteredMap extends BaseObservableMap { export class FilteredMap extends BaseObservableMap {
constructor(source, mapper, updater) { constructor(source, filter) {
super(); super();
this._source = source; this._source = source;
this._mapper = mapper; this._filter = filter;
this._updater = updater; /** @type {Map<string, bool>} */
this._mappedValues = new Map(); this._included = null;
this._subscription = null;
}
setFilter(filter) {
this._filter = filter;
this.update();
}
/**
* 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;
}
} }
onAdd(key, value) { onAdd(key, value) {
const mappedValue = this._mapper(value); if (this._filter) {
this._mappedValues.set(key, mappedValue); const included = this._filter(value, key);
this.emitAdd(key, mappedValue); this._included.set(key, included);
if (!included) {
return;
}
}
this.emitAdd(key, value);
} }
onRemove(key, _value) { onRemove(key, value) {
const mappedValue = this._mappedValues.get(key); if (this._filter && !this._included.get(key)) {
if (this._mappedValues.delete(key)) { return;
this.emitRemove(key, mappedValue);
} }
this.emitRemove(key, value);
} }
onChange(key, value, params) { onUpdate(key, value, params) {
const mappedValue = this._mappedValues.get(key); if (this._filter) {
if (mappedValue !== undefined) { const wasIncluded = this._included.get(key);
const newParams = this._updater(value, params); const isIncluded = this._filter(value, key);
if (newParams !== undefined) { this._included.set(key, isIncluded);
this.emitChange(key, mappedValue, newParams); 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() { onSubscribeFirst() {
for (let [key, value] of this._source) { this._subscription = this._source.subscribe(this);
const mappedValue = this._mapper(value); this.update();
this._mappedValues.set(key, mappedValue);
}
super.onSubscribeFirst(); super.onSubscribeFirst();
} }
onUnsubscribeLast() { onUnsubscribeLast() {
super.onUnsubscribeLast(); super.onUnsubscribeLast();
this._mappedValues.clear(); this._included = null;
this._subscription = this._subscription();
} }
onReset() { onReset() {
this._mappedValues.clear(); this.update();
this.emitReset(); this.emitReset();
} }
[Symbol.iterator]() { [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 => {
// },
// }
// }

View File

@ -67,6 +67,7 @@ export class MappedMap extends BaseObservableMap {
} }
onUnsubscribeLast() { onUnsubscribeLast() {
super.onUnsubscribeLast();
this._subscription = this._subscription(); this._subscription = this._subscription();
this._mappedValues.clear(); this._mappedValues.clear();
} }

View File

@ -14,10 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.LeftPanel { .LeftPanel {
overflow-y: auto; display: flex;
overscroll-behavior: contain; flex-direction: column;
}
.LeftPanel .filter {
display: flex;
}
.LeftPanel .filter input {
display: block;
flex: 1;
box-sizing: border-box;
} }
.LeftPanel ul { .LeftPanel ul {
@ -26,19 +35,25 @@ limitations under the License.
margin: 0; margin: 0;
} }
.LeftPanel li { .RoomList {
flex: 1 0 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.RoomList li {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.LeftPanel div.description { .RoomList .description {
margin: 0; margin: 0;
flex: 1 1 0; flex: 1 1 0;
min-width: 0; min-width: 0;
display: flex; display: flex;
} }
.LeftPanel .description > .name { .RoomList .description > .name {
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;

View File

@ -14,70 +14,29 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ListView} from "../general/ListView.js"; import {LeftPanelView} from "./leftpanel/LeftPanelView.js";
import {RoomTile} from "./RoomTile.js";
import {RoomView} from "./room/RoomView.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 {RoomPlaceholderView} from "./RoomPlaceholderView.js";
import {SessionStatusView} from "./SessionStatusView.js"; import {SessionStatusView} from "./SessionStatusView.js";
import {tag} from "../general/html.js";
export class SessionView { export class SessionView extends TemplateView {
constructor(viewModel) { render(t, vm) {
this._viewModel = viewModel; return t.div({
this._middleSwitcher = null; className: "SessionView",
this._roomList = null; "room-shown": vm => !!vm.currentRoom
this._currentRoom = null; }, [
this._root = null; t.view(new SessionStatusView(vm.sessionStatusViewModel)),
this._onViewModelChange = this._onViewModelChange.bind(this); 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();
} }
})
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()
]) ])
]); ]);
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() {}
}

View File

@ -0,0 +1,55 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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)
))
]);
}
}

View File

@ -1,5 +1,6 @@
/* /*
Copyright 2020 Bruno Windels <bruno@windels.cloud> Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
import {TemplateView} from "../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView.js";
import {renderAvatar} from "../common.js"; import {renderAvatar} from "../../common.js";
export class RoomTile extends TemplateView { export class RoomTileView extends TemplateView {
render(t, vm) { 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), renderAvatar(t, vm, 32),
t.div({className: "description"}, [ t.div({className: "description"}, [
t.div({className: {"name": true, unread: vm => vm.isUnread}}, vm => vm.name), 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),
]) ])
]); ]);
} }