diff --git a/.eslintrc.js b/.eslintrc.js index 2a14eac6..ebc08582 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -12,6 +12,6 @@ module.exports = { "no-console": "off", "no-empty": "off", "no-prototype-builtins": "off", - "no-unused-vars": "warn", + "no-unused-vars": "warn" } }; diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index 3a9b4a07..fab91c11 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -37,7 +37,7 @@ 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"; + return type === "lightbox" || type === "details"; default: return false; } @@ -113,6 +113,9 @@ export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) { segments.push(roomsSegmentWithRoom(rooms, roomId, currentNavPath)); } segments.push(new Segment("room", roomId)); + if (currentNavPath.get("details")?.value) { + segments.push(new Segment("details")); + } } else if (type === "last-session") { let sessionSegment = currentNavPath.get("session"); if (typeof sessionSegment?.value !== "string" && defaultSessionId) { @@ -254,6 +257,25 @@ export function tests() { assert.equal(segments[2].type, "room"); assert.equal(segments[2].value, "a"); }, + "parse open-room action changing focus to an existing room with details open": 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("details", true) + ]); + const segments = parseUrlPath("/session/1/open-room/a", path); + assert.equal(segments.length, 4); + 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].value, true); + }, "parse open-room action setting a room in an empty tile": assert => { const nav = new Navigation(allowsChild); const path = nav.pathFrom([ diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index aee80b6a..dddc603b 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -78,13 +78,23 @@ export class RoomGridViewModel extends ViewModel { return this._height; } + _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)); + } + this.navigation.applyPath(path); + } + focusTile(index) { if (index === this._selectedIndex) { return; } const vmo = this._viewModelsObservables[index]; if (vmo) { - this.navigation.push("room", vmo.id); + this._switchToRoom(vmo.id); } else { this.navigation.push("empty-grid-tile", index); } @@ -146,7 +156,7 @@ export class RoomGridViewModel extends ViewModel { this.emitChange(); viewModel?.focus(); } - + /** called from SessionViewModel */ releaseRoomViewModel(roomId) { const index = this._viewModelsObservables.findIndex(vmo => vmo && vmo.id === roomId); diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 7a84e67b..087b9315 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -17,6 +17,7 @@ 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"; @@ -62,6 +63,7 @@ export class SessionViewModel extends ViewModel { if (!this._gridViewModel) { this._updateRoom(roomId); } + this._updateRoomDetails(); })); if (!this._gridViewModel) { this._updateRoom(currentRoomId.get()); @@ -78,6 +80,10 @@ export class SessionViewModel extends ViewModel { this._updateLightbox(eventId); })); this._updateLightbox(lightbox.get()); + + const details = this.navigation.observe("details"); + this.track(details.subscribe(() => this._updateRoomDetails())); + this._updateRoomDetails(); } get id() { @@ -112,6 +118,10 @@ export class SessionViewModel extends ViewModel { return this._roomViewModelObservable?.get(); } + get roomDetailsViewModel() { + return this._roomDetailsViewModel; + } + _updateGrid(roomIds) { const changed = !(this._gridViewModel && roomIds); const currentRoomId = this.navigation.path.get("room"); @@ -230,8 +240,7 @@ export class SessionViewModel extends ViewModel { this._lightboxViewModel = this.disposeTracked(this._lightboxViewModel); } if (eventId) { - const roomId = this.navigation.path.get("room").value; - const room = this._sessionContainer.session.rooms.get(roomId); + const room = this._roomFromNavigation(); this._lightboxViewModel = this.track(new LightboxViewModel(this.childOptions({eventId, room}))); } this.emitChange("lightboxViewModel"); @@ -240,4 +249,22 @@ export class SessionViewModel extends ViewModel { get lightboxViewModel() { return this._lightboxViewModel; } + + _roomFromNavigation() { + const roomId = this.navigation.path.get("room")?.value; + const room = this._sessionContainer.session.rooms.get(roomId); + return room; + } + + _updateRoomDetails() { + this._roomDetailsViewModel = this.disposeTracked(this._roomDetailsViewModel); + const enable = !!this.navigation.path.get("details")?.value; + if (enable) { + const room = this._roomFromNavigation(); + if (!room) { return; } + this._roomDetailsViewModel = this.track(new RoomDetailsViewModel(this.childOptions({room}))); + } + this.emitChange("roomDetailsViewModel"); + } + } diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index 6503c124..061c640c 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -92,26 +92,30 @@ 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) { - let path = this.navigation.path.until("session"); - const room = this.navigation.path.get("room"); if (room) { path = path.with(room); + path = this._pathForDetails(path); } - this.navigation.applyPath(path); } else { - let path = this.navigation.path.until("session"); - const room = this.navigation.path.get("room"); if (room) { path = path.with(this.navigation.segment("rooms", [room.value])); path = path.with(room); + path = this._pathForDetails(path); } else { path = path.with(this.navigation.segment("rooms", [])); path = path.with(this.navigation.segment("empty-grid-tile", 0)); } - this.navigation.applyPath(path); } + this.navigation.applyPath(path); } get tileViewModels() { diff --git a/src/domain/session/rightpanel/RoomDetailsViewModel.js b/src/domain/session/rightpanel/RoomDetailsViewModel.js new file mode 100644 index 00000000..911e5945 --- /dev/null +++ b/src/domain/session/rightpanel/RoomDetailsViewModel.js @@ -0,0 +1,61 @@ +import {ViewModel} from "../../ViewModel.js"; +import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; + +export class RoomDetailsViewModel extends ViewModel { + constructor(options) { + super(options); + this._room = options.room; + this._onRoomChange = this._onRoomChange.bind(this); + this._room.on("change", this._onRoomChange); + } + + get roomId() { + return this._room.id; + } + + get canonicalAlias() { + return this._room.canonicalAlias; + } + + get name() { + return this._room.name; + } + + get isEncrypted() { + return !!this._room.isEncrypted; + } + + get memberCount() { + return this._room.joinedMemberCount; + } + + get avatarLetter() { + return avatarInitials(this.name); + } + + get avatarColorNumber() { + return getIdentifierColorNumber(this.roomId) + } + + avatarUrl(size) { + return getAvatarHttpUrl(this._room.avatarUrl, size, this.platform, this._room.mediaRepository); + } + + get avatarTitle() { + return this.name; + } + + _onRoomChange() { + this.emitChange(); + } + + closePanel() { + const path = this.navigation.path.until("room"); + this.navigation.applyPath(path); + } + + dispose() { + super.dispose(); + this._room.off("change", this._onRoomChange); + } +} diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index d2d46c32..4d53ec6c 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -283,10 +283,16 @@ export class RoomViewModel extends ViewModel { console.error(err.stack); } } - + get composerViewModel() { return this._composerVM; } + + openDetailsPanel() { + let path = this.navigation.path.until("room"); + path = path.with(this.navigation.segment("details", true)); + this.navigation.applyPath(path); + } } class ComposerViewModel extends ViewModel { @@ -383,4 +389,4 @@ class ArchivedViewModel extends ViewModel { get kind() { return "archived"; } -} \ No newline at end of file +} diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index a1bf7b03..cef443c1 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -362,6 +362,14 @@ export class BaseRoom extends EventEmitter { return this.membership === "leave"; } + get canonicalAlias() { + return this._summary.data.canonicalAlias; + } + + get joinedMemberCount() { + return this._summary.data.joinCount; + } + get mediaRepository() { return this._mediaRepository; } diff --git a/src/platform/web/ui/css/avatar.css b/src/platform/web/ui/css/avatar.css index 6e68236f..d369f85f 100644 --- a/src/platform/web/ui/css/avatar.css +++ b/src/platform/web/ui/css/avatar.css @@ -53,6 +53,14 @@ limitations under the License. font-size: calc(var(--avatar-size) * 0.6); } +.hydrogen .avatar.size-52 { + --avatar-size: 52px; + width: var(--avatar-size); + height: var(--avatar-size); + line-height: var(--avatar-size); + font-size: calc(var(--avatar-size) * 0.6); +} + .hydrogen .avatar.size-30 { --avatar-size: 30px; width: var(--avatar-size); diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index 9670afad..fecbbd60 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -54,6 +54,13 @@ main { min-width: 0; } +.right-shown{ + grid-template: + "status status status" auto + "left middle right" 1fr / + 300px 1fr 300px; +} + /* resize and reposition session view to account for mobile Safari which shifts the layout viewport up without resizing it when the keyboard shows */ .hydrogen.ios .SessionView { @@ -65,7 +72,7 @@ the layout viewport up without resizing it when the keyboard shows */ .middle .close-middle { display: none; } /* mobile layout */ @media screen and (max-width: 800px) { - .SessionView:not(.middle-shown) { + .SessionView:not(.middle-shown):not(.right-shown) { grid-template: "status" auto "left" 1fr / @@ -79,8 +86,16 @@ the layout viewport up without resizing it when the keyboard shows */ 1fr; } - .SessionView:not(.middle-shown) .room-placeholder { display: none; } + .SessionView.right-shown{ + grid-template: + "status" auto + "right" 1fr / + 1fr; + } + + .SessionView:not(.middle-shown):not(.right-shown) .room-placeholder { display: none; } .SessionView.middle-shown .LeftPanel { display: none; } + .SessionView.right-shown .middle, .SessionView.right-shown .LeftPanel { display: none; } /* show back button */ .middle .close-middle { display: block !important; } @@ -179,6 +194,11 @@ the layout viewport up without resizing it when the keyboard shows */ z-index: 2; } +.menu .menu-item{ + box-sizing: border-box; + width: 100%; +} + .Settings { display: flex; flex-direction: column; diff --git a/src/platform/web/ui/css/main.css b/src/platform/web/ui/css/main.css index aa22839e..82b849c8 100644 --- a/src/platform/web/ui/css/main.css +++ b/src/platform/web/ui/css/main.css @@ -18,6 +18,7 @@ limitations under the License. @import url('layout.css'); @import url('login.css'); @import url('left-panel.css'); +@import url('right-panel.css'); @import url('room.css'); @import url('timeline.css'); @import url('avatar.css'); diff --git a/src/platform/web/ui/css/right-panel.css b/src/platform/web/ui/css/right-panel.css new file mode 100644 index 00000000..f3f34e38 --- /dev/null +++ b/src/platform/web/ui/css/right-panel.css @@ -0,0 +1,31 @@ +.RoomDetailsView { + grid-area: right; + flex-direction: column; +} + +.RoomDetailsView_avatar { + display: flex; +} + +.RoomDetailsView_name h2 { + text-align: center; +} + +.RoomDetailsView_row { + justify-content: space-between; +} + +.RoomDetailsView_label, .RoomDetailsView_row, .RoomDetailsView, .EncryptionIconView { + display: flex; + align-items: center; +} + +.EncryptionIconView { + justify-content: center; +} + +.RoomDetailsView_buttons { + display: flex; + justify-content: flex-end; + width: 100%; +} diff --git a/src/platform/web/ui/css/themes/element/icons/e2ee-disabled.svg b/src/platform/web/ui/css/themes/element/icons/e2ee-disabled.svg new file mode 100644 index 00000000..26e669fc --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/e2ee-disabled.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/platform/web/ui/css/themes/element/icons/e2ee-normal.svg b/src/platform/web/ui/css/themes/element/icons/e2ee-normal.svg new file mode 100644 index 00000000..9d981ee7 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/e2ee-normal.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/platform/web/ui/css/themes/element/icons/encryption-status.svg b/src/platform/web/ui/css/themes/element/icons/encryption-status.svg new file mode 100644 index 00000000..8c81d4cd --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/encryption-status.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/platform/web/ui/css/themes/element/icons/info.svg b/src/platform/web/ui/css/themes/element/icons/info.svg new file mode 100644 index 00000000..d55e9356 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/info.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/platform/web/ui/css/themes/element/icons/room-members.svg b/src/platform/web/ui/css/themes/element/icons/room-members.svg new file mode 100644 index 00000000..bc03be13 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/room-members.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 348f1dcd..885383d7 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -22,7 +22,6 @@ limitations under the License. font-size: 10px; } - .hydrogen { font-family: 'Inter', sans-serif, 'emoji'; background-color: white; @@ -332,7 +331,6 @@ a { align-items: center; } - .SessionStatusView button.link { color: currentcolor; text-align: left; @@ -456,6 +454,10 @@ a { background-image: url("./icons/vertical-ellipsis.svg"); } +.RoomHeader .room-info { + background-image: url("./icons/info.svg"); +} + .RoomView_error { color: red; } @@ -660,6 +662,25 @@ button.link { margin: 0; } +.menu li{ + margin-bottom: 10px; +} + +.menu button { + border-radius: 4px; + border: none; + background-color: transparent; + text-align: left; + padding: 8px 32px 8px 8px; + font-size: 1.5rem; + height: 24px; + cursor: pointer; +} + +.menu .destructive button { + color: #FF4B55; +} + .menu .quick-reactions { display: flex; padding: 8px 32px 8px 8px; @@ -670,20 +691,6 @@ button.link { text-align: center; } -.menu button { - border-radius: 4px; - display: block; - border: none; - width: 100%; - background-color: transparent; - text-align: left; - padding: 8px 32px 8px 8px; -} - -.menu .destructive button { - color: #FF4B55; -} - .InviteView_body { display: flex; justify-content: space-around; @@ -779,3 +786,82 @@ button.link { max-width: 200px; width: 100%; } + +/* Right Panel */ + +.RoomDetailsView { + background: rgba(245, 245, 245, 0.90); + padding: 16px; +} + +.RoomDetailsView_id { + color: #737D8C; + font-size: 12px; +} + +.RoomDetailsView_rows{ + margin-top: 36px; + width: 100%; +} + +.RoomDetailsView_name h2 { + margin-bottom: 4px; + font-size: 1.8rem; +} + +.RoomDetailsView_row { + margin-bottom: 20px; + font-weight: 500; + font-size: 15px; +} + +.RoomDetailsView_label::before { + padding-right: 16px; + height: 24px; + width: 20px; +} + +.RoomDetailsView_value { + color: #737D8C; +} + +.MemberCount::before { + content: url("./icons/room-members.svg"); +} + +.EncryptionStatus::before { + content: url("./icons/encryption-status.svg"); +} + +/* Encryption icon next to avatar */ + +.EncryptionIconView { + width: 52px; + height: 52px; + border-radius: 100%; + background: #737D8C; + border: 3px solid #F2F5F8; + margin-left: -16px; +} + +.EncryptionIconView_encrypted, .EncryptionIconView_unencrypted { + height: 24px; + width: 24px; +} + +.EncryptionIconView_encrypted { + content: url("./icons/e2ee-normal.svg"); +} + +.EncryptionIconView_unencrypted { + content: url("./icons/e2ee-disabled.svg"); +} + +.RoomDetailsView .button-utility { + width: 24px; + height: 24px; +} + +.RoomDetailsView .close { + background-image: url("./icons/clear.svg"); +} diff --git a/src/platform/web/ui/general/Menu.js b/src/platform/web/ui/general/Menu.js index be5dea1d..b846b0e5 100644 --- a/src/platform/web/ui/general/Menu.js +++ b/src/platform/web/ui/general/Menu.js @@ -59,6 +59,6 @@ class MenuOption { } return t.li({ className, - }, t.button({onClick: this.callback}, this.label)); + }, t.button({className:"menu-item", onClick: this.callback}, this.label)); } } diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index c8a77a1b..877cc67c 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -25,13 +25,15 @@ 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"; export class SessionView extends TemplateView { render(t, vm) { return t.div({ className: { "SessionView": true, - "middle-shown": vm => !!vm.activeMiddleViewModel + "middle-shown": vm => !!vm.activeMiddleViewModel, + "right-shown": vm => !!vm.roomDetailsViewModel }, }, [ t.view(new SessionStatusView(vm.sessionStatusViewModel)), @@ -53,6 +55,7 @@ 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) ]); } diff --git a/src/platform/web/ui/session/rightpanel/RoomDetailsView.js b/src/platform/web/ui/session/rightpanel/RoomDetailsView.js new file mode 100644 index 00000000..a6d1a81f --- /dev/null +++ b/src/platform/web/ui/session/rightpanel/RoomDetailsView.js @@ -0,0 +1,51 @@ +import {TemplateView} from "../../general/TemplateView.js"; +import {classNames, tag} from "../../general/html.js"; +import {AvatarView} from "../../avatar.js"; + +export class RoomDetailsView extends TemplateView { + render(t, vm) { + const encryptionString = () => vm.isEncrypted ? vm.i18n`On` : vm.i18n`Off`; + return t.div({className: "RoomDetailsView"}, [ + this._createButton(t, vm), + t.div({className: "RoomDetailsView_avatar"}, + [ + t.view(new AvatarView(vm, 52)), + t.mapView(vm => vm.isEncrypted, isEncrypted => new EncryptionIconView(isEncrypted)) + ]), + t.div({className: "RoomDetailsView_name"}, [t.h2(vm => vm.name)]), + this._createRoomAliasDisplay(vm), + t.div({className: "RoomDetailsView_rows"}, + [ + this._createRightPanelRow(t, vm.i18n`People`, {MemberCount: true}, vm => vm.memberCount), + this._createRightPanelRow(t, vm.i18n`Encryption`, {EncryptionStatus: true}, encryptionString) + ]) + ]); + } + + _createRoomAliasDisplay(vm) { + return vm.canonicalAlias ? tag.div({className: "RoomDetailsView_id"}, [vm.canonicalAlias]) : + ""; + } + + _createRightPanelRow(t, label, labelClass, value) { + const labelClassString = classNames({RoomDetailsView_label: true, ...labelClass}); + return t.div({className: "RoomDetailsView_row"}, [ + t.div({className: labelClassString}, [label]), + t.div({className: "RoomDetailsView_value"}, value) + ]); + } + + _createButton(t, vm) { + return t.div({className: "RoomDetailsView_buttons"}, + [ + t.button({className: "close button-utility", onClick: () => vm.closePanel()}) + ]); + } +} + +class EncryptionIconView extends TemplateView { + render(t, isEncrypted) { + return t.div({className: "EncryptionIconView"}, + [t.div({className: isEncrypted ? "EncryptionIconView_encrypted" : "EncryptionIconView_unencrypted"})]); + } +} diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index 40d7d3c4..f8e84f87 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -68,6 +68,7 @@ export class RoomView extends TemplateView { } else { const vm = this.value; const options = []; + options.push(Menu.option(vm.i18n`Room details`, () => vm.openDetailsPanel())) if (vm.canLeave) { options.push(Menu.option(vm.i18n`Leave room`, () => vm.leaveRoom()).setDestructive()); }