Merge branch 'master' of github.com:vector-im/hydrogen-web into formatted-messages

This commit is contained in:
Danila Fedorin 2021-07-16 14:05:43 -07:00
commit c835dc324e
38 changed files with 1220 additions and 181 deletions

View file

@ -37,7 +37,9 @@ function allowsChild(parent, child) {
// downside of the approach: both of these will control which tile is selected // downside of the approach: both of these will control which tile is selected
return type === "room" || type === "empty-grid-tile"; return type === "room" || type === "empty-grid-tile";
case "room": case "room":
return type === "lightbox" || type === "details"; return type === "lightbox" || type === "right-panel";
case "right-panel":
return type === "details"|| type === "members";
default: default:
return false; 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) { export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) {
// substr(1) to take of initial / // substr(1) to take of initial /
const parts = urlPath.substr(1).split("/"); const parts = urlPath.substr(1).split("/");
@ -114,7 +133,9 @@ export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) {
} }
segments.push(new Segment("room", roomId)); segments.push(new Segment("room", roomId));
if (currentNavPath.get("details")?.value) { 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") { } else if (type === "last-session") {
let sessionSegment = currentNavPath.get("session"); let sessionSegment = currentNavPath.get("session");
@ -124,6 +145,8 @@ export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) {
if (sessionSegment) { if (sessionSegment) {
segments.push(sessionSegment); segments.push(sessionSegment);
} }
} else if (type === "details" || type === "members") {
pushRightPanelSegment(segments, type);
} else { } else {
// might be undefined, which will be turned into true by Segment // might be undefined, which will be turned into true by Segment
const value = iterator.next().value; const value = iterator.next().value;
@ -152,6 +175,9 @@ export function stringifyPath(path) {
urlPath += `/${segment.type}/${segment.value}`; urlPath += `/${segment.type}/${segment.value}`;
} }
break; break;
case "right-panel":
// Ignore right-panel in url
continue;
default: default:
urlPath += `/${segment.type}`; urlPath += `/${segment.type}`;
if (segment.value && segment.value !== true) { if (segment.value && segment.value !== true) {
@ -185,6 +211,18 @@ export function tests() {
const urlPath = stringifyPath(path); const urlPath = stringifyPath(path);
assert.equal(urlPath, "/session/1/rooms/a,b,c/1"); 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 => { "parse grid url path with focused empty tile": assert => {
const segments = parseUrlPath("/session/1/rooms/a,b,c/3"); const segments = parseUrlPath("/session/1/rooms/a,b,c/3");
assert.equal(segments.length, 3); assert.equal(segments.length, 3);
@ -263,18 +301,21 @@ export function tests() {
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
new Segment("room", "b"), new Segment("room", "b"),
new Segment("right-panel", true),
new Segment("details", true) new Segment("details", true)
]); ]);
const segments = parseUrlPath("/session/1/open-room/a", path); 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].type, "session");
assert.equal(segments[0].value, "1"); assert.equal(segments[0].value, "1");
assert.equal(segments[1].type, "rooms"); assert.equal(segments[1].type, "rooms");
assert.deepEqual(segments[1].value, ["a", "b", "c"]); assert.deepEqual(segments[1].value, ["a", "b", "c"]);
assert.equal(segments[2].type, "room"); assert.equal(segments[2].type, "room");
assert.equal(segments[2].value, "a"); 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[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 => { "parse open-room action setting a room in an empty tile": assert => {
const nav = new Navigation(allowsChild); const nav = new Navigation(allowsChild);

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import {ViewModel} from "../ViewModel.js"; import {ViewModel} from "../ViewModel.js";
import {addPanelIfNeeded} from "../navigation/index.js";
function dedupeSparse(roomIds) { function dedupeSparse(roomIds) {
return roomIds.map((id, idx) => { return roomIds.map((id, idx) => {
@ -79,12 +80,9 @@ export class RoomGridViewModel extends ViewModel {
} }
_switchToRoom(roomId) { _switchToRoom(roomId) {
const detailsShown = !!this.navigation.path.get("details")?.value;
let path = this.navigation.path.until("rooms"); let path = this.navigation.path.until("rooms");
path = path.with(this.navigation.segment("room", roomId)); path = path.with(this.navigation.segment("room", roomId));
if (detailsShown) { path = addPanelIfNeeded(this.navigation, path);
path = path.with(this.navigation.segment("details", true));
}
this.navigation.applyPath(path); this.navigation.applyPath(path);
} }

View file

@ -17,7 +17,6 @@ limitations under the License.
import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js"; import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js";
import {RoomViewModel} from "./room/RoomViewModel.js"; import {RoomViewModel} from "./room/RoomViewModel.js";
import {RoomDetailsViewModel} from "./rightpanel/RoomDetailsViewModel.js";
import {UnknownRoomViewModel} from "./room/UnknownRoomViewModel.js"; import {UnknownRoomViewModel} from "./room/UnknownRoomViewModel.js";
import {InviteViewModel} from "./room/InviteViewModel.js"; import {InviteViewModel} from "./room/InviteViewModel.js";
import {LightboxViewModel} from "./room/LightboxViewModel.js"; import {LightboxViewModel} from "./room/LightboxViewModel.js";
@ -26,6 +25,7 @@ import {RoomGridViewModel} from "./RoomGridViewModel.js";
import {SettingsViewModel} from "./settings/SettingsViewModel.js"; import {SettingsViewModel} from "./settings/SettingsViewModel.js";
import {ViewModel} from "../ViewModel.js"; import {ViewModel} from "../ViewModel.js";
import {RoomViewModelObservable} from "./RoomViewModelObservable.js"; import {RoomViewModelObservable} from "./RoomViewModelObservable.js";
import {RightPanelViewModel} from "./rightpanel/RightPanelViewModel.js";
export class SessionViewModel extends ViewModel { export class SessionViewModel extends ViewModel {
constructor(options) { constructor(options) {
@ -63,7 +63,7 @@ export class SessionViewModel extends ViewModel {
if (!this._gridViewModel) { if (!this._gridViewModel) {
this._updateRoom(roomId); this._updateRoom(roomId);
} }
this._updateRoomDetails(); this._updateRightPanel();
})); }));
if (!this._gridViewModel) { if (!this._gridViewModel) {
this._updateRoom(currentRoomId.get()); this._updateRoom(currentRoomId.get());
@ -81,9 +81,10 @@ export class SessionViewModel extends ViewModel {
})); }));
this._updateLightbox(lightbox.get()); this._updateLightbox(lightbox.get());
const details = this.navigation.observe("details");
this.track(details.subscribe(() => this._updateRoomDetails())); const rightpanel = this.navigation.observe("right-panel");
this._updateRoomDetails(); this.track(rightpanel.subscribe(() => this._updateRightPanel()));
this._updateRightPanel();
} }
get id() { get id() {
@ -118,8 +119,9 @@ export class SessionViewModel extends ViewModel {
return this._roomViewModelObservable?.get(); return this._roomViewModelObservable?.get();
} }
get roomDetailsViewModel() {
return this._roomDetailsViewModel; get rightPanelViewModel() {
return this._rightPanelViewModel;
} }
_updateGrid(roomIds) { _updateGrid(roomIds) {
@ -256,15 +258,14 @@ export class SessionViewModel extends ViewModel {
return room; return room;
} }
_updateRoomDetails() { _updateRightPanel() {
this._roomDetailsViewModel = this.disposeTracked(this._roomDetailsViewModel); this._rightPanelViewModel = this.disposeTracked(this._rightPanelViewModel);
const enable = !!this.navigation.path.get("details")?.value; const enable = !!this.navigation.path.get("right-panel")?.value;
if (enable) { if (enable) {
const room = this._roomFromNavigation(); const room = this._roomFromNavigation();
if (!room) { return; } this._rightPanelViewModel = this.track(new RightPanelViewModel(this.childOptions({room})));
this._roomDetailsViewModel = this.track(new RoomDetailsViewModel(this.childOptions({room})));
} }
this.emitChange("roomDetailsViewModel"); this.emitChange("rightPanelViewModel");
} }
} }

View file

@ -20,6 +20,7 @@ import {RoomTileViewModel} from "./RoomTileViewModel.js";
import {InviteTileViewModel} from "./InviteTileViewModel.js"; import {InviteTileViewModel} from "./InviteTileViewModel.js";
import {RoomFilter} from "./RoomFilter.js"; import {RoomFilter} from "./RoomFilter.js";
import {ApplyMap} from "../../../observable/map/ApplyMap.js"; import {ApplyMap} from "../../../observable/map/ApplyMap.js";
import {addPanelIfNeeded} from "../../navigation/index.js";
export class LeftPanelViewModel extends ViewModel { export class LeftPanelViewModel extends ViewModel {
constructor(options) { 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() { toggleGrid() {
const room = this.navigation.path.get("room"); const room = this.navigation.path.get("room");
let path = this.navigation.path.until("session"); let path = this.navigation.path.until("session");
if (this.gridEnabled) { if (this.gridEnabled) {
if (room) { if (room) {
path = path.with(room); path = path.with(room);
path = this._pathForDetails(path); path = addPanelIfNeeded(this.navigation, path);
} }
} else { } else {
if (room) { if (room) {
path = path.with(this.navigation.segment("rooms", [room.value])); path = path.with(this.navigation.segment("rooms", [room.value]));
path = path.with(room); path = path.with(room);
path = this._pathForDetails(path); path = addPanelIfNeeded(this.navigation, path);
} else { } else {
path = path.with(this.navigation.segment("rooms", [])); path = path.with(this.navigation.segment("rooms", []));
path = path.with(this.navigation.segment("empty-grid-tile", 0)); path = path.with(this.navigation.segment("empty-grid-tile", 0));

View file

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

View file

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

View file

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

View file

@ -9,6 +9,18 @@ export class RoomDetailsViewModel extends ViewModel {
this._room.on("change", this._onRoomChange); this._room.on("change", this._onRoomChange);
} }
get type() {
return "room-details";
}
get shouldShowBackButton() {
return false;
}
get previousSegmentName() {
return false;
}
get roomId() { get roomId() {
return this._room.id; return this._room.id;
} }
@ -49,13 +61,15 @@ export class RoomDetailsViewModel extends ViewModel {
this.emitChange(); this.emitChange();
} }
closePanel() {
const path = this.navigation.path.until("room");
this.navigation.applyPath(path);
}
dispose() { dispose() {
super.dispose(); super.dispose();
this._room.off("change", this._onRoomChange); 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);
}
} }

View file

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

View file

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

View file

@ -290,6 +290,7 @@ export class RoomViewModel extends ViewModel {
openDetailsPanel() { openDetailsPanel() {
let path = this.navigation.path.until("room"); let path = this.navigation.path.until("room");
path = path.with(this.navigation.segment("right-panel", true));
path = path.with(this.navigation.segment("details", true)); path = path.with(this.navigation.segment("details", true));
this.navigation.applyPath(path); this.navigation.applyPath(path);
} }

View file

@ -189,6 +189,8 @@ import {HomeServer as MockHomeServer} from "../../../../mocks/HomeServer.js";
// other imports // other imports
import {BaseMessageTile} from "./tiles/BaseMessageTile.js"; import {BaseMessageTile} from "./tiles/BaseMessageTile.js";
import {MappedList} from "../../../../observable/list/MappedList.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() { export function tests() {
const fragmentIdComparer = new FragmentIdComparer([]); const fragmentIdComparer = new FragmentIdComparer([]);
@ -251,8 +253,9 @@ export function tests() {
await txn.complete(); await txn.complete();
// 2. setup queue & timeline // 2. setup queue & timeline
const queue = new SendQueue({roomId, storage, hsApi: new MockHomeServer().api}); 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, 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 // 3. load the timeline, which will load the message with the reaction
await timeline.load(new User(alice), "join", new NullLogItem()); await timeline.load(new User(alice), "join", new NullLogItem());
const tiles = mapMessageEntriesToBaseMessageTile(timeline, queue); const tiles = mapMessageEntriesToBaseMessageTile(timeline, queue);
@ -310,8 +313,9 @@ export function tests() {
await txn.complete(); await txn.complete();
// 2. setup queue & timeline // 2. setup queue & timeline
const queue = new SendQueue({roomId, storage, hsApi: new MockHomeServer().api}); 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, 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 // 3. load the timeline, which will load the message with the reaction
await timeline.load(new User(alice), "join", new NullLogItem()); await timeline.load(new User(alice), "join", new NullLogItem());

View file

@ -28,6 +28,8 @@ import {EventEntry} from "./timeline/entries/EventEntry.js";
import {ObservedEventMap} from "./ObservedEventMap.js"; import {ObservedEventMap} from "./ObservedEventMap.js";
import {DecryptionSource} from "../e2ee/common.js"; import {DecryptionSource} from "../e2ee/common.js";
import {ensureLogItem} from "../../logging/utils.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"; const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
@ -50,6 +52,8 @@ export class BaseRoom extends EventEmitter {
this._getSyncToken = getSyncToken; this._getSyncToken = getSyncToken;
this._platform = platform; this._platform = platform;
this._observedEvents = null; this._observedEvents = null;
this._powerLevels = null;
this._powerLevelLoading = null;
} }
async _eventIdsToEntries(eventIds, txn) { async _eventIdsToEntries(eventIds, txn) {
@ -388,6 +392,47 @@ export class BaseRoom extends EventEmitter {
return this._summary.data.membership; 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) { enableSessionBackup(sessionBackup) {
this._roomEncryption?.enableSessionBackup(sessionBackup); this._roomEncryption?.enableSessionBackup(sessionBackup);
// TODO: do we really want to do this every time you open the app? // 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, clock: this._platform.clock,
logger: this._platform.logger, logger: this._platform.logger,
powerLevelsObservable: await this.observePowerLevels()
}); });
try { try {
if (this._roomEncryption) { if (this._roomEncryption) {

View file

@ -28,7 +28,7 @@ export class MemberList extends RetainedValue {
afterSync(memberChanges) { afterSync(memberChanges) {
for (const [userId, memberChange] of memberChanges.entries()) { for (const [userId, memberChange] of memberChanges.entries()) {
this._members.add(userId, memberChange.member); this._members.set(userId, memberChange.member);
} }
} }

View file

@ -42,10 +42,10 @@ export class PowerLevels {
if (this._membership !== "join") { if (this._membership !== "join") {
return Number.MIN_SAFE_INTEGER; return Number.MIN_SAFE_INTEGER;
} }
return this._getUserLevel(this._ownUserId); return this.getUserLevel(this._ownUserId);
} }
_getUserLevel(userId) { getUserLevel(userId) {
if (this._plEvent) { if (this._plEvent) {
let userLevel = this._plEvent.content?.users?.[userId]; let userLevel = this._plEvent.content?.users?.[userId];
if (typeof userLevel !== "number") { if (typeof userLevel !== "number") {

View file

@ -21,12 +21,11 @@ import {Direction} from "./Direction.js";
import {TimelineReader} from "./persistence/TimelineReader.js"; import {TimelineReader} from "./persistence/TimelineReader.js";
import {PendingEventEntry} from "./entries/PendingEventEntry.js"; import {PendingEventEntry} from "./entries/PendingEventEntry.js";
import {RoomMember} from "../members/RoomMember.js"; import {RoomMember} from "../members/RoomMember.js";
import {PowerLevels} from "./PowerLevels.js";
import {getRelation, ANNOTATION_RELATION_TYPE} from "./relations.js"; import {getRelation, ANNOTATION_RELATION_TYPE} from "./relations.js";
import {REDACTION_TYPE} from "../common.js"; import {REDACTION_TYPE} from "../common.js";
export class Timeline { export class Timeline {
constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, clock}) { constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, clock, powerLevelsObservable}) {
this._roomId = roomId; this._roomId = roomId;
this._storage = storage; this._storage = storage;
this._closeCallback = closeCallback; this._closeCallback = closeCallback;
@ -44,7 +43,14 @@ export class Timeline {
}); });
this._readerRequest = null; this._readerRequest = null;
this._allEntries = 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 */ /** @package */
@ -66,7 +72,6 @@ export class Timeline {
// as they should only populate once the view subscribes to it // as they should only populate once the view subscribes to it
// if they are populated already, the sender profile would be empty // 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 // 30 seems to be a good amount to fill the entire screen
const readerRequest = this._disposables.track(this._timelineReader.readFromEnd(30, txn, log)); const readerRequest = this._disposables.track(this._timelineReader.readFromEnd(30, txn, log));
try { try {
@ -78,28 +83,6 @@ export class Timeline {
// txn should be assumed to have finished here, as decryption will close it. // 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) { _setupEntries(timelineEntries) {
this._remoteEntries.setManySorted(timelineEntries); this._remoteEntries.setManySorted(timelineEntries);
if (this._pendingEvents) { if (this._pendingEvents) {

View file

@ -34,8 +34,8 @@ Object.assign(BaseObservableMap.prototype, {
return new SortedMapList(this, comparator); return new SortedMapList(this, comparator);
}, },
mapValues(mapper) { mapValues(mapper, updater) {
return new MappedMap(this, mapper); return new MappedMap(this, mapper, updater);
}, },
filterValues(filter) { filterValues(filter) {

View file

@ -91,8 +91,9 @@ export class FilteredMap extends BaseObservableMap {
const isIncluded = this._filter(value, key); const isIncluded = this._filter(value, key);
this._included.set(key, isIncluded); this._included.set(key, isIncluded);
this._emitForUpdate(wasIncluded, isIncluded, key, value, params); 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) { _emitForUpdate(wasIncluded, isIncluded, key, value, params = null) {
@ -191,5 +192,31 @@ export function tests() {
// "filter changed values": assert => { // "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);
}
} }
} }

View file

@ -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? 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 { export class MappedMap extends BaseObservableMap {
constructor(source, mapper) { constructor(source, mapper, updater) {
super(); super();
this._source = source; this._source = source;
this._mapper = mapper; this._mapper = mapper;
this._updater = updater;
this._mappedValues = new Map(); this._mappedValues = new Map();
} }
@ -55,6 +56,7 @@ export class MappedMap extends BaseObservableMap {
} }
const mappedValue = this._mappedValues.get(key); const mappedValue = this._mappedValues.get(key);
if (mappedValue !== undefined) { if (mappedValue !== undefined) {
this._updater?.(mappedValue, params, value);
// TODO: map params somehow if needed? // TODO: map params somehow if needed?
this.emitUpdate(key, mappedValue, params); this.emitUpdate(key, mappedValue, params);
} }

View file

@ -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() { reset() {
this._values.clear(); this._values.clear();
this.emitReset(); this.emitReset();
@ -136,6 +147,31 @@ export function tests() {
assert.equal(result, false); 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) { test_remove(assert) {
let fired = 0; let fired = 0;
const map = new ObservableMap(); const map = new ObservableMap();

View file

@ -37,6 +37,7 @@ import {hasReadPixelPermission, ImageHandle, VideoHandle} from "./dom/ImageHandl
import {downloadInIframe} from "./dom/download.js"; import {downloadInIframe} from "./dom/download.js";
import {Disposables} from "../../utils/Disposables.js"; import {Disposables} from "../../utils/Disposables.js";
import {parseHTML} from "./parsehtml.js"; import {parseHTML} from "./parsehtml.js";
import {handleAvatarError} from "./ui/avatar.js";
function addScript(src) { function addScript(src) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
@ -190,6 +191,8 @@ export class Platform {
this._disposables.track(disposable); this._disposables.track(disposable);
} }
} }
this._container.addEventListener("error", handleAvatarError, true);
this._disposables.track(() => this._container.removeEventListener("error", handleAvatarError, true));
window.__hydrogenViewModel = vm; window.__hydrogenViewModel = vm;
const view = new RootView(vm); const view = new RootView(vm);
this._container.appendChild(view.mount()); this._container.appendChild(view.mount());

View file

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

View file

@ -14,90 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {tag, text, classNames} from "./general/html.js"; import {tag, text, classNames, setAttribute} 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;
}
}
}
/** /**
* @param {Object} vm view model with {avatarUrl, avatarColorNumber, avatarTitle, avatarLetter} * @param {Object} vm view model with {avatarUrl, avatarColorNumber, avatarTitle, avatarLetter}
* @param {Number} size * @param {Number} size
@ -108,16 +25,36 @@ export function renderStaticAvatar(vm, size, extraClasses = undefined) {
let avatarClasses = classNames({ let avatarClasses = classNames({
avatar: true, avatar: true,
[`size-${size}`]: true, [`size-${size}`]: true,
[`usercolor${vm.avatarColorNumber}`]: !hasAvatar, [`usercolor${vm.avatarColorNumber}`]: !hasAvatar
}); });
if (extraClasses) { if (extraClasses) {
avatarClasses += ` ${extraClasses}`; avatarClasses += ` ${extraClasses}`;
} }
const avatarContent = hasAvatar ? renderImg(vm, size) : text(vm.avatarLetter); 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(); const sizeStr = size.toString();
return tag.img({src: vm.avatarUrl(size), width: sizeStr, height: sizeStr, title: vm.avatarTitle}); 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;
}

View file

@ -208,3 +208,13 @@ the layout viewport up without resizing it when the keyboard shows */
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
} }
.LazyListParent {
flex: 1;
}
.LoadingView {
display: flex;
justify-content: center;
align-items: center;
}

View file

@ -1,8 +1,16 @@
.RoomDetailsView { .RightPanelView{
grid-area: right; grid-area: right;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column; flex-direction: column;
} }
.RoomDetailsView {
flex-direction: column;
flex: 1;
}
.RoomDetailsView_avatar { .RoomDetailsView_avatar {
display: flex; display: flex;
} }
@ -11,21 +19,33 @@
text-align: center; text-align: center;
} }
.RoomDetailsView_row {
justify-content: space-between;
}
.RoomDetailsView_label, .RoomDetailsView_row, .RoomDetailsView, .EncryptionIconView { .RoomDetailsView_label, .RoomDetailsView_row, .RoomDetailsView, .EncryptionIconView {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.RoomDetailsView_value {
display: flex;
justify-content: flex-end;
}
.EncryptionIconView { .EncryptionIconView {
justify-content: center; justify-content: center;
} }
.RoomDetailsView_buttons { .RightPanelView_buttons {
display: flex; display: flex;
justify-content: flex-end; justify-content: space-between;
width: 100%; width: 100%;
box-sizing: border-box;
padding: 16px;
}
.RightPanelView_buttons .hide {
visibility: hidden;
}
.MemberTileView {
display: flex;
align-items: center;
} }

View file

@ -0,0 +1,3 @@
<svg width="7" height="11" viewBox="0 0 7 11" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.96967 10.7197C0.676777 10.4268 0.676007 9.95117 0.967952 9.65733L4.66823 5.93303L0.955922 2.22072C0.663029 1.92782 0.662259 1.45218 0.954204 1.15834C1.24615 0.864504 1.72025 0.863737 2.01315 1.15663L6.25579 5.39927C6.54868 5.69216 6.54945 6.1678 6.2575 6.46164L2.02861 10.718C1.73667 11.0118 1.26256 11.0126 0.96967 10.7197Z" fill="#737D8C"/>
</svg>

After

Width:  |  Height:  |  Size: 496 B

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.5 6L7.5 9L10.5 12" stroke="#8D99A5" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 220 B

View file

@ -787,11 +787,24 @@ button.link {
width: 100%; width: 100%;
} }
.LoadingView {
height: 100%;
width: 100%;
}
.LoadingView .spinner {
margin-right: 5px;
}
/* Right Panel */ /* Right Panel */
.RoomDetailsView { .RightPanelView {
background: rgba(245, 245, 245, 0.90); background: rgba(245, 245, 245, 0.90);
}
.RoomDetailsView {
padding: 16px; padding: 16px;
padding-top: 0;
} }
.RoomDetailsView_id { .RoomDetailsView_id {
@ -813,6 +826,24 @@ button.link {
margin-bottom: 20px; margin-bottom: 20px;
font-weight: 500; font-weight: 500;
font-size: 15px; 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 { .RoomDetailsView_label::before {
@ -821,8 +852,13 @@ button.link {
width: 20px; width: 20px;
} }
.RoomDetailsView_label {
width: 200px;
}
.RoomDetailsView_value { .RoomDetailsView_value {
color: #737D8C; color: #737D8C;
flex: 1;
} }
.MemberCount::before { .MemberCount::before {
@ -857,11 +893,42 @@ button.link {
content: url("./icons/e2ee-disabled.svg"); content: url("./icons/e2ee-disabled.svg");
} }
.RoomDetailsView .button-utility { .RightPanelView_buttons .button-utility {
width: 24px; width: 24px;
height: 24px; height: 24px;
} }
.RoomDetailsView .close { .RightPanelView_buttons .close {
background-image: url("./icons/clear.svg"); 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;
}

View file

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

View file

@ -17,7 +17,7 @@ limitations under the License.
import {el} from "./html.js"; import {el} from "./html.js";
import {mountView} from "./utils.js"; import {mountView} from "./utils.js";
function insertAt(parentNode, idx, childNode) { export function insertAt(parentNode, idx, childNode) {
const isLast = idx === parentNode.childElementCount; const isLast = idx === parentNode.childElementCount;
if (isLast) { if (isLast) {
parentNode.appendChild(childNode); parentNode.appendChild(childNode);

View file

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

View file

@ -25,7 +25,7 @@ import {StaticView} from "../general/StaticView.js";
import {SessionStatusView} from "./SessionStatusView.js"; import {SessionStatusView} from "./SessionStatusView.js";
import {RoomGridView} from "./RoomGridView.js"; import {RoomGridView} from "./RoomGridView.js";
import {SettingsView} from "./settings/SettingsView.js"; import {SettingsView} from "./settings/SettingsView.js";
import {RoomDetailsView} from "./rightpanel/RoomDetailsView.js"; import {RightPanelView} from "./rightpanel/RightPanelView.js";
export class SessionView extends TemplateView { export class SessionView extends TemplateView {
render(t, vm) { render(t, vm) {
@ -33,7 +33,7 @@ export class SessionView extends TemplateView {
className: { className: {
"SessionView": true, "SessionView": true,
"middle-shown": vm => !!vm.activeMiddleViewModel, "middle-shown": vm => !!vm.activeMiddleViewModel,
"right-shown": vm => !!vm.roomDetailsViewModel "right-shown": vm => !!vm.rightPanelViewModel
}, },
}, [ }, [
t.view(new SessionStatusView(vm.sessionStatusViewModel)), 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.`))); 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)
]); ]);
} }
} }

View file

@ -16,7 +16,7 @@ limitations under the License.
*/ */
import {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView.js";
import {AvatarView} from "../../avatar.js"; import {AvatarView} from "../../AvatarView.js";
export class RoomTileView extends TemplateView { export class RoomTileView extends TemplateView {
render(t, vm) { render(t, vm) {

View file

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

View file

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

View file

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

View file

@ -1,12 +1,11 @@
import {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView.js";
import {classNames, tag} from "../../general/html.js"; import {classNames, tag} from "../../general/html.js";
import {AvatarView} from "../../avatar.js"; import {AvatarView} from "../../AvatarView.js";
export class RoomDetailsView extends TemplateView { export class RoomDetailsView extends TemplateView {
render(t, vm) { render(t, vm) {
const encryptionString = () => vm.isEncrypted ? vm.i18n`On` : vm.i18n`Off`; const encryptionString = () => vm.isEncrypted ? vm.i18n`On` : vm.i18n`Off`;
return t.div({className: "RoomDetailsView"}, [ return t.div({className: "RoomDetailsView"}, [
this._createButton(t, vm),
t.div({className: "RoomDetailsView_avatar"}, t.div({className: "RoomDetailsView_avatar"},
[ [
t.view(new AvatarView(vm, 52)), t.view(new AvatarView(vm, 52)),
@ -16,7 +15,8 @@ export class RoomDetailsView extends TemplateView {
this._createRoomAliasDisplay(vm), this._createRoomAliasDisplay(vm),
t.div({className: "RoomDetailsView_rows"}, 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) this._createRightPanelRow(t, vm.i18n`Encryption`, {EncryptionStatus: true}, encryptionString)
]) ])
]); ]);
@ -35,12 +35,14 @@ export class RoomDetailsView extends TemplateView {
]); ]);
} }
_createButton(t, vm) { _createRightPanelButtonRow(t, label, labelClass, value, onClick) {
return t.div({className: "RoomDetailsView_buttons"}, const labelClassString = classNames({RoomDetailsView_label: true, ...labelClass});
[ return t.button({className: "RoomDetailsView_row", onClick}, [
t.button({className: "close button-utility", onClick: () => vm.closePanel()}) t.div({className: labelClassString}, [label]),
]); t.div({className: "RoomDetailsView_value"}, value)
]);
} }
} }
class EncryptionIconView extends TemplateView { class EncryptionIconView extends TemplateView {

View file

@ -22,7 +22,7 @@ import {TimelineList} from "./TimelineList.js";
import {TimelineLoadingView} from "./TimelineLoadingView.js"; import {TimelineLoadingView} from "./TimelineLoadingView.js";
import {MessageComposer} from "./MessageComposer.js"; import {MessageComposer} from "./MessageComposer.js";
import {RoomArchivedView} from "./RoomArchivedView.js"; import {RoomArchivedView} from "./RoomArchivedView.js";
import {AvatarView} from "../../avatar.js"; import {AvatarView} from "../../AvatarView.js";
export class RoomView extends TemplateView { export class RoomView extends TemplateView {
constructor(options) { constructor(options) {
@ -70,7 +70,7 @@ export class RoomView extends TemplateView {
const options = []; const options = [];
options.push(Menu.option(vm.i18n`Room details`, () => vm.openDetailsPanel())) options.push(Menu.option(vm.i18n`Room details`, () => vm.openDetailsPanel()))
if (vm.canLeave) { 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) { if (vm.canForget) {
options.push(Menu.option(vm.i18n`Forget room`, () => vm.forgetRoom()).setDestructive()); 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();
}
}
} }