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();
+ }
+ }
}