diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index fab91c11..6ce63ffd 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -37,7 +37,9 @@ function allowsChild(parent, child) { // downside of the approach: both of these will control which tile is selected return type === "room" || type === "empty-grid-tile"; case "room": - return type === "lightbox" || type === "details"; + return type === "lightbox" || type === "right-panel"; + case "right-panel": + return type === "details"|| type === "members"; default: return false; } @@ -85,6 +87,23 @@ function roomsSegmentWithRoom(rooms, roomId, path) { } } +function pushRightPanelSegment(array, segment) { + array.push(new Segment("right-panel")); + array.push(new Segment(segment)); +} + +export function addPanelIfNeeded(navigation, path) { + const segments = navigation.path.segments; + const i = segments.findIndex(segment => segment.type === "right-panel"); + let _path = path; + if (i !== -1) { + _path = path.until("room"); + _path = _path.with(segments[i]); + _path = _path.with(segments[i + 1]); + } + return _path; +} + export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) { // substr(1) to take of initial / const parts = urlPath.substr(1).split("/"); @@ -114,7 +133,9 @@ export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) { } segments.push(new Segment("room", roomId)); if (currentNavPath.get("details")?.value) { - segments.push(new Segment("details")); + pushRightPanelSegment(segments, "details"); + } else if (currentNavPath.get("members")?.value) { + pushRightPanelSegment(segments, "members"); } } else if (type === "last-session") { let sessionSegment = currentNavPath.get("session"); @@ -124,6 +145,8 @@ export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) { if (sessionSegment) { segments.push(sessionSegment); } + } else if (type === "details" || type === "members") { + pushRightPanelSegment(segments, type); } else { // might be undefined, which will be turned into true by Segment const value = iterator.next().value; @@ -152,6 +175,9 @@ export function stringifyPath(path) { urlPath += `/${segment.type}/${segment.value}`; } break; + case "right-panel": + // Ignore right-panel in url + continue; default: urlPath += `/${segment.type}`; if (segment.value && segment.value !== true) { @@ -185,6 +211,18 @@ export function tests() { const urlPath = stringifyPath(path); assert.equal(urlPath, "/session/1/rooms/a,b,c/1"); }, + "stringify url with right-panel and details segment": assert => { + const nav = new Navigation(allowsChild); + const path = nav.pathFrom([ + new Segment("session", 1), + new Segment("rooms", ["a", "b", "c"]), + new Segment("room", "b"), + new Segment("right-panel"), + new Segment("details") + ]); + const urlPath = stringifyPath(path); + assert.equal(urlPath, "/session/1/rooms/a,b,c/1/details"); + }, "parse grid url path with focused empty tile": assert => { const segments = parseUrlPath("/session/1/rooms/a,b,c/3"); assert.equal(segments.length, 3); @@ -263,18 +301,21 @@ export function tests() { new Segment("session", 1), new Segment("rooms", ["a", "b", "c"]), new Segment("room", "b"), + new Segment("right-panel", true), new Segment("details", true) ]); const segments = parseUrlPath("/session/1/open-room/a", path); - assert.equal(segments.length, 4); + assert.equal(segments.length, 5); 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, "a"); - assert.equal(segments[3].type, "details"); + assert.equal(segments[3].type, "right-panel"); assert.equal(segments[3].value, true); + assert.equal(segments[4].type, "details"); + assert.equal(segments[4].value, true); }, "parse open-room action setting a room in an empty tile": assert => { const nav = new Navigation(allowsChild); diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index dddc603b..08887f3e 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -15,6 +15,7 @@ limitations under the License. */ import {ViewModel} from "../ViewModel.js"; +import {addPanelIfNeeded} from "../navigation/index.js"; function dedupeSparse(roomIds) { return roomIds.map((id, idx) => { @@ -79,12 +80,9 @@ export class RoomGridViewModel extends ViewModel { } _switchToRoom(roomId) { - const detailsShown = !!this.navigation.path.get("details")?.value; let path = this.navigation.path.until("rooms"); path = path.with(this.navigation.segment("room", roomId)); - if (detailsShown) { - path = path.with(this.navigation.segment("details", true)); - } + path = addPanelIfNeeded(this.navigation, path); this.navigation.applyPath(path); } diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 087b9315..83b7f1af 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -17,7 +17,6 @@ limitations under the License. import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js"; import {RoomViewModel} from "./room/RoomViewModel.js"; -import {RoomDetailsViewModel} from "./rightpanel/RoomDetailsViewModel.js"; import {UnknownRoomViewModel} from "./room/UnknownRoomViewModel.js"; import {InviteViewModel} from "./room/InviteViewModel.js"; import {LightboxViewModel} from "./room/LightboxViewModel.js"; @@ -26,6 +25,7 @@ import {RoomGridViewModel} from "./RoomGridViewModel.js"; import {SettingsViewModel} from "./settings/SettingsViewModel.js"; import {ViewModel} from "../ViewModel.js"; import {RoomViewModelObservable} from "./RoomViewModelObservable.js"; +import {RightPanelViewModel} from "./rightpanel/RightPanelViewModel.js"; export class SessionViewModel extends ViewModel { constructor(options) { @@ -63,7 +63,7 @@ export class SessionViewModel extends ViewModel { if (!this._gridViewModel) { this._updateRoom(roomId); } - this._updateRoomDetails(); + this._updateRightPanel(); })); if (!this._gridViewModel) { this._updateRoom(currentRoomId.get()); @@ -81,9 +81,10 @@ export class SessionViewModel extends ViewModel { })); this._updateLightbox(lightbox.get()); - const details = this.navigation.observe("details"); - this.track(details.subscribe(() => this._updateRoomDetails())); - this._updateRoomDetails(); + + const rightpanel = this.navigation.observe("right-panel"); + this.track(rightpanel.subscribe(() => this._updateRightPanel())); + this._updateRightPanel(); } get id() { @@ -118,8 +119,9 @@ export class SessionViewModel extends ViewModel { return this._roomViewModelObservable?.get(); } - get roomDetailsViewModel() { - return this._roomDetailsViewModel; + + get rightPanelViewModel() { + return this._rightPanelViewModel; } _updateGrid(roomIds) { @@ -256,15 +258,14 @@ export class SessionViewModel extends ViewModel { return room; } - _updateRoomDetails() { - this._roomDetailsViewModel = this.disposeTracked(this._roomDetailsViewModel); - const enable = !!this.navigation.path.get("details")?.value; + _updateRightPanel() { + this._rightPanelViewModel = this.disposeTracked(this._rightPanelViewModel); + const enable = !!this.navigation.path.get("right-panel")?.value; if (enable) { const room = this._roomFromNavigation(); - if (!room) { return; } - this._roomDetailsViewModel = this.track(new RoomDetailsViewModel(this.childOptions({room}))); + this._rightPanelViewModel = this.track(new RightPanelViewModel(this.childOptions({room}))); } - this.emitChange("roomDetailsViewModel"); + this.emitChange("rightPanelViewModel"); } } diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index 061c640c..13583b14 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -20,6 +20,7 @@ import {RoomTileViewModel} from "./RoomTileViewModel.js"; import {InviteTileViewModel} from "./InviteTileViewModel.js"; import {RoomFilter} from "./RoomFilter.js"; import {ApplyMap} from "../../../observable/map/ApplyMap.js"; +import {addPanelIfNeeded} from "../../navigation/index.js"; export class LeftPanelViewModel extends ViewModel { constructor(options) { @@ -92,24 +93,19 @@ export class LeftPanelViewModel extends ViewModel { } } - _pathForDetails(path) { - const details = this.navigation.path.get("details"); - return details?.value ? path.with(details) : path; - } - toggleGrid() { const room = this.navigation.path.get("room"); let path = this.navigation.path.until("session"); if (this.gridEnabled) { if (room) { path = path.with(room); - path = this._pathForDetails(path); + path = addPanelIfNeeded(this.navigation, path); } } else { if (room) { path = path.with(this.navigation.segment("rooms", [room.value])); path = path.with(room); - path = this._pathForDetails(path); + path = addPanelIfNeeded(this.navigation, path); } else { path = path.with(this.navigation.segment("rooms", [])); path = path.with(this.navigation.segment("empty-grid-tile", 0)); diff --git a/src/domain/session/rightpanel/MemberListViewModel.js b/src/domain/session/rightpanel/MemberListViewModel.js new file mode 100644 index 00000000..ce0fff40 --- /dev/null +++ b/src/domain/session/rightpanel/MemberListViewModel.js @@ -0,0 +1,42 @@ +import {ViewModel} from "../../ViewModel.js"; +import {MemberTileViewModel} from "./MemberTileViewModel.js"; +import {createMemberComparator} from "./members/comparator.js"; +import {Disambiguator} from "./members/disambiguator.js"; + +export class MemberListViewModel extends ViewModel { + constructor(options) { + super(options); + const list = options.members; + this.track(() => list.release()); + + const powerLevelsObservable = options.powerLevelsObservable; + this.track(powerLevelsObservable.subscribe(() => { /*resort based on new power levels here*/ })); + + const powerLevels = powerLevelsObservable.get(); + this.memberTileViewModels = this._mapTileViewModels(list.members.filterValues(member => member.membership === "join")) + .sortValues(createMemberComparator(powerLevels)); + this.nameDisambiguator = new Disambiguator(); + this.mediaRepository = options.mediaRepository; + } + + get type() { return "member-list"; } + + get shouldShowBackButton() { return true; } + + get previousSegmentName() { return "details"; } + + _mapTileViewModels(members) { + const mapper = (member, emitChange) => { + const mediaRepository = this.mediaRepository; + const vm = new MemberTileViewModel(this.childOptions({member, emitChange, mediaRepository})); + this.nameDisambiguator.disambiguate(vm); + return vm; + } + const updater = (vm, params, newMember) => { + vm.updateFrom(newMember); + this.nameDisambiguator.disambiguate(vm); + }; + return members.mapValues(mapper, updater); + } + +} diff --git a/src/domain/session/rightpanel/MemberTileViewModel.js b/src/domain/session/rightpanel/MemberTileViewModel.js new file mode 100644 index 00000000..23a7eeef --- /dev/null +++ b/src/domain/session/rightpanel/MemberTileViewModel.js @@ -0,0 +1,68 @@ +import {ViewModel} from "../../ViewModel.js"; +import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; + +export class MemberTileViewModel extends ViewModel { + constructor(options) { + super(options); + this._member = this._options.member; + this._mediaRepository = options.mediaRepository + this._previousName = null; + this._nameChanged = true; + } + + get name() { + return `${this._member.name}${this._disambiguationPart}`; + } + + get _disambiguationPart() { + return this._disambiguate ? ` (${this.userId})` : ""; + } + + get userId() { + return this._member.userId; + } + + get previousName() { + return this._previousName; + } + + get nameChanged() { + return this._nameChanged; + } + + _updatePreviousName(newName) { + const currentName = this._member.name; + if (currentName !== newName) { + this._previousName = currentName; + this._nameChanged = true; + } else { + this._nameChanged = false; + } + } + + setDisambiguation(status) { + this._disambiguate = status; + this.emitChange(); + } + + updateFrom(newMember) { + this._updatePreviousName(newMember.name); + this._member = newMember; + } + + get avatarLetter() { + return avatarInitials(this.name); + } + + get avatarColorNumber() { + return getIdentifierColorNumber(this.userId) + } + + avatarUrl(size) { + return getAvatarHttpUrl(this._member.avatarUrl, size, this.platform, this._mediaRepository); + } + + get avatarTitle() { + return this.name; + } +} diff --git a/src/domain/session/rightpanel/RightPanelViewModel.js b/src/domain/session/rightpanel/RightPanelViewModel.js new file mode 100644 index 00000000..c56080a8 --- /dev/null +++ b/src/domain/session/rightpanel/RightPanelViewModel.js @@ -0,0 +1,62 @@ +import {ViewModel} from "../../ViewModel.js"; +import {RoomDetailsViewModel} from "./RoomDetailsViewModel.js"; +import {MemberListViewModel} from "./MemberListViewModel.js"; + +export class RightPanelViewModel extends ViewModel { + constructor(options) { + super(options); + this._room = options.room; + this._setupNavigation(); + } + + get activeViewModel() { return this._activeViewModel; } + + async _getMemberArguments() { + const members = await this._room.loadMemberList(); + const room = this._room; + const powerLevelsObservable = await this._room.observePowerLevels(); + return {members, powerLevelsObservable, mediaRepository: room.mediaRepository}; + } + + _setupNavigation() { + this._hookUpdaterToSegment("details", RoomDetailsViewModel, () => { return {room: this._room}; }); + this._hookUpdaterToSegment("members", MemberListViewModel, () => this._getMemberArguments()); + } + + _hookUpdaterToSegment(segment, viewmodel, argCreator) { + const observable = this.navigation.observe(segment); + const updater = this._setupUpdater(segment, viewmodel, argCreator); + this.track(observable.subscribe(() => updater())); + } + + _setupUpdater(segment, viewmodel, argCreator) { + const updater = async (skipDispose = false) => { + if (!skipDispose) { + this._activeViewModel = this.disposeTracked(this._activeViewModel); + } + const enable = !!this.navigation.path.get(segment)?.value; + if (enable) { + const args = await argCreator(); + this._activeViewModel = this.track(new viewmodel(this.childOptions(args))); + } + this.emitChange("activeViewModel"); + }; + updater(true); + return updater; + } + + closePanel() { + const path = this.navigation.path.until("room"); + this.navigation.applyPath(path); + } + + showPreviousPanel() { + const segmentName = this.activeViewModel.previousSegmentName; + if (segmentName) { + let path = this.navigation.path.until("room"); + path = path.with(this.navigation.segment("right-panel", true)); + path = path.with(this.navigation.segment(segmentName, true)); + this.navigation.applyPath(path); + } + } +} diff --git a/src/domain/session/rightpanel/RoomDetailsViewModel.js b/src/domain/session/rightpanel/RoomDetailsViewModel.js index b9f05835..111e444c 100644 --- a/src/domain/session/rightpanel/RoomDetailsViewModel.js +++ b/src/domain/session/rightpanel/RoomDetailsViewModel.js @@ -9,6 +9,18 @@ export class RoomDetailsViewModel extends ViewModel { this._room.on("change", this._onRoomChange); } + get type() { + return "room-details"; + } + + get shouldShowBackButton() { + return false; + } + + get previousSegmentName() { + return false; + } + get roomId() { return this._room.id; } @@ -49,13 +61,15 @@ export class RoomDetailsViewModel extends ViewModel { this.emitChange(); } - closePanel() { - const path = this.navigation.path.until("room"); - this.navigation.applyPath(path); - } - dispose() { super.dispose(); this._room.off("change", this._onRoomChange); } + + openPanel(segment) { + let path = this.navigation.path.until("room"); + path = path.with(this.navigation.segment("right-panel", true)); + path = path.with(this.navigation.segment(segment, true)); + this.navigation.applyPath(path); + } } diff --git a/src/domain/session/rightpanel/members/comparator.js b/src/domain/session/rightpanel/members/comparator.js new file mode 100644 index 00000000..7b87abe7 --- /dev/null +++ b/src/domain/session/rightpanel/members/comparator.js @@ -0,0 +1,97 @@ +import {PowerLevels} from "../../../../matrix/room/timeline/PowerLevels.js"; + +export function createMemberComparator(powerLevels) { + const collator = new Intl.Collator(); + const removeCharacter = string => string.charAt(0) === "@"? string.slice(1) : string; + + return function comparator(member, otherMember) { + const p1 = powerLevels.getUserLevel(member.userId); + const p2 = powerLevels.getUserLevel(otherMember.userId); + if (p1 !== p2) { return p2 - p1; } + const name = removeCharacter(member.name); + const otherName = removeCharacter(otherMember.name); + return collator.compare(name, otherName); + }; +} + +export function tests() { + + function createComparatorWithPowerLevel(map) { + let users = {}; + for (const prop in map) { + Object.assign(users, {[prop]: map[prop]}); + } + const powerLevelEvent = { + content: {users, users_default: 0} + }; + return createMemberComparator(new PowerLevels({powerLevelEvent})); + } + + return { + "power_level(member1) > power_level(member2) returns value <= 0": assert => { + const fn = createComparatorWithPowerLevel({"@alice:hs.tld": 50}); + const member1 = {userId: "@alice:hs.tld", name: "alice"}; + const member2 = {userId: "@bob:hs.tld", name: "bob"}; + assert.strictEqual(fn(member1, member2) <= 0, true); + }, + + "power_level(member1) < power_level(member2) returns value > 0": assert => { + const fn = createComparatorWithPowerLevel({"@alice:hs.tld": 50}); + const member1 = {userId: "@bob:hs.tld", name: "bob"}; + const member2 = {userId: "@alice:hs.tld", name: "alice"}; + assert.strictEqual(fn(member1, member2) > 0, true); + }, + + "alphabetic compare on name": assert => { + const fn = createComparatorWithPowerLevel(); + const member1 = {userId: "@bob:hs.tld", name: "bob"}; + const member2 = {userId: "@alice:hs.tld", name: "alice"}; + assert.strictEqual(fn(member1, member2) > 0, true); + assert.strictEqual(fn(member2, member1) <= 0, true); + }, + + "alphabetic compare with case (alice comes before Bob)": assert => { + const fn = createComparatorWithPowerLevel(); + const member1 = {userId: "@bob:hs.tld", name: "Bob"}; + const member2 = {userId: "@alice:hs.tld", name: "alice"}; + assert.strictEqual(fn(member1, member2) > 0, true); + assert.strictEqual(fn(member2, member1) <= 0, true); + }, + + "equal powerlevel and same names returns 0": assert => { + const fn = createComparatorWithPowerLevel({"@bobby:hs.tld": 50, "@bob:hs.tld": 50}); + const member1 = {userId: "@bob:hs.tld", name: "bob"}; + const member2 = {userId: "@bobby:hs.tld", name: "bob"}; + assert.strictEqual(fn(member1, member2), 0); + assert.strictEqual(fn(member2, member1), 0); + }, + + "(both_negative_powerlevel) power_level(member1) < power_level(member2) returns value > 0": assert => { + const fn = createComparatorWithPowerLevel({"@alice:hs.tld": -100, "@bob:hs.tld": -50}); + const member1 = {userId: "@alice:hs.tld", name: "alice"}; + const member2 = {userId: "@bob:hs.tld", name: "bob"}; + assert.strictEqual(fn(member1, member2) > 0, true); + }, + + "(both_negative_powerlevel) power_level(member1) > power_level(member2) returns value <= 0": assert => { + const fn = createComparatorWithPowerLevel({"@alice:hs.tld": -50, "@bob:hs.tld": -100}); + const member1 = {userId: "@alice:hs.tld", name: "alice"}; + const member2 = {userId: "@bob:hs.tld", name: "bob"}; + assert.strictEqual(fn(member1, member2) <= 0, true); + }, + + "(one_negative_powerlevel) power_level(member1) > power_level(member2) returns value <= 0": assert => { + const fn = createComparatorWithPowerLevel({"@alice:hs.tld": 50, "@bob:hs.tld": -100}); + const member1 = {userId: "@alice:hs.tld", name: "alice"}; + const member2 = {userId: "@bob:hs.tld", name: "bob"}; + assert.strictEqual(fn(member1, member2) <= 0, true); + }, + + "(one_negative_powerlevel) power_level(member1) < power_level(member2) returns value > 0": assert => { + const fn = createComparatorWithPowerLevel({"@alice:hs.tld": -100, "@bob:hs.tld": 50}); + const member1 = {userId: "@alice:hs.tld", name: "alice"}; + const member2 = {userId: "@bob:hs.tld", name: "bob"}; + assert.strictEqual(fn(member1, member2) > 0, true); + }, + }; +} diff --git a/src/domain/session/rightpanel/members/disambiguator.js b/src/domain/session/rightpanel/members/disambiguator.js new file mode 100644 index 00000000..d6468031 --- /dev/null +++ b/src/domain/session/rightpanel/members/disambiguator.js @@ -0,0 +1,156 @@ +export class Disambiguator { + constructor() { + this._map = new Map(); + } + + _unDisambiguate(vm, array) { + const idx = array.indexOf(vm); + if (idx !== -1) { + const [removed] = array.splice(idx, 1); + removed.setDisambiguation(false); + } + } + + _handlePreviousName(vm) { + const previousName = vm.previousName; + if (typeof previousName !== "string") { return; } + const value = this._map.get(previousName); + if (Array.isArray(value)) { + this._unDisambiguate(vm, value); + if (value.length === 1) { + const vm = value[0]; + vm.setDisambiguation(false); + this._map.set(previousName, vm); + } + } else { + this._map.delete(previousName); + } + } + + _updateMap(vm) { + const name = vm.name; + const value = this._map.get(name); + if (value) { + if (Array.isArray(value)) { + if (value.findIndex(member => member.userId === vm.userId) !== -1) { return; } + value.push(vm); + return value; + } else if(vm.userId !== value.userId) { + const array = [value, vm] + this._map.set(name, array); + return array; + } + } else { + this._map.set(name, vm); + } + } + + disambiguate(vm) { + if (!vm.nameChanged) { return; } + this._handlePreviousName(vm); + const value = this._updateMap(vm); + value?.forEach((vm) => vm.setDisambiguation(true)); + } +} + +export function tests(){ + + class MockViewModel { + constructor(name, userId) { + this.name = name; + this.disambiguate = false; + this.userId = userId; + this.nameChanged = true; + } + + updateName(newName) { + if (this.name !== newName) { + this.previousName = this.name; + this.nameChanged = true; + } + else { + this.nameChanged = false; + } + this.name = newName; + } + + setDisambiguation(status) { + this.disambiguate = status; + } + } + + function createVmAndDisambiguator(nameList) { + const d = new Disambiguator(); + const array = nameList.map(([name, id]) => new MockViewModel(name, id)); + return [...array, d]; + } + + return { + "Unique names": assert => { + const [vm1, vm2, d] = createVmAndDisambiguator([["foo", "a"], ["bar", "b"]]); + d.disambiguate(vm1); + d.disambiguate(vm2); + assert.strictEqual(vm1.disambiguate, false); + assert.strictEqual(vm2.disambiguate, false); + }, + + "Same names are disambiguated": assert => { + const [vm1, vm2, vm3, d] = createVmAndDisambiguator([["foo", "a"], ["foo", "b"], ["foo", "c"]]); + d.disambiguate(vm1); + d.disambiguate(vm2); + d.disambiguate(vm3); + assert.strictEqual(vm1.disambiguate, true); + assert.strictEqual(vm2.disambiguate, true); + assert.strictEqual(vm3.disambiguate, true); + }, + + "Name updates disambiguate": assert => { + const [vm1, vm2, vm3, d] = createVmAndDisambiguator([["foo", "a"], ["bar", "b"], ["jar", "c"]]); + d.disambiguate(vm1); + d.disambiguate(vm2); + d.disambiguate(vm3); + + vm2.updateName("foo"); + d.disambiguate(vm2); + assert.strictEqual(vm1.disambiguate, true); + assert.strictEqual(vm2.disambiguate, true); + + vm1.updateName("bar"); + d.disambiguate(vm1); + assert.strictEqual(vm1.disambiguate, false); + assert.strictEqual(vm2.disambiguate, false); + + vm3.updateName("foo"); + d.disambiguate(vm3); + vm1.updateName("foo"); + d.disambiguate(vm1); + assert.strictEqual(vm1.disambiguate, true); + assert.strictEqual(vm2.disambiguate, true); + assert.strictEqual(vm3.disambiguate, true); + + vm2.updateName("bar"); + d.disambiguate(vm2); + assert.strictEqual(vm1.disambiguate, true); + assert.strictEqual(vm2.disambiguate, false); + assert.strictEqual(vm3.disambiguate, true); + }, + + "Multiple disambiguate events": assert => { + const [vm1, d] = createVmAndDisambiguator([["foo", "a"]]); + d.disambiguate(vm1); + vm1.updateName(vm1.name); + d.disambiguate(vm1); + assert.strictEqual(vm1.disambiguate, false); + }, + + "Empty names must un-disambiguate": assert => { + const [vm1, vm2, d] = createVmAndDisambiguator([["", "a"], ["", "b"]]); + d.disambiguate(vm1); + d.disambiguate(vm2); + vm1.updateName("foo"); + d.disambiguate(vm1); + assert.strictEqual(vm1.disambiguate, false); + assert.strictEqual(vm2.disambiguate, false); + } + }; +} diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 38835db3..3b04f83a 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -290,6 +290,7 @@ export class RoomViewModel extends ViewModel { openDetailsPanel() { let path = this.navigation.path.until("room"); + path = path.with(this.navigation.segment("right-panel", true)); path = path.with(this.navigation.segment("details", true)); this.navigation.applyPath(path); } diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index 8813512d..51226775 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -189,6 +189,8 @@ import {HomeServer as MockHomeServer} from "../../../../mocks/HomeServer.js"; // other imports import {BaseMessageTile} from "./tiles/BaseMessageTile.js"; import {MappedList} from "../../../../observable/list/MappedList.js"; +import {ObservableValue} from "../../../../observable/ObservableValue.js"; +import {PowerLevels} from "../../../../matrix/room/timeline/PowerLevels.js"; export function tests() { const fragmentIdComparer = new FragmentIdComparer([]); @@ -251,8 +253,9 @@ export function tests() { await txn.complete(); // 2. setup queue & timeline const queue = new SendQueue({roomId, storage, hsApi: new MockHomeServer().api}); + const powerLevelsObservable = new ObservableValue(new PowerLevels({ ownUserId: alice, membership: "join" })); const timeline = new Timeline({roomId, storage, fragmentIdComparer, - clock: new MockClock(), pendingEvents: queue.pendingEvents}); + clock: new MockClock(), pendingEvents: queue.pendingEvents, powerLevelsObservable}); // 3. load the timeline, which will load the message with the reaction await timeline.load(new User(alice), "join", new NullLogItem()); const tiles = mapMessageEntriesToBaseMessageTile(timeline, queue); @@ -310,8 +313,9 @@ export function tests() { await txn.complete(); // 2. setup queue & timeline const queue = new SendQueue({roomId, storage, hsApi: new MockHomeServer().api}); + const powerLevelsObservable = new ObservableValue(new PowerLevels({ ownUserId: alice, membership: "join" })); const timeline = new Timeline({roomId, storage, fragmentIdComparer, - clock: new MockClock(), pendingEvents: queue.pendingEvents}); + clock: new MockClock(), pendingEvents: queue.pendingEvents, powerLevelsObservable}); // 3. load the timeline, which will load the message with the reaction await timeline.load(new User(alice), "join", new NullLogItem()); diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index 9d33c5c5..7ee04761 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -28,6 +28,8 @@ import {EventEntry} from "./timeline/entries/EventEntry.js"; import {ObservedEventMap} from "./ObservedEventMap.js"; import {DecryptionSource} from "../e2ee/common.js"; import {ensureLogItem} from "../../logging/utils.js"; +import {PowerLevels} from "./timeline/PowerLevels.js"; +import {RetainedObservableValue} from "../../observable/ObservableValue.js"; const EVENT_ENCRYPTED_TYPE = "m.room.encrypted"; @@ -50,6 +52,8 @@ export class BaseRoom extends EventEmitter { this._getSyncToken = getSyncToken; this._platform = platform; this._observedEvents = null; + this._powerLevels = null; + this._powerLevelLoading = null; } async _eventIdsToEntries(eventIds, txn) { @@ -388,6 +392,47 @@ export class BaseRoom extends EventEmitter { return this._summary.data.membership; } + async _loadPowerLevels() { + const txn = await this._storage.readTxn([this._storage.storeNames.roomState]); + const powerLevelsState = await txn.roomState.get(this._roomId, "m.room.power_levels", ""); + if (powerLevelsState) { + return new PowerLevels({ + powerLevelEvent: powerLevelsState.event, + ownUserId: this._user.id, + membership: this.membership + }); + } + const createState = await txn.roomState.get(this._roomId, "m.room.create", ""); + if (createState) { + return new PowerLevels({ + createEvent: createState.event, + ownUserId: this._user.id, + membership: this.membership + }); + } else { + const membership = this.membership; + return new PowerLevels({ownUserId: this._user.id, membership}); + } + } + + /** + * Get the PowerLevels of the room. + * Always subscribe to the value returned by this method. + * @returns {RetainedObservableValue} PowerLevels of the room + */ + async observePowerLevels() { + if (this._powerLevelLoading) { await this._powerLevelLoading; } + let observable = this._powerLevels; + if (!observable) { + this._powerLevelLoading = this._loadPowerLevels(); + const powerLevels = await this._powerLevelLoading; + observable = new RetainedObservableValue(powerLevels, () => { this._powerLevels = null; }); + this._powerLevels = observable; + this._powerLevelLoading = null; + } + return observable; + } + enableSessionBackup(sessionBackup) { this._roomEncryption?.enableSessionBackup(sessionBackup); // TODO: do we really want to do this every time you open the app? @@ -429,6 +474,7 @@ export class BaseRoom extends EventEmitter { }, clock: this._platform.clock, logger: this._platform.logger, + powerLevelsObservable: await this.observePowerLevels() }); try { if (this._roomEncryption) { diff --git a/src/matrix/room/members/MemberList.js b/src/matrix/room/members/MemberList.js index 05e6ea9a..de07adf5 100644 --- a/src/matrix/room/members/MemberList.js +++ b/src/matrix/room/members/MemberList.js @@ -28,7 +28,7 @@ export class MemberList extends RetainedValue { afterSync(memberChanges) { for (const [userId, memberChange] of memberChanges.entries()) { - this._members.add(userId, memberChange.member); + this._members.set(userId, memberChange.member); } } diff --git a/src/matrix/room/timeline/PowerLevels.js b/src/matrix/room/timeline/PowerLevels.js index d2fb026d..c19f992d 100644 --- a/src/matrix/room/timeline/PowerLevels.js +++ b/src/matrix/room/timeline/PowerLevels.js @@ -42,10 +42,10 @@ export class PowerLevels { if (this._membership !== "join") { return Number.MIN_SAFE_INTEGER; } - return this._getUserLevel(this._ownUserId); + return this.getUserLevel(this._ownUserId); } - _getUserLevel(userId) { + getUserLevel(userId) { if (this._plEvent) { let userLevel = this._plEvent.content?.users?.[userId]; if (typeof userLevel !== "number") { diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 3d82a284..bd521e62 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -21,12 +21,11 @@ import {Direction} from "./Direction.js"; import {TimelineReader} from "./persistence/TimelineReader.js"; import {PendingEventEntry} from "./entries/PendingEventEntry.js"; import {RoomMember} from "../members/RoomMember.js"; -import {PowerLevels} from "./PowerLevels.js"; import {getRelation, ANNOTATION_RELATION_TYPE} from "./relations.js"; import {REDACTION_TYPE} from "../common.js"; export class Timeline { - constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, clock}) { + constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, clock, powerLevelsObservable}) { this._roomId = roomId; this._storage = storage; this._closeCallback = closeCallback; @@ -44,7 +43,14 @@ export class Timeline { }); this._readerRequest = null; this._allEntries = null; - this._powerLevels = null; + this.initializePowerLevels(powerLevelsObservable); + } + + initializePowerLevels(observable) { + if (observable) { + this._powerLevels = observable.get(); + this._disposables.track(observable.subscribe(powerLevels => this._powerLevels = powerLevels)); + } } /** @package */ @@ -66,7 +72,6 @@ export class Timeline { // as they should only populate once the view subscribes to it // if they are populated already, the sender profile would be empty - this._powerLevels = await this._loadPowerLevels(membership, txn); // 30 seems to be a good amount to fill the entire screen const readerRequest = this._disposables.track(this._timelineReader.readFromEnd(30, txn, log)); try { @@ -78,28 +83,6 @@ export class Timeline { // txn should be assumed to have finished here, as decryption will close it. } - async _loadPowerLevels(membership, txn) { - // TODO: update power levels as state is updated - const powerLevelsState = await txn.roomState.get(this._roomId, "m.room.power_levels", ""); - if (powerLevelsState) { - return new PowerLevels({ - powerLevelEvent: powerLevelsState.event, - ownUserId: this._ownMember.userId, - membership - }); - } - const createState = await txn.roomState.get(this._roomId, "m.room.create", ""); - if (createState) { - return new PowerLevels({ - createEvent: createState.event, - ownUserId: this._ownMember.userId, - membership - }); - } else { - return new PowerLevels({ownUserId: this._ownMember.userId, membership}); - } - } - _setupEntries(timelineEntries) { this._remoteEntries.setManySorted(timelineEntries); if (this._pendingEvents) { diff --git a/src/observable/index.js b/src/observable/index.js index 4c455407..47b68e91 100644 --- a/src/observable/index.js +++ b/src/observable/index.js @@ -34,8 +34,8 @@ Object.assign(BaseObservableMap.prototype, { return new SortedMapList(this, comparator); }, - mapValues(mapper) { - return new MappedMap(this, mapper); + mapValues(mapper, updater) { + return new MappedMap(this, mapper, updater); }, filterValues(filter) { diff --git a/src/observable/map/FilteredMap.js b/src/observable/map/FilteredMap.js index 8e936f5d..f7090502 100644 --- a/src/observable/map/FilteredMap.js +++ b/src/observable/map/FilteredMap.js @@ -91,8 +91,9 @@ export class FilteredMap extends BaseObservableMap { const isIncluded = this._filter(value, key); this._included.set(key, isIncluded); this._emitForUpdate(wasIncluded, isIncluded, key, value, params); + } else { + this.emitUpdate(key, value, params); } - this.emitUpdate(key, value, params); } _emitForUpdate(wasIncluded, isIncluded, key, value, params = null) { @@ -191,5 +192,31 @@ export function tests() { // "filter changed values": assert => { // }, + + "emits must trigger once": assert => { + const source = new ObservableMap(); + let count_add = 0, count_update = 0, count_remove = 0; + source.add("num1", 1); + source.add("num2", 2); + source.add("num3", 3); + const oddMap = new FilteredMap(source, x => x % 2 !== 0); + oddMap.subscribe({ + onAdd() { + count_add += 1; + }, + onRemove() { + count_remove += 1; + }, + onUpdate() { + count_update += 1; + } + }); + source.set("num3", 4); + source.set("num3", 5); + source.set("num3", 7); + assert.strictEqual(count_add, 1); + assert.strictEqual(count_update, 1); + assert.strictEqual(count_remove, 1); + } } } diff --git a/src/observable/map/MappedMap.js b/src/observable/map/MappedMap.js index ec33c4dd..47013df8 100644 --- a/src/observable/map/MappedMap.js +++ b/src/observable/map/MappedMap.js @@ -20,10 +20,11 @@ so a mapped value can emit updates on it's own with this._emitSpontaneousUpdate how should the mapped value be notified of an update though? and can it then decide to not propagate the update? */ export class MappedMap extends BaseObservableMap { - constructor(source, mapper) { + constructor(source, mapper, updater) { super(); this._source = source; this._mapper = mapper; + this._updater = updater; this._mappedValues = new Map(); } @@ -55,6 +56,7 @@ export class MappedMap extends BaseObservableMap { } const mappedValue = this._mappedValues.get(key); if (mappedValue !== undefined) { + this._updater?.(mappedValue, params, value); // TODO: map params somehow if needed? this.emitUpdate(key, mappedValue, params); } diff --git a/src/observable/map/ObservableMap.js b/src/observable/map/ObservableMap.js index 4e9df5bb..b72cd039 100644 --- a/src/observable/map/ObservableMap.js +++ b/src/observable/map/ObservableMap.js @@ -54,6 +54,17 @@ export class ObservableMap extends BaseObservableMap { } } + set(key, value) { + if (this._values.has(key)) { + // We set the value here because update only supports inline updates + this._values.set(key, value); + return this.update(key); + } + else { + return this.add(key, value); + } + } + reset() { this._values.clear(); this.emitReset(); @@ -136,6 +147,31 @@ export function tests() { assert.equal(result, false); }, + test_set(assert) { + let add_fired = 0, update_fired = 0; + const map = new ObservableMap(); + map.subscribe({ + onAdd(key, value) { + add_fired += 1; + assert.equal(key, 1); + assert.deepEqual(value, {value: 5}); + }, + onUpdate(key, value, params) { + update_fired += 1; + assert.equal(key, 1); + assert.deepEqual(value, {value: 7}); + } + }); + // Add + map.set(1, {value: 5}); + assert.equal(map.size, 1); + assert.equal(add_fired, 1); + // Update + map.set(1, {value: 7}); + assert.equal(map.size, 1); + assert.equal(update_fired, 1); + }, + test_remove(assert) { let fired = 0; const map = new ObservableMap(); diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 224d67b6..f1410106 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -37,6 +37,7 @@ import {hasReadPixelPermission, ImageHandle, VideoHandle} from "./dom/ImageHandl import {downloadInIframe} from "./dom/download.js"; import {Disposables} from "../../utils/Disposables.js"; import {parseHTML} from "./parsehtml.js"; +import {handleAvatarError} from "./ui/avatar.js"; function addScript(src) { return new Promise(function (resolve, reject) { @@ -190,6 +191,8 @@ export class Platform { this._disposables.track(disposable); } } + this._container.addEventListener("error", handleAvatarError, true); + this._disposables.track(() => this._container.removeEventListener("error", handleAvatarError, true)); window.__hydrogenViewModel = vm; const view = new RootView(vm); this._container.appendChild(view.mount()); diff --git a/src/platform/web/ui/AvatarView.js b/src/platform/web/ui/AvatarView.js new file mode 100644 index 00000000..1f6f2736 --- /dev/null +++ b/src/platform/web/ui/AvatarView.js @@ -0,0 +1,86 @@ +import {BaseUpdateView} from "./general/BaseUpdateView.js"; +import {renderStaticAvatar, renderImg} from "./avatar.js"; +import {text} from "./general/html.js"; + +/* +optimization to not use a sub view when changing between img and text +because there can be many many instances of this view +*/ + +export class AvatarView extends BaseUpdateView { + /** + * @param {ViewModel} value view model with {avatarUrl, avatarColorNumber, avatarTitle, avatarLetter} + * @param {Number} size + */ + constructor(value, size) { + super(value); + this._root = null; + this._avatarUrl = null; + this._avatarTitle = null; + this._avatarLetter = null; + this._size = size; + } + + _avatarUrlChanged() { + if (this.value.avatarUrl(this._size) !== this._avatarUrl) { + this._avatarUrl = this.value.avatarUrl(this._size); + return true; + } + return false; + } + + _avatarTitleChanged() { + if (this.value.avatarTitle !== this._avatarTitle) { + this._avatarTitle = this.value.avatarTitle; + return true; + } + return false; + } + + _avatarLetterChanged() { + if (this.value.avatarLetter !== this._avatarLetter) { + this._avatarLetter = this.value.avatarLetter; + return true; + } + return false; + } + + mount(options) { + this._avatarUrlChanged(); + this._avatarLetterChanged(); + this._avatarTitleChanged(); + this._root = renderStaticAvatar(this.value, this._size); + // takes care of update being called when needed + super.mount(options); + return this._root; + } + + root() { + return this._root; + } + + update(vm) { + // important to always call _...changed for every prop + if (this._avatarUrlChanged()) { + // avatarColorNumber won't change, it's based on room/user id + const bgColorClass = `usercolor${vm.avatarColorNumber}`; + if (vm.avatarUrl(this._size)) { + this._root.replaceChild(renderImg(vm, this._size), this._root.firstChild); + this._root.classList.remove(bgColorClass); + } else { + this._root.textContent = vm.avatarLetter; + this._root.classList.add(bgColorClass); + } + } + const hasAvatar = !!vm.avatarUrl(this._size); + if (this._avatarTitleChanged() && hasAvatar) { + const element = this._root.firstChild; + if (element.tagName === "IMG") { + element.setAttribute("title", vm.avatarTitle); + } + } + if (this._avatarLetterChanged() && !hasAvatar) { + this._root.textContent = vm.avatarLetter; + } + } +} diff --git a/src/platform/web/ui/avatar.js b/src/platform/web/ui/avatar.js index 8845f887..2e2b0142 100644 --- a/src/platform/web/ui/avatar.js +++ b/src/platform/web/ui/avatar.js @@ -14,90 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {tag, text, classNames} from "./general/html.js"; -import {BaseUpdateView} from "./general/BaseUpdateView.js"; - -/* -optimization to not use a sub view when changing between img and text -because there can be many many instances of this view -*/ - -export class AvatarView extends BaseUpdateView { - /** - * @param {ViewModel} value view model with {avatarUrl, avatarColorNumber, avatarTitle, avatarLetter} - * @param {Number} size - */ - constructor(value, size) { - super(value); - this._root = null; - this._avatarUrl = null; - this._avatarTitle = null; - this._avatarLetter = null; - this._size = size; - } - - _avatarUrlChanged() { - if (this.value.avatarUrl(this._size) !== this._avatarUrl) { - this._avatarUrl = this.value.avatarUrl(this._size); - return true; - } - return false; - } - - _avatarTitleChanged() { - if (this.value.avatarTitle !== this._avatarTitle) { - this._avatarTitle = this.value.avatarTitle; - return true; - } - return false; - } - - _avatarLetterChanged() { - if (this.value.avatarLetter !== this._avatarLetter) { - this._avatarLetter = this.value.avatarLetter; - return true; - } - return false; - } - - mount(options) { - this._avatarUrlChanged(); - this._avatarLetterChanged(); - this._avatarTitleChanged(); - this._root = renderStaticAvatar(this.value, this._size); - // takes care of update being called when needed - super.mount(options); - return this._root; - } - - root() { - return this._root; - } - - update(vm) { - // important to always call _...changed for every prop - if (this._avatarUrlChanged()) { - // avatarColorNumber won't change, it's based on room/user id - const bgColorClass = `usercolor${vm.avatarColorNumber}`; - if (vm.avatarUrl(this._size)) { - this._root.replaceChild(renderImg(vm, this._size), this._root.firstChild); - this._root.classList.remove(bgColorClass); - } else { - this._root.replaceChild(text(vm.avatarLetter), this._root.firstChild); - this._root.classList.add(bgColorClass); - } - } - const hasAvatar = !!vm.avatarUrl(this._size); - if (this._avatarTitleChanged() && hasAvatar) { - const img = this._root.firstChild; - img.setAttribute("title", vm.avatarTitle); - } - if (this._avatarLetterChanged() && !hasAvatar) { - this._root.firstChild.textContent = vm.avatarLetter; - } - } -} - +import {tag, text, classNames, setAttribute} from "./general/html.js"; /** * @param {Object} vm view model with {avatarUrl, avatarColorNumber, avatarTitle, avatarLetter} * @param {Number} size @@ -108,16 +25,36 @@ export function renderStaticAvatar(vm, size, extraClasses = undefined) { let avatarClasses = classNames({ avatar: true, [`size-${size}`]: true, - [`usercolor${vm.avatarColorNumber}`]: !hasAvatar, + [`usercolor${vm.avatarColorNumber}`]: !hasAvatar }); if (extraClasses) { avatarClasses += ` ${extraClasses}`; } const avatarContent = hasAvatar ? renderImg(vm, size) : text(vm.avatarLetter); - return tag.div({className: avatarClasses}, [avatarContent]); + const avatar = tag.div({className: avatarClasses}, [avatarContent]); + if (hasAvatar) { + setAttribute(avatar, "data-avatar-letter", vm.avatarLetter); + setAttribute(avatar, "data-avatar-color", vm.avatarColorNumber); + } + return avatar; } -function renderImg(vm, size) { +export function renderImg(vm, size) { const sizeStr = size.toString(); return tag.img({src: vm.avatarUrl(size), width: sizeStr, height: sizeStr, title: vm.avatarTitle}); } + +function isAvatarEvent(e) { + const element = e.target; + const parent = element.parentElement; + return element.tagName === "IMG" && parent.classList.contains("avatar"); +} + +export function handleAvatarError(e) { + if (!isAvatarEvent(e)) { return; } + const parent = e.target.parentElement; + const avatarColorNumber = parent.getAttribute("data-avatar-color"); + parent.classList.add(`usercolor${avatarColorNumber}`); + const avatarLetter = parent.getAttribute("data-avatar-letter"); + parent.textContent = avatarLetter; +} diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index fecbbd60..3fb23a27 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -208,3 +208,13 @@ the layout viewport up without resizing it when the keyboard shows */ min-height: 0; overflow-y: auto; } + +.LazyListParent { + flex: 1; +} + +.LoadingView { + display: flex; + justify-content: center; + align-items: center; +} diff --git a/src/platform/web/ui/css/right-panel.css b/src/platform/web/ui/css/right-panel.css index f3f34e38..937fa944 100644 --- a/src/platform/web/ui/css/right-panel.css +++ b/src/platform/web/ui/css/right-panel.css @@ -1,8 +1,16 @@ -.RoomDetailsView { +.RightPanelView{ grid-area: right; + min-height: 0; + min-width: 0; + display: flex; flex-direction: column; } +.RoomDetailsView { + flex-direction: column; + flex: 1; +} + .RoomDetailsView_avatar { display: flex; } @@ -11,21 +19,33 @@ text-align: center; } -.RoomDetailsView_row { - justify-content: space-between; -} - .RoomDetailsView_label, .RoomDetailsView_row, .RoomDetailsView, .EncryptionIconView { display: flex; align-items: center; } +.RoomDetailsView_value { + display: flex; + justify-content: flex-end; +} + .EncryptionIconView { justify-content: center; } -.RoomDetailsView_buttons { +.RightPanelView_buttons { display: flex; - justify-content: flex-end; + justify-content: space-between; width: 100%; + box-sizing: border-box; + padding: 16px; +} + +.RightPanelView_buttons .hide { + visibility: hidden; +} + +.MemberTileView { + display: flex; + align-items: center; } diff --git a/src/platform/web/ui/css/themes/element/icons/chevron-small.svg b/src/platform/web/ui/css/themes/element/icons/chevron-small.svg new file mode 100644 index 00000000..741e6be0 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/chevron-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/platform/web/ui/css/themes/element/icons/chevron-thin-left.svg b/src/platform/web/ui/css/themes/element/icons/chevron-thin-left.svg new file mode 100644 index 00000000..092bf4fb --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/chevron-thin-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 885383d7..56eaf224 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -787,11 +787,24 @@ button.link { width: 100%; } +.LoadingView { + height: 100%; + width: 100%; +} + +.LoadingView .spinner { + margin-right: 5px; +} + /* Right Panel */ -.RoomDetailsView { +.RightPanelView { background: rgba(245, 245, 245, 0.90); +} + +.RoomDetailsView { padding: 16px; + padding-top: 0; } .RoomDetailsView_id { @@ -813,6 +826,24 @@ button.link { margin-bottom: 20px; font-weight: 500; font-size: 15px; + width: 100%; + background: none; + border: none; + padding: 0; +} + +button.RoomDetailsView_row { + cursor: pointer; +} + +button.RoomDetailsView_row::after { + content: url("./icons/chevron-small.svg"); + margin-left: 12px; +} + +.RoomDetailsView_row:not(button)::after{ + content: " "; + width: 19px; } .RoomDetailsView_label::before { @@ -821,8 +852,13 @@ button.link { width: 20px; } +.RoomDetailsView_label { + width: 200px; +} + .RoomDetailsView_value { color: #737D8C; + flex: 1; } .MemberCount::before { @@ -857,11 +893,42 @@ button.link { content: url("./icons/e2ee-disabled.svg"); } -.RoomDetailsView .button-utility { +.RightPanelView_buttons .button-utility { width: 24px; height: 24px; } -.RoomDetailsView .close { +.RightPanelView_buttons .close { background-image: url("./icons/clear.svg"); } + +.RightPanelView_buttons .back { + background-image: url("./icons/chevron-thin-left.svg"); +} + +/* Memberlist Panel */ + +.MemberListView { + padding-left: 16px; + padding-right: 16px; + margin: 0; +} + +.MemberTileView { + margin-bottom: 8px; +} + +.MemberTileView .avatar { + margin-right: 8px; +} + +.MemberTileView_name { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + flex: 1; +} + +.LazyListParent { + overflow-y: auto; +} diff --git a/src/platform/web/ui/general/LazyListView.js b/src/platform/web/ui/general/LazyListView.js new file mode 100644 index 00000000..f42084ae --- /dev/null +++ b/src/platform/web/ui/general/LazyListView.js @@ -0,0 +1,263 @@ +import {el} from "./html.js"; +import {mountView} from "./utils.js"; +import {insertAt, ListView} from "./ListView.js"; + +class ItemRange { + constructor(topCount, renderCount, bottomCount) { + this.topCount = topCount; + this.renderCount = renderCount; + this.bottomCount = bottomCount; + } + + contains(range) { + // don't contain empty ranges + // as it will prevent clearing the list + // once it is scrolled far enough out of view + if (!range.renderCount && this.renderCount) { + return false; + } + return range.topCount >= this.topCount && + (range.topCount + range.renderCount) <= (this.topCount + this.renderCount); + } + + containsIndex(idx) { + return idx >= this.topCount && idx <= (this.topCount + this.renderCount); + } + + expand(amount) { + // don't expand ranges that won't render anything + if (this.renderCount === 0) { + return this; + } + + const topGrow = Math.min(amount, this.topCount); + const bottomGrow = Math.min(amount, this.bottomCount); + return new ItemRange( + this.topCount - topGrow, + this.renderCount + topGrow + bottomGrow, + this.bottomCount - bottomGrow, + ); + } + + totalSize() { + return this.topCount + this.renderCount + this.bottomCount; + } + + normalize(idx) { + /* + map index from list to index in rendered range + eg: if the index range of this._list is [0, 200] and we have rendered + elements in range [50, 60] then index 50 in list must map to index 0 + in DOM tree/childInstance array. + */ + return idx - this.topCount; + } +} + +export class LazyListView extends ListView { + constructor({itemHeight, overflowMargin = 5, overflowItems = 20,...options}, childCreator) { + super(options, childCreator); + this._itemHeight = itemHeight; + this._overflowMargin = overflowMargin; + this._overflowItems = overflowItems; + } + + _getVisibleRange() { + const length = this._list ? this._list.length : 0; + const scrollTop = this._parent.scrollTop; + const topCount = Math.min(Math.max(0, Math.floor(scrollTop / this._itemHeight)), length); + const itemsAfterTop = length - topCount; + const visibleItems = this._height !== 0 ? Math.ceil(this._height / this._itemHeight) : 0; + const renderCount = Math.min(visibleItems, itemsAfterTop); + const bottomCount = itemsAfterTop - renderCount; + return new ItemRange(topCount, renderCount, bottomCount); + } + + _renderIfNeeded(forceRender = false) { + /* + forceRender only because we don't optimize onAdd/onRemove yet. + Ideally, onAdd/onRemove should only render whatever has changed + update padding + update renderRange + */ + const range = this._getVisibleRange(); + const intersectRange = range.expand(this._overflowMargin); + const renderRange = range.expand(this._overflowItems); + // only update render Range if the new range + overflowMargin isn't contained by the old anymore + // or if we are force rendering + if (forceRender || !this._renderRange.contains(intersectRange)) { + this._renderRange = renderRange; + this._renderElementsInRange(); + } + } + + async _initialRender() { + /* + Wait two frames for the return from mount() to be inserted into DOM. + This should be enough, but if this gives us trouble we can always use + MutationObserver. + */ + await new Promise(r => requestAnimationFrame(r)); + await new Promise(r => requestAnimationFrame(r)); + + this._height = this._parent.clientHeight; + if (this._height === 0) { console.error("LazyListView could not calculate parent height."); } + const range = this._getVisibleRange(); + const renderRange = range.expand(this._overflowItems); + this._renderRange = renderRange; + this._renderElementsInRange(); + } + + _itemsFromList(start, end) { + const array = []; + let i = 0; + for (const item of this._list) { + if (i >= start && i < end) { + array.push(item); + } + i = i + 1; + } + return array; + } + + _itemAtIndex(idx) { + let i = 0; + for (const item of this._list) { + if (i === idx) { + return item; + } + i = i + 1; + } + return null; + } + + _renderElementsInRange() { + const { topCount, renderCount, bottomCount } = this._renderRange; + const paddingTop = topCount * this._itemHeight; + const paddingBottom = bottomCount * this._itemHeight; + const renderedItems = this._itemsFromList(topCount, topCount + renderCount); + this._root.style.paddingTop = `${paddingTop}px`; + this._root.style.paddingBottom = `${paddingBottom}px`; + for (const child of this._childInstances) { + this._removeChild(child); + } + this._childInstances = []; + const fragment = document.createDocumentFragment(); + for (const item of renderedItems) { + const view = this._childCreator(item); + this._childInstances.push(view); + fragment.appendChild(mountView(view, this._mountArgs)); + } + this._root.appendChild(fragment); + } + + mount() { + const root = super.mount(); + this._parent = el("div", {className: "LazyListParent"}, root); + /* + Hooking to scroll events can be expensive. + Do we need to do more (like event throttling)? + */ + this._parent.addEventListener("scroll", () => this._renderIfNeeded()); + this._initialRender(); + return this._parent; + } + + update(attributes) { + this._renderRange = null; + super.update(attributes); + this._initialRender(); + } + + loadList() { + if (!this._list) { return; } + this._subscription = this._list.subscribe(this); + this._childInstances = []; + /* + super.loadList() would render the entire list at this point. + We instead lazy render a part of the list in _renderIfNeeded + */ + } + + _removeChild(child) { + child.root().remove(); + child.unmount(); + } + + // If size of the list changes, re-render + onAdd() { + this._renderIfNeeded(true); + } + + onRemove() { + this._renderIfNeeded(true); + } + + onUpdate(idx, value, params) { + if (this._renderRange.containsIndex(idx)) { + const normalizedIdx = this._renderRange.normalize(idx); + super.onUpdate(normalizedIdx, value, params); + } + } + + recreateItem(idx, value) { + if (this._renderRange.containsIndex(idx)) { + const normalizedIdx = this._renderRange.normalize(idx); + super.recreateItem(normalizedIdx, value) + } + } + + /** + * Render additional element from top or bottom to offset the outgoing element + */ + _renderExtraOnMove(fromIdx, toIdx) { + const {topCount, renderCount} = this._renderRange; + if (toIdx < fromIdx) { + // Element is moved up the list, so render element from top boundary + const index = topCount; + const child = this._childCreator(this._itemAtIndex(index)); + this._childInstances.unshift(child); + this._root.insertBefore(mountView(child, this._mountArgs), this._root.firstChild); + } + else { + // Element is moved down the list, so render element from bottom boundary + const index = topCount + renderCount - 1; + const child = this._childCreator(this._itemAtIndex(index)); + this._childInstances.push(child); + this._root.appendChild(mountView(child, this._mountArgs)); + } + } + + /** + * Remove an element from top or bottom to make space for the incoming element + */ + _removeElementOnMove(fromIdx, toIdx) { + // If element comes from the bottom, remove element at bottom and vice versa + const child = toIdx < fromIdx ? this._childInstances.pop() : this._childInstances.shift(); + this._removeChild(child); + } + + onMove(fromIdx, toIdx, value) { + const fromInRange = this._renderRange.containsIndex(fromIdx); + const toInRange = this._renderRange.containsIndex(toIdx); + const normalizedFromIdx = this._renderRange.normalize(fromIdx); + const normalizedToIdx = this._renderRange.normalize(toIdx); + if (fromInRange && toInRange) { + super.onMove(normalizedFromIdx, normalizedToIdx, value); + } + else if (fromInRange && !toInRange) { + this.onBeforeListChanged(); + const [child] = this._childInstances.splice(normalizedFromIdx, 1); + this._removeChild(child); + this._renderExtraOnMove(fromIdx, toIdx); + this.onListChanged(); + } + else if (!fromInRange && toInRange) { + this.onBeforeListChanged(); + const child = this._childCreator(value); + this._removeElementOnMove(fromIdx, toIdx); + this._childInstances.splice(normalizedToIdx, 0, child); + insertAt(this._root, normalizedToIdx, mountView(child, this._mountArgs)); + this.onListChanged(); + } + } + +} diff --git a/src/platform/web/ui/general/ListView.js b/src/platform/web/ui/general/ListView.js index 2e29996c..884eedc4 100644 --- a/src/platform/web/ui/general/ListView.js +++ b/src/platform/web/ui/general/ListView.js @@ -17,7 +17,7 @@ limitations under the License. import {el} from "./html.js"; import {mountView} from "./utils.js"; -function insertAt(parentNode, idx, childNode) { +export function insertAt(parentNode, idx, childNode) { const isLast = idx === parentNode.childElementCount; if (isLast) { parentNode.appendChild(childNode); diff --git a/src/platform/web/ui/general/LoadingView.js b/src/platform/web/ui/general/LoadingView.js new file mode 100644 index 00000000..0041b03f --- /dev/null +++ b/src/platform/web/ui/general/LoadingView.js @@ -0,0 +1,8 @@ +import {TemplateView} from "./TemplateView.js"; +import {spinner} from "../common.js"; + +export class LoadingView extends TemplateView { + render(t) { + return t.div({ className: "LoadingView" }, [spinner(t), "Loading"]); + } +} diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index 877cc67c..0cda8428 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -25,7 +25,7 @@ import {StaticView} from "../general/StaticView.js"; import {SessionStatusView} from "./SessionStatusView.js"; import {RoomGridView} from "./RoomGridView.js"; import {SettingsView} from "./settings/SettingsView.js"; -import {RoomDetailsView} from "./rightpanel/RoomDetailsView.js"; +import {RightPanelView} from "./rightpanel/RightPanelView.js"; export class SessionView extends TemplateView { render(t, vm) { @@ -33,7 +33,7 @@ export class SessionView extends TemplateView { className: { "SessionView": true, "middle-shown": vm => !!vm.activeMiddleViewModel, - "right-shown": vm => !!vm.roomDetailsViewModel + "right-shown": vm => !!vm.rightPanelViewModel }, }, [ t.view(new SessionStatusView(vm.sessionStatusViewModel)), @@ -55,8 +55,8 @@ export class SessionView extends TemplateView { return new StaticView(t => t.div({className: "room-placeholder"}, t.h2(vm.i18n`Choose a room on the left side.`))); } }), - t.mapView(vm => vm.roomDetailsViewModel, roomDetailsViewModel => roomDetailsViewModel ? new RoomDetailsView(roomDetailsViewModel) : null), - t.mapView(vm => vm.lightboxViewModel, lightboxViewModel => lightboxViewModel ? new LightboxView(lightboxViewModel) : null) + t.mapView(vm => vm.lightboxViewModel, lightboxViewModel => lightboxViewModel ? new LightboxView(lightboxViewModel) : null), + t.mapView(vm => vm.rightPanelViewModel, rightPanelViewModel => rightPanelViewModel ? new RightPanelView(rightPanelViewModel) : null) ]); } } diff --git a/src/platform/web/ui/session/leftpanel/RoomTileView.js b/src/platform/web/ui/session/leftpanel/RoomTileView.js index 84b38b62..228addba 100644 --- a/src/platform/web/ui/session/leftpanel/RoomTileView.js +++ b/src/platform/web/ui/session/leftpanel/RoomTileView.js @@ -16,7 +16,7 @@ limitations under the License. */ import {TemplateView} from "../../general/TemplateView.js"; -import {AvatarView} from "../../avatar.js"; +import {AvatarView} from "../../AvatarView.js"; export class RoomTileView extends TemplateView { render(t, vm) { diff --git a/src/platform/web/ui/session/rightpanel/MemberListView.js b/src/platform/web/ui/session/rightpanel/MemberListView.js new file mode 100644 index 00000000..77235249 --- /dev/null +++ b/src/platform/web/ui/session/rightpanel/MemberListView.js @@ -0,0 +1,12 @@ +import {LazyListView} from "../../general/LazyListView.js"; +import {MemberTileView} from "./MemberTileView.js"; + +export class MemberListView extends LazyListView{ + constructor(vm) { + super({ + list: vm.memberTileViewModels, + className: "MemberListView", + itemHeight: 40 + }, tileViewModel => new MemberTileView(tileViewModel)); + } +} diff --git a/src/platform/web/ui/session/rightpanel/MemberTileView.js b/src/platform/web/ui/session/rightpanel/MemberTileView.js new file mode 100644 index 00000000..95b04719 --- /dev/null +++ b/src/platform/web/ui/session/rightpanel/MemberTileView.js @@ -0,0 +1,11 @@ +import {TemplateView} from "../../general/TemplateView.js"; +import {AvatarView} from "../../AvatarView.js"; + +export class MemberTileView extends TemplateView { + render(t, vm) { + return t.li({ className: "MemberTileView" }, [ + t.view(new AvatarView(vm, 32)), + t.div({ className: "MemberTileView_name" }, (vm) => vm.name), + ]); + } +} diff --git a/src/platform/web/ui/session/rightpanel/RightPanelView.js b/src/platform/web/ui/session/rightpanel/RightPanelView.js new file mode 100644 index 00000000..f812d6aa --- /dev/null +++ b/src/platform/web/ui/session/rightpanel/RightPanelView.js @@ -0,0 +1,34 @@ +import {TemplateView} from "../../general/TemplateView.js"; +import {RoomDetailsView} from "./RoomDetailsView.js"; +import {MemberListView} from "./MemberListView.js"; +import {LoadingView} from "../../general/LoadingView.js"; + +export class RightPanelView extends TemplateView { + render(t) { + const viewFromType = { + "room-details": RoomDetailsView, + "member-list": MemberListView + }; + return t.div({ className: "RightPanelView" }, + [ + t.ifView(vm => vm.activeViewModel, vm => new ButtonsView(vm)), + t.mapView(vm => vm.activeViewModel, vm => vm ? new viewFromType[vm.type](vm) : new LoadingView()) + ] + ); + } +} + +class ButtonsView extends TemplateView { + render(t, vm) { + return t.div({ className: "RightPanelView_buttons" }, + [ + t.button({ + className: { + "back": true, + "button-utility": true, + "hide": !vm.activeViewModel.shouldShowBackButton + }, onClick: () => vm.showPreviousPanel()}), + t.button({className: "close button-utility", onClick: () => vm.closePanel()}) + ]); + } +} diff --git a/src/platform/web/ui/session/rightpanel/RoomDetailsView.js b/src/platform/web/ui/session/rightpanel/RoomDetailsView.js index a6d1a81f..eb3522a4 100644 --- a/src/platform/web/ui/session/rightpanel/RoomDetailsView.js +++ b/src/platform/web/ui/session/rightpanel/RoomDetailsView.js @@ -1,12 +1,11 @@ import {TemplateView} from "../../general/TemplateView.js"; import {classNames, tag} from "../../general/html.js"; -import {AvatarView} from "../../avatar.js"; +import {AvatarView} from "../../AvatarView.js"; export class RoomDetailsView extends TemplateView { render(t, vm) { const encryptionString = () => vm.isEncrypted ? vm.i18n`On` : vm.i18n`Off`; return t.div({className: "RoomDetailsView"}, [ - this._createButton(t, vm), t.div({className: "RoomDetailsView_avatar"}, [ t.view(new AvatarView(vm, 52)), @@ -16,7 +15,8 @@ export class RoomDetailsView extends TemplateView { this._createRoomAliasDisplay(vm), t.div({className: "RoomDetailsView_rows"}, [ - this._createRightPanelRow(t, vm.i18n`People`, {MemberCount: true}, vm => vm.memberCount), + this._createRightPanelButtonRow(t, vm.i18n`People`, { MemberCount: true }, vm => vm.memberCount, + () => vm.openPanel("members")), this._createRightPanelRow(t, vm.i18n`Encryption`, {EncryptionStatus: true}, encryptionString) ]) ]); @@ -35,12 +35,14 @@ export class RoomDetailsView extends TemplateView { ]); } - _createButton(t, vm) { - return t.div({className: "RoomDetailsView_buttons"}, - [ - t.button({className: "close button-utility", onClick: () => vm.closePanel()}) - ]); + _createRightPanelButtonRow(t, label, labelClass, value, onClick) { + const labelClassString = classNames({RoomDetailsView_label: true, ...labelClass}); + return t.button({className: "RoomDetailsView_row", onClick}, [ + t.div({className: labelClassString}, [label]), + t.div({className: "RoomDetailsView_value"}, value) + ]); } + } class EncryptionIconView extends TemplateView { diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index f8e84f87..71eb1e13 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -22,7 +22,7 @@ import {TimelineList} from "./TimelineList.js"; import {TimelineLoadingView} from "./TimelineLoadingView.js"; import {MessageComposer} from "./MessageComposer.js"; import {RoomArchivedView} from "./RoomArchivedView.js"; -import {AvatarView} from "../../avatar.js"; +import {AvatarView} from "../../AvatarView.js"; export class RoomView extends TemplateView { constructor(options) { @@ -70,7 +70,7 @@ export class RoomView extends TemplateView { const options = []; options.push(Menu.option(vm.i18n`Room details`, () => vm.openDetailsPanel())) if (vm.canLeave) { - options.push(Menu.option(vm.i18n`Leave room`, () => vm.leaveRoom()).setDestructive()); + options.push(Menu.option(vm.i18n`Leave room`, () => this._confirmToLeaveRoom()).setDestructive()); } if (vm.canForget) { options.push(Menu.option(vm.i18n`Forget room`, () => vm.forgetRoom()).setDestructive()); @@ -97,4 +97,10 @@ export class RoomView extends TemplateView { }); } } + + _confirmToLeaveRoom() { + if (confirm(this.value.i18n`Are you sure you want to leave "${this.value.name}"?`)) { + this.value.leaveRoom(); + } + } }