diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index 6ce63ffd..dbac16ac 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -39,7 +39,7 @@ function allowsChild(parent, child) { case "room": return type === "lightbox" || type === "right-panel"; case "right-panel": - return type === "details"|| type === "members"; + return type === "details"|| type === "members" || type === "member"; default: return false; } @@ -87,9 +87,9 @@ function roomsSegmentWithRoom(rooms, roomId, path) { } } -function pushRightPanelSegment(array, segment) { +function pushRightPanelSegment(array, segment, value = true) { array.push(new Segment("right-panel")); - array.push(new Segment(segment)); + array.push(new Segment(segment, value)); } export function addPanelIfNeeded(navigation, path) { @@ -132,10 +132,11 @@ export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) { segments.push(roomsSegmentWithRoom(rooms, roomId, currentNavPath)); } segments.push(new Segment("room", roomId)); - if (currentNavPath.get("details")?.value) { - pushRightPanelSegment(segments, "details"); - } else if (currentNavPath.get("members")?.value) { - pushRightPanelSegment(segments, "members"); + // Add right-panel segments from previous path + const previousSegments = currentNavPath.segments; + const i = previousSegments.findIndex(s => s.type === "right-panel"); + if (i !== -1) { + segments.push(...previousSegments.slice(i)); } } else if (type === "last-session") { let sessionSegment = currentNavPath.get("session"); @@ -147,6 +148,10 @@ export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) { } } else if (type === "details" || type === "members") { pushRightPanelSegment(segments, type); + } else if (type === "member") { + const userId = iterator.next().value; + if (!userId) { break; } + pushRightPanelSegment(segments, type, userId); } else { // might be undefined, which will be turned into true by Segment const value = iterator.next().value; diff --git a/src/domain/session/rightpanel/MemberDetailsViewModel.js b/src/domain/session/rightpanel/MemberDetailsViewModel.js new file mode 100644 index 00000000..0303e05d --- /dev/null +++ b/src/domain/session/rightpanel/MemberDetailsViewModel.js @@ -0,0 +1,82 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {ViewModel} from "../../ViewModel.js"; +import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; + +export class MemberDetailsViewModel extends ViewModel { + constructor(options) { + super(options); + this._observableMember = options.observableMember; + this._mediaRepository = options.mediaRepository; + this._member = this._observableMember.get(); + this._isEncrypted = options.isEncrypted; + this._powerLevelsObservable = options.powerLevelsObservable; + this.track(this._powerLevelsObservable.subscribe(() => this._onPowerLevelsChange())); + this.track(this._observableMember.subscribe( () => this._onMemberChange())); + } + + get name() { return this._member.name; } + get userId() { return this._member.userId; } + + get type() { return "member-details"; } + get shouldShowBackButton() { return true; } + get previousSegmentName() { return "members"; } + + get role() { + if (this.powerLevel >= 100) { return this.i18n`Admin`; } + else if (this.powerLevel >= 50) { return this.i18n`Moderator`; } + else if (this.powerLevel === 0) { return this.i18n`Default`; } + else { return this.i18n`Custom (${this.powerLevel})`; } + } + + _onMemberChange() { + this._member = this._observableMember.get(); + this.emitChange("member"); + } + + _onPowerLevelsChange() { + this.emitChange("role"); + } + + 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; + } + + get isEncrypted() { + return this._isEncrypted; + } + + get powerLevel() { + return this._powerLevelsObservable.get()?.getUserLevel(this._member.userId); + } + + get linkToUser() { + return `https://matrix.to/#/${this._member.userId}`; + } +} diff --git a/src/domain/session/rightpanel/MemberListViewModel.js b/src/domain/session/rightpanel/MemberListViewModel.js index ffc20aeb..5b8bb83e 100644 --- a/src/domain/session/rightpanel/MemberListViewModel.js +++ b/src/domain/session/rightpanel/MemberListViewModel.js @@ -23,7 +23,6 @@ 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*/ })); diff --git a/src/domain/session/rightpanel/MemberTileViewModel.js b/src/domain/session/rightpanel/MemberTileViewModel.js index 6244fafa..91a07f0c 100644 --- a/src/domain/session/rightpanel/MemberTileViewModel.js +++ b/src/domain/session/rightpanel/MemberTileViewModel.js @@ -46,6 +46,10 @@ export class MemberTileViewModel extends ViewModel { return this._nameChanged; } + get detailsUrl() { + return `${this.urlCreator.urlUntilSegment("room")}/member/${this._member.userId}`; + } + _updatePreviousName(newName) { const currentName = this._member.name; if (currentName !== newName) { diff --git a/src/domain/session/rightpanel/RightPanelViewModel.js b/src/domain/session/rightpanel/RightPanelViewModel.js index 7786d711..670df868 100644 --- a/src/domain/session/rightpanel/RightPanelViewModel.js +++ b/src/domain/session/rightpanel/RightPanelViewModel.js @@ -17,35 +17,59 @@ limitations under the License. import {ViewModel} from "../../ViewModel.js"; import {RoomDetailsViewModel} from "./RoomDetailsViewModel.js"; import {MemberListViewModel} from "./MemberListViewModel.js"; +import {MemberDetailsViewModel} from "./MemberDetailsViewModel.js"; export class RightPanelViewModel extends ViewModel { constructor(options) { super(options); this._room = options.room; + this._members = null; this._setupNavigation(); } get activeViewModel() { return this._activeViewModel; } - async _getMemberArguments() { - const members = await this._room.loadMemberList(); + async _getMemberListArguments() { + if (!this._members) { + this._members = await this._room.loadMemberList(); + this.track(() => this._members.release()); + } const room = this._room; const powerLevelsObservable = await this._room.observePowerLevels(); - return {members, powerLevelsObservable, mediaRepository: room.mediaRepository}; + return {members: this._members, powerLevelsObservable, mediaRepository: room.mediaRepository}; + } + + async _getMemberDetailsArguments() { + const segment = this.navigation.path.get("member"); + const userId = segment.value; + const observableMember = await this._room.observeMember(userId); + if (!observableMember) { + return false; + } + const isEncrypted = this._room.isEncrypted; + const powerLevelsObservable = await this._room.observePowerLevels(); + return {observableMember, isEncrypted, powerLevelsObservable, mediaRepository: this._room.mediaRepository}; } _setupNavigation() { this._hookUpdaterToSegment("details", RoomDetailsViewModel, () => { return {room: this._room}; }); - this._hookUpdaterToSegment("members", MemberListViewModel, () => this._getMemberArguments()); + this._hookUpdaterToSegment("members", MemberListViewModel, () => this._getMemberListArguments()); + this._hookUpdaterToSegment("member", MemberDetailsViewModel, () => this._getMemberDetailsArguments(), + () => { + // If we fail to create the member details panel, fallback to memberlist + const url = `${this.urlCreator.urlUntilSegment("room")}/members`; + this.urlCreator.pushUrl(url); + } + ); } - _hookUpdaterToSegment(segment, viewmodel, argCreator) { + _hookUpdaterToSegment(segment, viewmodel, argCreator, failCallback) { const observable = this.navigation.observe(segment); - const updater = this._setupUpdater(segment, viewmodel, argCreator); - this.track(observable.subscribe(() => updater())); + const updater = this._setupUpdater(segment, viewmodel, argCreator, failCallback); + this.track(observable.subscribe(updater)); } - _setupUpdater(segment, viewmodel, argCreator) { + _setupUpdater(segment, viewmodel, argCreator, failCallback) { const updater = async (skipDispose = false) => { if (!skipDispose) { this._activeViewModel = this.disposeTracked(this._activeViewModel); @@ -53,6 +77,10 @@ export class RightPanelViewModel extends ViewModel { const enable = !!this.navigation.path.get(segment)?.value; if (enable) { const args = await argCreator(); + if (!args && failCallback) { + failCallback(); + return; + } this._activeViewModel = this.track(new viewmodel(this.childOptions(args))); } this.emitChange("activeViewModel"); diff --git a/src/platform/web/ui/css/right-panel.css b/src/platform/web/ui/css/right-panel.css index 937fa944..95fd9cae 100644 --- a/src/platform/web/ui/css/right-panel.css +++ b/src/platform/web/ui/css/right-panel.css @@ -6,7 +6,7 @@ flex-direction: column; } -.RoomDetailsView { +.RoomDetailsView, .MemberDetailsView { flex-direction: column; flex: 1; } @@ -15,11 +15,11 @@ display: flex; } -.RoomDetailsView_name h2 { +.RoomDetailsView_name h2, .MemberDetailsView_name h2 { text-align: center; } -.RoomDetailsView_label, .RoomDetailsView_row, .RoomDetailsView, .EncryptionIconView { +.RoomDetailsView_label, .RoomDetailsView_row, .RoomDetailsView, .MemberDetailsView, .EncryptionIconView { display: flex; align-items: center; } @@ -45,7 +45,7 @@ visibility: hidden; } -.MemberTileView { +.MemberTileView a { display: flex; align-items: center; } diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 56eaf224..b1ef7ab8 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -807,7 +807,7 @@ button.link { padding-top: 0; } -.RoomDetailsView_id { +.RoomDetailsView_id, .MemberDetailsView_id { color: #737D8C; font-size: 12px; } @@ -817,7 +817,7 @@ button.link { width: 100%; } -.RoomDetailsView_name h2 { +.RoomDetailsView_name h2, .MemberDetailsView_name h2 { margin-bottom: 4px; font-size: 1.8rem; } @@ -916,6 +916,11 @@ button.RoomDetailsView_row::after { .MemberTileView { margin-bottom: 8px; + list-style: none; +} + +.MemberTileView a { + text-decoration: none; } .MemberTileView .avatar { @@ -929,6 +934,37 @@ button.RoomDetailsView_row::after { flex: 1; } +/* Member details panel */ +.MemberDetailsView_section { + box-sizing: border-box; + padding: 16px; + width: 100%; +} + +.MemberDetailsView_label { + font-size: 12px; + font-weight: 600; + color: #8d99a5; + text-transform: uppercase; +} + +.MemberDetailsView_value, .MemberDetailsView_options { + margin-left: 8px; + margin-top: 5px; + font-size: 12px; +} + +.MemberDetailsView_options { + display: inline-flex; + flex-direction: column; +} + +.MemberDetailsView_options a{ + color: #0dbd8b; + text-decoration: none; + margin-bottom: 3px; +} + .LazyListParent { overflow-y: auto; } diff --git a/src/platform/web/ui/session/rightpanel/MemberDetailsView.js b/src/platform/web/ui/session/rightpanel/MemberDetailsView.js new file mode 100644 index 00000000..5ada0912 --- /dev/null +++ b/src/platform/web/ui/session/rightpanel/MemberDetailsView.js @@ -0,0 +1,53 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {AvatarView} from "../../AvatarView.js"; +import {TemplateView} from "../../general/TemplateView.js"; + +export class MemberDetailsView extends TemplateView { + render(t, vm) { + return t.div({className: "MemberDetailsView"}, + [ t.view(new AvatarView(vm, 128)), + t.div({className: "MemberDetailsView_name"}, t.h2(vm => vm.name)), + t.div({className: "MemberDetailsView_id"}, vm.userId), + this._createSection(t, vm.i18n`Role`, vm => vm.role), + this._createSection(t, vm.i18n`Security`, vm.isEncrypted ? + vm.i18n`Messages in this room are end-to-end encrypted.` : + vm.i18n`Messages in this room are not end-to-end encrypted.` + ), + this._createOptions(t, vm) + ]); + } + + _createSection(t, label, value) { + return t.div({ className: "MemberDetailsView_section" }, + [ + t.div({className: "MemberDetailsView_label"}, label), + t.div({className: "MemberDetailsView_value"}, value) + ]); + } + + _createOptions(t, vm) { + return t.div({ className: "MemberDetailsView_section" }, + [ + t.div({className: "MemberDetailsView_label"}, vm.i18n`Options`), + t.div({className: "MemberDetailsView_options"}, + [ + t.a({href: vm.linkToUser, target: "_blank", rel: "noopener"}, vm.i18n`Open Link to User`) + ]) + ]); + } +} diff --git a/src/platform/web/ui/session/rightpanel/MemberTileView.js b/src/platform/web/ui/session/rightpanel/MemberTileView.js index f3d5f0e1..df9f597a 100644 --- a/src/platform/web/ui/session/rightpanel/MemberTileView.js +++ b/src/platform/web/ui/session/rightpanel/MemberTileView.js @@ -19,9 +19,12 @@ 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), - ]); + return t.li({ className: "MemberTileView" }, + t.a({ href: vm.detailsUrl }, + [ + t.view(new AvatarView(vm, 32)), + t.div({ className: "MemberTileView_name" }, (vm) => vm.name), + ]) + ); } } diff --git a/src/platform/web/ui/session/rightpanel/RightPanelView.js b/src/platform/web/ui/session/rightpanel/RightPanelView.js index 9ef97ad9..b94d306e 100644 --- a/src/platform/web/ui/session/rightpanel/RightPanelView.js +++ b/src/platform/web/ui/session/rightpanel/RightPanelView.js @@ -18,20 +18,31 @@ import {TemplateView} from "../../general/TemplateView.js"; import {RoomDetailsView} from "./RoomDetailsView.js"; import {MemberListView} from "./MemberListView.js"; import {LoadingView} from "../../general/LoadingView.js"; +import {MemberDetailsView} from "./MemberDetailsView.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()) + t.mapView(vm => vm.activeViewModel, vm => this._viewFromType(vm)) ] ); } + + _viewFromType(vm) { + const type = vm?.type; + switch (type) { + case "room-details": + return new RoomDetailsView(vm); + case "member-list": + return new MemberListView(vm); + case "member-details": + return new MemberDetailsView(vm); + default: + return new LoadingView(); + } + } } class ButtonsView extends TemplateView {