Merge pull request #395 from MidhunSureshR/memberlist
Add memberlist right-panel to Hydrogen
This commit is contained in:
commit
da7dff18c6
33 changed files with 1097 additions and 90 deletions
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
|
|
42
src/domain/session/rightpanel/MemberListViewModel.js
Normal file
42
src/domain/session/rightpanel/MemberListViewModel.js
Normal 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);
|
||||
}
|
||||
|
||||
}
|
68
src/domain/session/rightpanel/MemberTileViewModel.js
Normal file
68
src/domain/session/rightpanel/MemberTileViewModel.js
Normal 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;
|
||||
}
|
||||
}
|
62
src/domain/session/rightpanel/RightPanelViewModel.js
Normal file
62
src/domain/session/rightpanel/RightPanelViewModel.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
97
src/domain/session/rightpanel/members/comparator.js
Normal file
97
src/domain/session/rightpanel/members/comparator.js
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
156
src/domain/session/rightpanel/members/disambiguator.js
Normal file
156
src/domain/session/rightpanel/members/disambiguator.js
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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") {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 |
|
@ -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 |
|
@ -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;
|
||||
}
|
||||
|
|
263
src/platform/web/ui/general/LazyListView.js
Normal file
263
src/platform/web/ui/general/LazyListView.js
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
|
|
8
src/platform/web/ui/general/LoadingView.js
Normal file
8
src/platform/web/ui/general/LoadingView.js
Normal 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"]);
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
12
src/platform/web/ui/session/rightpanel/MemberListView.js
Normal file
12
src/platform/web/ui/session/rightpanel/MemberListView.js
Normal 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));
|
||||
}
|
||||
}
|
11
src/platform/web/ui/session/rightpanel/MemberTileView.js
Normal file
11
src/platform/web/ui/session/rightpanel/MemberTileView.js
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
34
src/platform/web/ui/session/rightpanel/RightPanelView.js
Normal file
34
src/platform/web/ui/session/rightpanel/RightPanelView.js
Normal 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()})
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -6,7 +6,6 @@ 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 {
|
||||
|
|
Reference in a new issue