diff --git a/res/css/_components.scss b/res/css/_components.scss index c8ea237dcd..40a2c576d0 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -90,6 +90,7 @@ @import "./views/elements/_ErrorBoundary.scss"; @import "./views/elements/_EventListSummary.scss"; @import "./views/elements/_Field.scss"; +@import "./views/elements/_IconButton.scss"; @import "./views/elements/_ImageView.scss"; @import "./views/elements/_InlineSpinner.scss"; @import "./views/elements/_InteractiveTooltip.scss"; diff --git a/res/css/views/elements/_Field.scss b/res/css/views/elements/_Field.scss index 4d012a136e..b260d4b097 100644 --- a/res/css/views/elements/_Field.scss +++ b/res/css/views/elements/_Field.scss @@ -49,6 +49,7 @@ limitations under the License. color: $primary-fg-color; background-color: $primary-bg-color; flex: 1; + min-width: 0; } .mx_Field select { diff --git a/res/css/views/elements/_IconButton.scss b/res/css/views/elements/_IconButton.scss new file mode 100644 index 0000000000..d8ebbeb65e --- /dev/null +++ b/res/css/views/elements/_IconButton.scss @@ -0,0 +1,55 @@ +/* +Copyright 2019 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. +*/ + +.mx_IconButton { + width: 32px; + height: 32px; + border-radius: 100%; + background-color: $accent-bg-color; + // don't shrink or grow if in a flex container + flex: 0 0 auto; + + &.mx_AccessibleButton_disabled { + background-color: none; + + &::before { + background-color: lightgrey; + } + } + + &:hover { + opacity: 90%; + } + + &::before { + content: ""; + display: block; + width: 100%; + height: 100%; + mask-repeat: no-repeat; + mask-position: center; + mask-size: 55%; + background-color: $accent-color; + } + + &.mx_IconButton_icon_check::before { + mask-image: url('$(res)/img/feather-customised/check.svg'); + } + + &.mx_IconButton_icon_edit::before { + mask-image: url('$(res)/img/feather-customised/edit.svg'); + } +} diff --git a/res/css/views/messages/_MKeyVerificationRequest.scss b/res/css/views/messages/_MKeyVerificationRequest.scss index aff44e4109..b4cde4e7ef 100644 --- a/res/css/views/messages/_MKeyVerificationRequest.scss +++ b/res/css/views/messages/_MKeyVerificationRequest.scss @@ -25,7 +25,7 @@ limitations under the License. width: 12px; height: 16px; content: ""; - mask: url("$(res)/img/e2e/verified.svg"); + mask: url("$(res)/img/e2e/normal.svg"); mask-repeat: no-repeat; mask-size: 100%; margin-top: 4px; @@ -33,6 +33,7 @@ limitations under the License. } &.mx_KeyVerification_icon_verified::after { + mask: url("$(res)/img/e2e/verified.svg"); background-color: $accent-color; } diff --git a/res/css/views/right_panel/_UserInfo.scss b/res/css/views/right_panel/_UserInfo.scss index df536a7388..c68f3ffd37 100644 --- a/res/css/views/right_panel/_UserInfo.scss +++ b/res/css/views/right_panel/_UserInfo.scss @@ -20,156 +20,222 @@ limitations under the License. flex-direction: column; flex: 1; overflow-y: auto; -} - -.mx_UserInfo_profile .mx_E2EIcon { - display: inline; - margin: auto; - padding-right: 25px; - mask-size: contain; -} - -.mx_UserInfo_cancel { - height: 16px; - width: 16px; - padding: 10px 0 10px 10px; - cursor: pointer; - mask-image: url('$(res)/img/minimise.svg'); - mask-repeat: no-repeat; - mask-position: 16px center; - background-color: $rightpanel-button-color; -} - -.mx_UserInfo_profile h2 { - flex: 1; - overflow-x: auto; - max-height: 50px; -} - -.mx_UserInfo h2 { - font-size: 16px; - font-weight: 600; - margin: 16px 0 8px 0; -} - -.mx_UserInfo_container { - padding: 0 16px 16px 16px; - border-bottom: 1px solid lightgray; -} - -.mx_UserInfo_memberDetailsContainer { - padding-bottom: 0; -} - -.mx_UserInfo .mx_RoomTile_nameContainer { - width: 154px; -} - -.mx_UserInfo .mx_RoomTile_badge { - display: none; -} - -.mx_UserInfo .mx_RoomTile_name { - width: 160px; -} - -.mx_UserInfo_avatar { - background: $tagpanel-bg-color; -} - -.mx_UserInfo_avatar > img { - height: auto; - width: 100%; - max-height: 30vh; - object-fit: contain; - display: block; -} - -.mx_UserInfo_avatar .mx_BaseAvatar.mx_BaseAvatar_image { - cursor: zoom-in; -} - -.mx_UserInfo h3 { - text-transform: uppercase; - color: $input-darker-fg-color; - font-weight: bold; font-size: 12px; - margin: 4px 0; -} -.mx_UserInfo_profileField { - font-size: 15px; - position: relative; - text-align: center; -} - -.mx_UserInfo_memberDetails { - text-align: center; -} - -.mx_UserInfo_field { - cursor: pointer; - font-size: 15px; - color: $primary-fg-color; - margin-left: 8px; - line-height: 23px; -} - -.mx_UserInfo_createRoom { - cursor: pointer; - display: flex; - align-items: center; - padding: 0 8px; -} - -.mx_UserInfo_createRoom_label { - width: initial !important; - cursor: pointer; -} - -.mx_UserInfo_statusMessage { - font-size: 11px; - opacity: 0.5; - overflow: hidden; - white-space: nowrap; - text-overflow: clip; -} -.mx_UserInfo .mx_UserInfo_scrollContainer { - flex: 1; - padding-bottom: 16px; -} - -.mx_UserInfo .mx_UserInfo_scrollContainer .mx_UserInfo_container { - padding-top: 16px; - padding-bottom: 0; - border-bottom: none; -} - -.mx_UserInfo_container_header { - display: flex; -} - -.mx_UserInfo_container_header_right { - position: relative; - margin-left: auto; -} - -.mx_UserInfo_newDmButton { - background-color: $roomheader-addroom-bg-color; - border-radius: 10px; // 16/2 + 2 padding - height: 16px; - flex: 0 0 16px; - - &::before { - background-color: $roomheader-addroom-fg-color; - mask: url('$(res)/img/icons-room-add.svg'); + .mx_UserInfo_cancel { + height: 16px; + width: 16px; + padding: 10px 0 10px 10px; + cursor: pointer; + mask-image: url('$(res)/img/minimise.svg'); mask-repeat: no-repeat; - mask-position: center; - content: ''; + mask-position: 16px center; + background-color: $rightpanel-button-color; position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; + } + + h2 { + font-size: 18px; + font-weight: 600; + margin: 18px 0 0 0; + } + + .mx_UserInfo_container { + padding: 0 16px 16px 16px; + border-bottom: 1px solid lightgray; + } + + .mx_UserInfo_memberDetailsContainer { + padding-bottom: 0; + } + + .mx_RoomTile_nameContainer { + width: 154px; + } + + .mx_RoomTile_badge { + display: none; + } + + .mx_RoomTile_name { + width: 160px; + } + + .mx_UserInfo_avatar { + margin: 24px 32px 0 32px; + cursor: pointer; + } + + .mx_UserInfo_avatar > div { + max-width: 30vh; + margin: 0 auto; + } + + .mx_UserInfo_avatar > div > div { + /* use padding-top instead of height to make this element square, + as the % in padding is a % of the width (including margin, + that's why we had to put the margin to center on a parent div), + and not a % of the parent height. */ + padding-top: 100%; + height: 0; + border-radius: 100%; + box-sizing: content-box; + background-repeat: no-repeat; + background-size: cover; + background-position: center; + } + + .mx_UserInfo_avatar .mx_BaseAvatar.mx_BaseAvatar_image { + cursor: zoom-in; + } + + h3 { + text-transform: uppercase; + color: $notice-secondary-color; + font-weight: bold; + font-size: 12px; + margin: 4px 0; + } + + p { + margin: 5px 0; + } + + .mx_UserInfo_profile { + text-align: center; + + h2 { + font-size: 18px; + line-height: 25px; + flex: 1; + overflow-x: auto; + max-height: 50px; + display: flex; + justify-content: center; + align-items: center; + + .mx_E2EIcon { + margin: 5px; + } + } + + .mx_UserInfo_profileStatus { + margin-top: 12px; + } + } + + .mx_UserInfo_memberDetails .mx_UserInfo_profileField { + display: flex; + justify-content: center; + align-items: center; + + margin: 6px 0; + + .mx_IconButton, .mx_Spinner { + margin-left: 20px; + width: 16px; + height: 16px; + + &::before { + mask-size: 80%; + } + } + + .mx_UserInfo_roleDescription { + display: flex; + justify-content: center; + align-items: center; + // try to make it the same height as the dropdown + margin: 11px 0 12px 0; + + .mx_IconButton { + margin-left: 6px; + } + } + + .mx_Field { + margin: 0; + } + } + + .mx_UserInfo_field { + cursor: pointer; + color: $accent-color; + line-height: 16px; + margin: 8px 0; + + &.mx_UserInfo_destructive { + color: $warning-color; + } + } + + .mx_UserInfo_statusMessage { + font-size: 11px; + opacity: 0.5; + overflow: hidden; + white-space: nowrap; + text-overflow: clip; + } + + .mx_UserInfo_scrollContainer { + flex: 1 1 0; + padding-bottom: 16px; + } + + .mx_UserInfo_scrollContainer .mx_UserInfo_container { + padding-top: 16px; + padding-bottom: 0; + border-bottom: none; + + > :not(h3) { + margin-left: 8px; + } + } + + .mx_UserInfo_devices { + .mx_UserInfo_device { + display: flex; + + &.mx_UserInfo_device_verified { + .mx_UserInfo_device_trusted { + color: $accent-color; + } + } + &.mx_UserInfo_device_unverified { + .mx_UserInfo_device_trusted { + color: $warning-color; + } + } + + .mx_UserInfo_device_name { + flex: 1; + margin-right: 5px; + } + } + + // both for icon in expand button and device item + .mx_E2EIcon { + // don't squeeze + flex: 0 0 auto; + margin: 2px 5px 0 0; + width: 12px; + height: 12px; + } + + .mx_UserInfo_expand { + display: flex; + margin-top: 11px; + color: $accent-color; + } + } + + .mx_UserInfo_verify { + display: block; + background-color: $accent-color; + color: $accent-fg-color; + border-radius: 4px; + padding: 7px 1.5em; + text-align: center; + margin: 16px 0; } } diff --git a/res/css/views/rooms/_E2EIcon.scss b/res/css/views/rooms/_E2EIcon.scss index 84a16611de..bc11ac6e1c 100644 --- a/res/css/views/rooms/_E2EIcon.scss +++ b/res/css/views/rooms/_E2EIcon.scss @@ -17,17 +17,56 @@ limitations under the License. .mx_E2EIcon { width: 25px; height: 25px; - mask-repeat: no-repeat; - mask-position: center 0; margin: 0 9px; + position: relative; + display: block; } -.mx_E2EIcon_verified { - mask-image: url('$(res)/img/e2e/lock-verified.svg'); +.mx_E2EIcon_verified::before, .mx_E2EIcon_warning::before { + content: ""; + display: block; + /* the symbols in the shield icons are cut out to make it themeable with css masking. + if they appear on a different background than white, the symbol wouldn't be white though, so we + add a rectangle here below the masked element to shine through the symbol cut-out. + hardcoding white and not using a theme variable as this would probably be white for any theme. */ + background-color: white; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; +} + +.mx_E2EIcon_verified::after, .mx_E2EIcon_warning::after { + content: ""; + display: block; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + mask-repeat: no-repeat; + mask-size: contain; +} + +.mx_E2EIcon_verified::before { + /* white rectangle below checkmark of shield */ + margin: 25% 28% 38% 25%; +} + + +.mx_E2EIcon_verified::after { + mask-image: url('$(res)/img/e2e/verified.svg'); background-color: $accent-color; } -.mx_E2EIcon_warning { - mask-image: url('$(res)/img/e2e/lock-warning.svg'); + +.mx_E2EIcon_warning::before { + /* white rectangle below "!" of shield */ + margin: 18% 40% 25% 40%; +} + +.mx_E2EIcon_warning::after { + mask-image: url('$(res)/img/e2e/warning.svg'); background-color: $warning-color; } diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index e9f33183f5..14562fe7ed 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -78,7 +78,10 @@ limitations under the License. .mx_MessageComposer_e2eIcon.mx_E2EIcon { position: absolute; left: 60px; - background-color: $composer-e2e-icon-color; + + &::after { + background-color: $composer-e2e-icon-color; + } } .mx_MessageComposer_noperm_error { diff --git a/res/img/e2e/normal.svg b/res/img/e2e/normal.svg new file mode 100644 index 0000000000..5b848bc27f --- /dev/null +++ b/res/img/e2e/normal.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/e2e/verified.svg b/res/img/e2e/verified.svg index 459a552a40..af6bb92297 100644 --- a/res/img/e2e/verified.svg +++ b/res/img/e2e/verified.svg @@ -1,3 +1,12 @@ - - + + + diff --git a/res/img/e2e/warning.svg b/res/img/e2e/warning.svg index 3d5fba550c..2501da6ab3 100644 --- a/res/img/e2e/warning.svg +++ b/res/img/e2e/warning.svg @@ -1,6 +1,12 @@ - - - - - + + + diff --git a/res/img/feather-customised/edit.svg b/res/img/feather-customised/edit.svg new file mode 100644 index 0000000000..f511aa1477 --- /dev/null +++ b/res/img/feather-customised/edit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/Roles.js b/src/Roles.js index 10c4ceaf1e..7cc3c880d7 100644 --- a/src/Roles.js +++ b/src/Roles.js @@ -28,8 +28,8 @@ export function levelRoleMap(usersDefault) { export function textualPowerLevel(level, usersDefault) { const LEVEL_ROLE_MAP = levelRoleMap(usersDefault); if (LEVEL_ROLE_MAP[level]) { - return LEVEL_ROLE_MAP[level] + (level !== undefined ? ` (${level})` : ` (${usersDefault})`); + return LEVEL_ROLE_MAP[level]; } else { - return level; + return _t("Custom (%(level)s)", {level}); } } diff --git a/src/components/views/elements/IconButton.js b/src/components/views/elements/IconButton.js new file mode 100644 index 0000000000..ef7b4a8399 --- /dev/null +++ b/src/components/views/elements/IconButton.js @@ -0,0 +1,34 @@ +/* +Copyright 2019 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 React from 'react'; +import PropTypes from 'prop-types'; +import AccessibleButton from "./AccessibleButton"; + +export default function IconButton(props) { + const {icon, className, ...restProps} = props; + + let newClassName = (className || "") + " mx_IconButton"; + newClassName = newClassName + " mx_IconButton_icon_" + icon; + + const allProps = Object.assign({}, restProps, {className: newClassName}); + + return React.createElement(AccessibleButton, allProps); +} + +IconButton.propTypes = Object.assign({ + icon: PropTypes.string, +}, AccessibleButton.propTypes); diff --git a/src/components/views/elements/PowerSelector.js b/src/components/views/elements/PowerSelector.js index 5bc8eeba58..e6babded32 100644 --- a/src/components/views/elements/PowerSelector.js +++ b/src/components/views/elements/PowerSelector.js @@ -129,10 +129,11 @@ module.exports = createReactClass({ render: function() { let picker; + const label = typeof this.props.label === "undefined" ? _t("Power level") : this.props.label; if (this.state.custom) { picker = ( ); @@ -151,7 +152,7 @@ module.exports = createReactClass({ picker = ( {options} diff --git a/src/components/views/right_panel/UserInfo.js b/src/components/views/right_panel/UserInfo.js index 207bf29998..53a87ed1c6 100644 --- a/src/components/views/right_panel/UserInfo.js +++ b/src/components/views/right_panel/UserInfo.js @@ -27,7 +27,6 @@ import sdk from '../../../index'; import { _t } from '../../../languageHandler'; import createRoom from '../../../createRoom'; import DMRoomMap from '../../../utils/DMRoomMap'; -import Unread from '../../../Unread'; import AccessibleButton from '../elements/AccessibleButton'; import SdkConfig from '../../../SdkConfig'; import SettingsStore from "../../../settings/SettingsStore"; @@ -40,6 +39,7 @@ import MatrixClientPeg from "../../../MatrixClientPeg"; import E2EIcon from "../rooms/E2EIcon"; import withLegacyMatrixClient from "../../../utils/withLegacyMatrixClient"; import {useEventEmitter} from "../../../hooks/useEventEmitter"; +import {textualPowerLevel} from '../../../Roles'; const _disambiguateDevices = (devices) => { const names = Object.create(null); @@ -63,10 +63,92 @@ const _getE2EStatus = (devices) => { return hasUnverifiedDevice ? "warning" : "verified"; }; -const DevicesSection = ({devices, userId, loading}) => { - const MemberDeviceInfo = sdk.getComponent('rooms.MemberDeviceInfo'); +async function unverifyUser(matrixClient, userId) { + const devices = await matrixClient.getStoredDevicesForUser(userId); + for (const device of devices) { + if (device.isVerified()) { + matrixClient.setDeviceVerified( + userId, device.deviceId, false, + ); + } + } +} + +function openDMForUser(matrixClient, userId) { + const dmRooms = DMRoomMap.shared().getDMRoomsForUserId(userId); + const lastActiveRoom = dmRooms.reduce((lastActiveRoom, roomId) => { + const room = matrixClient.getRoom(roomId); + if (!room || room.getMyMembership() === "leave") { + return lastActiveRoom; + } + if (!lastActiveRoom || lastActiveRoom.getLastActiveTimestamp() < room.getLastActiveTimestamp()) { + return room; + } + return lastActiveRoom; + }, null); + + if (lastActiveRoom) { + dis.dispatch({ + action: 'view_room', + room_id: lastActiveRoom.roomId, + }); + } else { + createRoom({dmUserId: userId}); + } +} + +function useIsEncrypted(cli, room) { + const [isEncrypted, setIsEncrypted] = useState(cli.isRoomEncrypted(room.roomId)); + + const update = useCallback((event) => { + if (event.getType() === "m.room.encryption") { + setIsEncrypted(cli.isRoomEncrypted(room.roomId)); + } + }, [cli, room]); + useEventEmitter(room.currentState, "RoomState.events", update); + return isEncrypted; +} + +function verifyDevice(userId, device) { + const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog'); + Modal.createTrackedDialog('Device Verify Dialog', '', DeviceVerifyDialog, { + userId: userId, + device: device, + }); +} + +function DeviceItem({userId, device}) { + const classes = classNames("mx_UserInfo_device", { + mx_UserInfo_device_verified: device.isVerified(), + mx_UserInfo_device_unverified: !device.isVerified(), + }); + const iconClasses = classNames("mx_E2EIcon", { + mx_E2EIcon_verified: device.isVerified(), + mx_E2EIcon_warning: !device.isVerified(), + }); + + const onDeviceClick = () => { + if (!device.isVerified()) { + verifyDevice(userId, device); + } + }; + + const deviceName = device.ambiguous ? + (device.getDisplayName() ? device.getDisplayName() : "") + " (" + device.deviceId + ")" : + device.getDisplayName(); + const trustedLabel = device.isVerified() ? _t("Trusted") : _t("Not trusted"); + return ( +
+
{deviceName}
+
{trustedLabel}
+ ); +} + +function DevicesSection({devices, userId, loading}) { const Spinner = sdk.getComponent("elements.Spinner"); + const [isExpanded, setExpanded] = useState(false); + if (loading) { // still loading return ; @@ -74,123 +156,50 @@ const DevicesSection = ({devices, userId, loading}) => { if (devices === null) { return _t("Unable to load device list"); } - if (devices.length === 0) { - return _t("No devices with registered encryption keys"); - } - return ( -
-

{ _t("Trust & Devices") }

-
- { devices.map((device, i) => ) } -
-
- ); -}; + const unverifiedDevices = devices.filter(d => !d.isVerified()); + const verifiedDevices = devices.filter(d => d.isVerified()); -const onRoomTileClick = (roomId) => { - dis.dispatch({ - action: 'view_room', - room_id: roomId, - }); -}; - -const DirectChatsSection = withLegacyMatrixClient(({matrixClient: cli, userId, startUpdating, stopUpdating}) => { - const onNewDMClick = async () => { - startUpdating(); - await createRoom({dmUserId: userId}); - stopUpdating(); - }; - - // TODO: Immutable DMs replaces a lot of this - // dmRooms will not include dmRooms that we have been invited into but did not join. - // Because DMRoomMap runs off account_data[m.direct] which is only set on join of dm room. - // XXX: we potentially want DMs we have been invited to, to also show up here :L - // especially as logic below concerns specially if we haven't joined but have been invited - const [dmRooms, setDmRooms] = useState(new DMRoomMap(cli).getDMRoomsForUserId(userId)); - - // TODO bind the below - // cli.on("Room", this.onRoom); - // cli.on("Room.name", this.onRoomName); - // cli.on("deleteRoom", this.onDeleteRoom); - - const accountDataHandler = useCallback((ev) => { - if (ev.getType() === "m.direct") { - const dmRoomMap = new DMRoomMap(cli); - setDmRooms(dmRoomMap.getDMRoomsForUserId(userId)); - } - }, [cli, userId]); - useEventEmitter(cli, "accountData", accountDataHandler); - - const RoomTile = sdk.getComponent("rooms.RoomTile"); - - const tiles = []; - for (const roomId of dmRooms) { - const room = cli.getRoom(roomId); - if (room) { - const myMembership = room.getMyMembership(); - // not a DM room if we have are not joined - if (myMembership !== 'join') continue; - - const them = room.getMember(userId); - // not a DM room if they are not joined - if (!them || !them.membership || them.membership !== 'join') continue; - - const highlight = room.getUnreadNotificationCount('highlight') > 0; - - tiles.push( - , - ); + let expandButton; + if (verifiedDevices.length) { + if (isExpanded) { + expandButton = ( setExpanded(false)}> +
{_t("Hide verified Sign-In's")}
+
); + } else { + expandButton = ( setExpanded(true)}> +
+
{_t("%(count)s verified Sign-In's", {count: verifiedDevices.length})}
+ ); } } - const labelClasses = classNames({ - mx_UserInfo_createRoom_label: true, - mx_RoomTile_name: true, + let deviceList = unverifiedDevices.map((device, i) => { + return (); }); - - let body = tiles; - if (!body) { - body = ( - -
- {_t("Start -
-
{ _t("Start a chat") }
-
- ); + if (isExpanded) { + const keyStart = unverifiedDevices.length; + deviceList = deviceList.concat(verifiedDevices.map((device, i) => { + return (); + })); } return ( -
-
-

{ _t("Direct messages") }

- -
- { body } +
+
{deviceList}
+
{expandButton}
); -}); +} -const UserOptionsSection = withLegacyMatrixClient(({matrixClient: cli, member, isIgnored, canInvite}) => { +const UserOptionsSection = withLegacyMatrixClient(({matrixClient: cli, member, isIgnored, canInvite, devices}) => { let ignoreButton = null; let insertPillButton = null; let inviteUserButton = null; let readReceiptButton = null; + const isMe = member.userId === cli.getUserId(); + const onShareUserClick = () => { const ShareDialog = sdk.getComponent("dialogs.ShareDialog"); Modal.createTrackedDialog('share room member dialog', '', ShareDialog, { @@ -200,7 +209,7 @@ const UserOptionsSection = withLegacyMatrixClient(({matrixClient: cli, member, i // Only allow the user to ignore the user if its not ourselves // same goes for jumping to read receipt - if (member.userId !== cli.getUserId()) { + if (!isMe) { const onIgnoreToggle = () => { const ignoredUsers = cli.getIgnoredUsers(); if (isIgnored) { @@ -214,7 +223,7 @@ const UserOptionsSection = withLegacyMatrixClient(({matrixClient: cli, member, i }; ignoreButton = ( - + { isIgnored ? _t("Unignore") : _t("Ignore") } ); @@ -285,15 +294,34 @@ const UserOptionsSection = withLegacyMatrixClient(({matrixClient: cli, member, i ); + let directMessageButton; + if (!isMe) { + directMessageButton = ( + openDMForUser(cli, member.userId)} className="mx_UserInfo_field"> + { _t('Direct message') } + + ); + } + let unverifyButton; + if (devices && devices.some(device => device.isVerified())) { + unverifyButton = ( + unverifyUser(cli, member.userId)} className="mx_UserInfo_field mx_UserInfo_destructive"> + { _t('Unverify user') } + + ); + } + return (
-

{ _t("User Options") }

-
+

{ _t("Options") }

+
+ { directMessageButton } { readReceiptButton } { shareUserButton } { insertPillButton } - { ignoreButton } { inviteUserButton } + { ignoreButton } + { unverifyButton }
); @@ -337,10 +365,13 @@ const _isMuted = (member, powerLevelContent) => { return member.powerLevel < levelToSend; }; -const useRoomPowerLevels = (room) => { +const useRoomPowerLevels = (cli, room) => { const [powerLevels, setPowerLevels] = useState({}); const update = useCallback(() => { + if (!room) { + return; + } const event = room.currentState.getStateEvents("m.room.power_levels", ""); if (event) { setPowerLevels(event.getContent()); @@ -352,7 +383,7 @@ const useRoomPowerLevels = (room) => { }; }, [room]); - useEventEmitter(room, "RoomState.events", update); + useEventEmitter(cli, "RoomState.members", update); useEffect(() => { update(); return () => { @@ -399,7 +430,7 @@ const RoomKickButton = withLegacyMatrixClient(({matrixClient: cli, member, start }; const kickLabel = member.membership === "invite" ? _t("Disinvite") : _t("Kick"); - return + return { kickLabel } ; }); @@ -472,7 +503,7 @@ const RedactMessagesButton = withLegacyMatrixClient(({matrixClient: cli, member} } }; - return + return { _t("Remove recent messages") } ; }); @@ -524,7 +555,11 @@ const BanToggleButton = withLegacyMatrixClient(({matrixClient: cli, member, star label = _t("Unban"); } - return + const classes = classNames("mx_UserInfo_field", { + mx_UserInfo_destructive: member.membership !== 'ban', + }); + + return { label } ; }); @@ -581,21 +616,24 @@ const MuteToggleButton = withLegacyMatrixClient( } }; + const classes = classNames("mx_UserInfo_field", { + mx_UserInfo_destructive: !isMuted, + }); + const muteLabel = isMuted ? _t("Unmute") : _t("Mute"); - return + return { muteLabel } ; }, ); const RoomAdminToolsContainer = withLegacyMatrixClient( - ({matrixClient: cli, room, children, member, startUpdating, stopUpdating}) => { + ({matrixClient: cli, room, children, member, startUpdating, stopUpdating, powerLevels}) => { let kickButton; let banButton; let muteButton; let redactButton; - const powerLevels = useRoomPowerLevels(room); const editPowerLevel = ( (powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) || powerLevels.state_default @@ -705,7 +743,7 @@ const GroupAdminToolsSection = withLegacyMatrixClient( }; const kickButton = ( - + { isInvited ? _t('Disinvite') : _t('Remove from community') } ); @@ -744,47 +782,17 @@ const useIsSynapseAdmin = (cli) => { return isAdmin; }; -// cli is injected by withLegacyMatrixClient -const UserInfo = withLegacyMatrixClient(({matrixClient: cli, user, groupId, roomId, onClose}) => { - // Load room if we are given a room id and memoize it - const room = useMemo(() => roomId ? cli.getRoom(roomId) : null, [cli, roomId]); - - // only display the devices list if our client supports E2E - const _enableDevices = cli.isCryptoEnabled(); - - // Load whether or not we are a Synapse Admin - const isSynapseAdmin = useIsSynapseAdmin(cli); - - // Check whether the user is ignored - const [isIgnored, setIsIgnored] = useState(cli.isUserIgnored(user.userId)); - // Recheck if the user or client changes - useEffect(() => { - setIsIgnored(cli.isUserIgnored(user.userId)); - }, [cli, user.userId]); - // Recheck also if we receive new accountData m.ignored_user_list - const accountDataHandler = useCallback((ev) => { - if (ev.getType() === "m.ignored_user_list") { - setIsIgnored(cli.isUserIgnored(user.userId)); - } - }, [cli, user.userId]); - useEventEmitter(cli, "accountData", accountDataHandler); - - // Count of how many operations are currently in progress, if > 0 then show a Spinner - const [pendingUpdateCount, setPendingUpdateCount] = useState(0); - const startUpdating = useCallback(() => { - setPendingUpdateCount(pendingUpdateCount + 1); - }, [pendingUpdateCount]); - const stopUpdating = useCallback(() => { - setPendingUpdateCount(pendingUpdateCount - 1); - }, [pendingUpdateCount]); - +function useRoomPermissions(cli, room, user) { const [roomPermissions, setRoomPermissions] = useState({ // modifyLevelMax is the max PL we can set this user to, typically min(their PL, our PL) && canSetPL modifyLevelMax: -1, + canEdit: false, canInvite: false, }); - const updateRoomPermissions = useCallback(async () => { - if (!room) return; + const updateRoomPermissions = useCallback(() => { + if (!room) { + return; + } const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); if (!powerLevelEvent) return; @@ -811,20 +819,197 @@ const UserInfo = withLegacyMatrixClient(({matrixClient: cli, user, groupId, room setRoomPermissions({ canInvite: me.powerLevel >= powerLevels.invite, + canEdit: modifyLevelMax >= 0, modifyLevelMax, }); }, [cli, user, room]); - useEventEmitter(cli, "RoomState.events", updateRoomPermissions); + useEventEmitter(cli, "RoomState.members", updateRoomPermissions); useEffect(() => { updateRoomPermissions(); return () => { setRoomPermissions({ maximalPowerLevel: -1, + canEdit: false, canInvite: false, }); }; }, [updateRoomPermissions]); + return roomPermissions; +} + +const PowerLevelSection = withLegacyMatrixClient(({matrixClient: cli, user, room, roomPermissions, powerLevels}) => { + const [isEditing, setEditing] = useState(false); + if (room && user.roomId) { // is in room + if (isEditing) { + return ( setEditing(false)} />); + } else { + const IconButton = sdk.getComponent('elements.IconButton'); + const powerLevelUsersDefault = powerLevels.users_default || 0; + const powerLevel = parseInt(user.powerLevel, 10); + const modifyButton = roomPermissions.canEdit ? + ( setEditing(true)} />) : null; + const role = textualPowerLevel(powerLevel, powerLevelUsersDefault); + const label = _t("%(role)s in %(roomName)s", + {role, roomName: room.name}, + {strong: label => {label}}, + ); + return ( +
+
{label}{modifyButton}
+
+ ); + } + } else { + return null; + } +}); + +const PowerLevelEditor = withLegacyMatrixClient(({matrixClient: cli, user, room, roomPermissions, onFinished}) => { + const [isUpdating, setIsUpdating] = useState(false); + const [selectedPowerLevel, setSelectedPowerLevel] = useState(parseInt(user.powerLevel, 10)); + const [isDirty, setIsDirty] = useState(false); + const onPowerChange = useCallback((powerLevel) => { + setIsDirty(true); + setSelectedPowerLevel(parseInt(powerLevel, 10)); + }, [setSelectedPowerLevel, setIsDirty]); + + const changePowerLevel = useCallback(async () => { + const _applyPowerChange = (roomId, target, powerLevel, powerLevelEvent) => { + return cli.setPowerLevel(roomId, target, parseInt(powerLevel), powerLevelEvent).then( + function() { + // NO-OP; rely on the m.room.member event coming down else we could + // get out of sync if we force setState here! + console.log("Power change success"); + }, function(err) { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Failed to change power level " + err); + Modal.createTrackedDialog('Failed to change power level', '', ErrorDialog, { + title: _t("Error"), + description: _t("Failed to change power level"), + }); + }, + ); + }; + + try { + if (!isDirty) { + return; + } + + setIsUpdating(true); + + const powerLevel = selectedPowerLevel; + + const roomId = user.roomId; + const target = user.userId; + + const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); + if (!powerLevelEvent) return; + + if (!powerLevelEvent.getContent().users) { + _applyPowerChange(roomId, target, powerLevel, powerLevelEvent); + return; + } + + const myUserId = cli.getUserId(); + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + + // If we are changing our own PL it can only ever be decreasing, which we cannot reverse. + if (myUserId === target) { + try { + if (!(await _warnSelfDemote())) return; + } catch (e) { + console.error("Failed to warn about self demotion: ", e); + } + await _applyPowerChange(roomId, target, powerLevel, powerLevelEvent); + return; + } + + const myPower = powerLevelEvent.getContent().users[myUserId]; + if (parseInt(myPower) === parseInt(powerLevel)) { + const {finished} = Modal.createTrackedDialog('Promote to PL100 Warning', '', QuestionDialog, { + title: _t("Warning!"), + description: +
+ { _t("You will not be able to undo this change as you are promoting the user " + + "to have the same power level as yourself.") }
+ { _t("Are you sure?") } +
, + button: _t("Continue"), + }); + + const [confirmed] = await finished; + if (confirmed) return; + } + await _applyPowerChange(roomId, target, powerLevel, powerLevelEvent); + } finally { + onFinished(); + } + }, [user.roomId, user.userId, cli, selectedPowerLevel, isDirty, setIsUpdating, onFinished, room]); + + const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); + const powerLevelUsersDefault = powerLevelEvent ? powerLevelEvent.getContent().users_default : 0; + const IconButton = sdk.getComponent('elements.IconButton'); + const Spinner = sdk.getComponent("elements.Spinner"); + const buttonOrSpinner = isUpdating ? : + ; + + const PowerSelector = sdk.getComponent('elements.PowerSelector'); + return ( +
+ + {buttonOrSpinner} +
+ ); +}); + +// cli is injected by withLegacyMatrixClient +const UserInfo = withLegacyMatrixClient(({matrixClient: cli, user, groupId, roomId, onClose}) => { + // Load room if we are given a room id and memoize it + const room = useMemo(() => roomId ? cli.getRoom(roomId) : null, [cli, roomId]); + + // only display the devices list if our client supports E2E + const _enableDevices = cli.isCryptoEnabled(); + + const powerLevels = useRoomPowerLevels(cli, room); + // Load whether or not we are a Synapse Admin + const isSynapseAdmin = useIsSynapseAdmin(cli); + + // Check whether the user is ignored + const [isIgnored, setIsIgnored] = useState(cli.isUserIgnored(user.userId)); + // Recheck if the user or client changes + useEffect(() => { + setIsIgnored(cli.isUserIgnored(user.userId)); + }, [cli, user.userId]); + // Recheck also if we receive new accountData m.ignored_user_list + const accountDataHandler = useCallback((ev) => { + if (ev.getType() === "m.ignored_user_list") { + setIsIgnored(cli.isUserIgnored(user.userId)); + } + }, [cli, user.userId]); + useEventEmitter(cli, "accountData", accountDataHandler); + + // Count of how many operations are currently in progress, if > 0 then show a Spinner + const [pendingUpdateCount, setPendingUpdateCount] = useState(0); + const startUpdating = useCallback(() => { + setPendingUpdateCount(pendingUpdateCount + 1); + }, [pendingUpdateCount]); + const stopUpdating = useCallback(() => { + setPendingUpdateCount(pendingUpdateCount - 1); + }, [pendingUpdateCount]); + + const roomPermissions = useRoomPermissions(cli, room, user); + const onSynapseDeactivate = useCallback(async () => { const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog'); const {finished} = Modal.createTrackedDialog('Synapse User Deactivation', '', QuestionDialog, { @@ -852,70 +1037,12 @@ const UserInfo = withLegacyMatrixClient(({matrixClient: cli, user, groupId, room } }, [cli, user.userId]); - const onPowerChange = useCallback(async (powerLevel) => { - const _applyPowerChange = (roomId, target, powerLevel, powerLevelEvent) => { - startUpdating(); - cli.setPowerLevel(roomId, target, parseInt(powerLevel), powerLevelEvent).then( - function() { - // NO-OP; rely on the m.room.member event coming down else we could - // get out of sync if we force setState here! - console.log("Power change success"); - }, function(err) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - console.error("Failed to change power level " + err); - Modal.createTrackedDialog('Failed to change power level', '', ErrorDialog, { - title: _t("Error"), - description: _t("Failed to change power level"), - }); - }, - ).finally(() => { - stopUpdating(); - }).done(); - }; - const roomId = user.roomId; - const target = user.userId; - - const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); - if (!powerLevelEvent) return; - - if (!powerLevelEvent.getContent().users) { - _applyPowerChange(roomId, target, powerLevel, powerLevelEvent); - return; + const onMemberAvatarKey = e => { + if (e.key === "Enter") { + onMemberAvatarClick(); } - - const myUserId = cli.getUserId(); - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - - // If we are changing our own PL it can only ever be decreasing, which we cannot reverse. - if (myUserId === target) { - try { - if (!(await _warnSelfDemote())) return; - _applyPowerChange(roomId, target, powerLevel, powerLevelEvent); - } catch (e) { - console.error("Failed to warn about self demotion: ", e); - } - return; - } - - const myPower = powerLevelEvent.getContent().users[myUserId]; - if (parseInt(myPower) === parseInt(powerLevel)) { - const {finished} = Modal.createTrackedDialog('Promote to PL100 Warning', '', QuestionDialog, { - title: _t("Warning!"), - description: -
- { _t("You will not be able to undo this change as you are promoting the user " + - "to have the same power level as yourself.") }
- { _t("Are you sure?") } -
, - button: _t("Continue"), - }); - - const [confirmed] = await finished; - if (confirmed) return; - } - _applyPowerChange(roomId, target, powerLevel, powerLevelEvent); - }, [user.roomId, user.userId, room && room.currentState, cli]); // eslint-disable-line + }; const onMemberAvatarClick = useCallback(() => { const member = user; @@ -935,17 +1062,12 @@ const UserInfo = withLegacyMatrixClient(({matrixClient: cli, user, groupId, room let synapseDeactivateButton; let spinner; - let directChatsSection; - if (user.userId !== cli.getUserId()) { - directChatsSection = ; - } - // We don't need a perfect check here, just something to pass as "probably not our homeserver". If // someone does figure out how to bypass this check the worst that happens is an error. // FIXME this should be using cli instead of MatrixClientPeg.matrixClient if (isSynapseAdmin && user.userId.endsWith(`:${MatrixClientPeg.getHomeserverName()}`)) { synapseDeactivateButton = ( - + {_t("Deactivate user")} ); @@ -955,6 +1077,7 @@ const UserInfo = withLegacyMatrixClient(({matrixClient: cli, user, groupId, room if (room && user.roomId) { adminToolsContainer = ( { statusMessage }; } - let memberDetails = null; - - if (room && user.roomId) { // is in room - const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); - const powerLevelUsersDefault = powerLevelEvent ? powerLevelEvent.getContent().users_default : 0; - - const PowerSelector = sdk.getComponent('elements.PowerSelector'); - memberDetails =
-
- -
- -
; - } - const avatarUrl = user.getMxcAvatarUrl ? user.getMxcAvatarUrl() : user.avatarUrl; let avatarElement; if (avatarUrl) { const httpUrl = cli.mxcUrlToHttp(avatarUrl, 800, 800); - avatarElement =
- {_t("Profile + avatarElement =
+
; } @@ -1058,6 +1168,12 @@ const UserInfo = withLegacyMatrixClient(({matrixClient: cli, user, groupId, room title={_t('Close')} />; } + const memberDetails = ; + + const isRoomEncrypted = useIsEncrypted(cli, room); // undefined means yet to be loaded, null means failed to load, otherwise list of devices const [devices, setDevices] = useState(undefined); // Download device lists @@ -1082,14 +1198,15 @@ const UserInfo = withLegacyMatrixClient(({matrixClient: cli, user, groupId, room setDevices(null); } } - - _downloadDeviceList(); + if (isRoomEncrypted) { + _downloadDeviceList(); + } // Handle being unmounted return () => { cancelled = true; }; - }, [cli, user.userId]); + }, [cli, user.userId, isRoomEncrypted]); // Listen to changes useEffect(() => { @@ -1106,21 +1223,20 @@ const UserInfo = withLegacyMatrixClient(({matrixClient: cli, user, groupId, room } }; - cli.on("deviceVerificationChanged", onDeviceVerificationChanged); + if (isRoomEncrypted) { + cli.on("deviceVerificationChanged", onDeviceVerificationChanged); + } // Handle being unmounted return () => { cancel = true; - cli.removeListener("deviceVerificationChanged", onDeviceVerificationChanged); + if (isRoomEncrypted) { + cli.removeListener("deviceVerificationChanged", onDeviceVerificationChanged); + } }; - }, [cli, user.userId]); - - let devicesSection; - const isRoomEncrypted = _enableDevices && room && cli.isRoomEncrypted(room.roomId); - if (isRoomEncrypted) { - devicesSection = ; - } else { - let text; + }, [cli, user.userId, isRoomEncrypted]); + let text; + if (!isRoomEncrypted) { if (!_enableDevices) { text = _t("This client does not support end-to-end encryption."); } else if (room) { @@ -1128,22 +1244,24 @@ const UserInfo = withLegacyMatrixClient(({matrixClient: cli, user, groupId, room } else { // TODO what to render for GroupMember } - - if (text) { - devicesSection = ( -
-

{ _t("Trust & Devices") }

-
- { text } -
-
- ); - } + } else { + text = _t("Messages in this room are end-to-end encrypted."); } + const devicesSection = isRoomEncrypted ? + () : null; + const securitySection = ( +
+

{ _t("Security") }

+

{ text }

+ verifyDevice(user.userId, null)}>{_t("Verify")} + { devicesSection } +
+ ); + let e2eIcon; if (isRoomEncrypted && devices) { - e2eIcon = ; + e2eIcon = ; } return ( @@ -1153,16 +1271,14 @@ const UserInfo = withLegacyMatrixClient(({matrixClient: cli, user, groupId, room
-
-

+
+

{ e2eIcon } { displayName }

-
- { user.userId } -
-
+
{ user.userId }
+
{presenceLabel} {statusLabel}
@@ -1176,11 +1292,9 @@ const UserInfo = withLegacyMatrixClient(({matrixClient: cli, user, groupId, room
} - { devicesSection } - - { directChatsSection } - + { securitySection } diff --git a/src/components/views/rooms/E2EIcon.js b/src/components/views/rooms/E2EIcon.js index 54260e4ee2..d6baa30c8e 100644 --- a/src/components/views/rooms/E2EIcon.js +++ b/src/components/views/rooms/E2EIcon.js @@ -36,7 +36,13 @@ export default function(props) { _t("All devices for this user are trusted") : _t("All devices in this encrypted room are trusted"); } - const icon = (
); + + let style = null; + if (props.size) { + style = {width: `${props.size}px`, height: `${props.size}px`}; + } + + const icon = (
); if (props.onClick) { return ({ icon }); } else { diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 22f1f914b6..5fcf1e4491 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -606,8 +606,8 @@ module.exports = createReactClass({ mx_EventTile_last: this.props.last, mx_EventTile_contextual: this.props.contextual, mx_EventTile_actionBarFocused: this.state.actionBarFocused, - mx_EventTile_verified: this.state.verified === true, - mx_EventTile_unverified: this.state.verified === false, + mx_EventTile_verified: !isBubbleMessage && this.state.verified === true, + mx_EventTile_unverified: !isBubbleMessage && this.state.verified === false, mx_EventTile_bad: isEncryptionFailure, mx_EventTile_emote: msgtype === 'm.emote', mx_EventTile_redacted: isRedacted, @@ -800,7 +800,7 @@ module.exports = createReactClass({ { timestamp } - { this._renderE2EPadlock() } + { !isBubbleMessage && this._renderE2EPadlock() } { thread } { sender } -
+
{ timestamp } - { this._renderE2EPadlock() } + { !isBubbleMessage && this._renderE2EPadlock() } { thread } %(role)s in %(roomName)s": "%(role)s in %(roomName)s", "Failed to deactivate user": "Failed to deactivate user", "This client does not support end-to-end encryption.": "This client does not support end-to-end encryption.", "Messages in this room are not end-to-end encrypted.": "Messages in this room are not end-to-end encrypted.", + "Messages in this room are end-to-end encrypted.": "Messages in this room are end-to-end encrypted.", + "Security": "Security", + "Verify": "Verify", "Sunday": "Sunday", "Monday": "Monday", "Tuesday": "Tuesday", @@ -1091,7 +1102,6 @@ "Reply": "Reply", "Edit": "Edit", "Message Actions": "Message Actions", - "Options": "Options", "Attachment": "Attachment", "Error decrypting attachment": "Error decrypting attachment", "Decrypt %(text)s": "Decrypt %(text)s",