diff --git a/res/css/views/rooms/_EntityTile.scss b/res/css/views/rooms/_EntityTile.scss index 2b6b31acb4..a2867de3a7 100644 --- a/res/css/views/rooms/_EntityTile.scss +++ b/res/css/views/rooms/_EntityTile.scss @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2020 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. @@ -19,6 +20,15 @@ limitations under the License. align-items: center; color: $primary-fg-color; cursor: pointer; + + .mx_E2EIcon { + margin: 0; + position: absolute; + bottom: 2px; + right: 7px; + height: 15px; + width: 15px; + } } .mx_EntityTile:hover { @@ -30,7 +40,7 @@ limitations under the License. content: ""; position: absolute; top: calc(50% - 8px); // center - right: 10px; + right: -8px; mask: url('$(res)/img/member_chevron.png'); mask-repeat: no-repeat; width: 16px; @@ -64,14 +74,6 @@ limitations under the License. position: relative; } -.mx_EntityTile_power { - position: absolute; - width: 16px; - height: 17px; - top: 0px; - right: 6px; -} - .mx_EntityTile_name, .mx_GroupRoomTile_name { flex: 1 1 0; @@ -83,6 +85,7 @@ limitations under the License. .mx_EntityTile_details { overflow: hidden; + flex: 1; } .mx_EntityTile_ellipsis .mx_EntityTile_name { @@ -112,10 +115,6 @@ limitations under the License. opacity: 0.25; } -.mx_EntityTile:not(.mx_EntityTile_noHover):hover .mx_EntityTile_name { - font-size: 13px; -} - .mx_EntityTile_subtext { font-size: 11px; opacity: 0.5; @@ -123,3 +122,17 @@ limitations under the License. white-space: nowrap; text-overflow: clip; } + +.mx_EntityTile_power { + padding-inline-start: 6px; + font-size: 10px; + color: $notice-secondary-color; + max-width: 6em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mx_EntityTile:hover .mx_EntityTile_power { + display: none; +} diff --git a/res/img/admin.svg b/res/img/admin.svg deleted file mode 100644 index 7ea7459304..0000000000 --- a/res/img/admin.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - icons_owner - Created with sketchtool. - - - - - - - - - - - - diff --git a/res/img/mod.svg b/res/img/mod.svg deleted file mode 100644 index 847baf98f9..0000000000 --- a/res/img/mod.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - icons_admin - Created with sketchtool. - - - - - - - - - - - diff --git a/src/Entities.js b/src/Entities.js deleted file mode 100644 index 872a837f3a..0000000000 --- a/src/Entities.js +++ /dev/null @@ -1,137 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd - -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 * as sdk from './index'; - -function isMatch(query, name, uid) { - query = query.toLowerCase(); - name = name.toLowerCase(); - uid = uid.toLowerCase(); - - // direct prefix matches - if (name.indexOf(query) === 0 || uid.indexOf(query) === 0) { - return true; - } - - // strip @ on uid and try matching again - if (uid.length > 1 && uid[0] === "@" && uid.substring(1).indexOf(query) === 0) { - return true; - } - - // split spaces in name and try matching constituent parts - const parts = name.split(" "); - for (let i = 0; i < parts.length; i++) { - if (parts[i].indexOf(query) === 0) { - return true; - } - } - return false; -} - -/* - * Converts various data models to Entity objects. - * - * Entity objects provide an interface for UI components to use to display - * members in a data-agnostic way. This means they don't need to care if the - * underlying data model is a RoomMember, User or 3PID data structure, it just - * cares about rendering. - */ - -class Entity { - constructor(model) { - this.model = model; - } - - getJsx() { - return null; - } - - matches(queryString) { - return false; - } -} - -class MemberEntity extends Entity { - getJsx() { - const MemberTile = sdk.getComponent("rooms.MemberTile"); - return ( - - ); - } - - matches(queryString) { - return isMatch(queryString, this.model.name, this.model.userId); - } -} - -class UserEntity extends Entity { - constructor(model, showInviteButton, inviteFn) { - super(model); - this.showInviteButton = Boolean(showInviteButton); - this.inviteFn = inviteFn; - this.onClick = this.onClick.bind(this); - } - - onClick() { - if (this.inviteFn) { - this.inviteFn(this.model.userId); - } - } - - getJsx() { - const UserTile = sdk.getComponent("rooms.UserTile"); - return ( - - ); - } - - matches(queryString) { - const name = this.model.displayName || this.model.userId; - return isMatch(queryString, name, this.model.userId); - } -} - -export function newEntity(jsx, matchFn) { - const entity = new Entity(); - entity.getJsx = function() { - return jsx; - }; - entity.matches = matchFn; - return entity; -} - -/** - * @param {RoomMember[]} members - * @return {Entity[]} - */ -export function fromRoomMembers(members) { - return members.map(function(m) { - return new MemberEntity(m); - }); -} - -/** - * @param {User[]} users - * @param {boolean} showInviteButton - * @param {Function} inviteFn Called with the user ID. - * @return {Entity[]} - */ -export function fromUsers(users, showInviteButton, inviteFn) { - return users.map(function(u) { - return new UserEntity(u, showInviteButton, inviteFn); - }); -} diff --git a/src/components/views/rooms/EntityTile.js b/src/components/views/rooms/EntityTile.js index 57db1ac240..fd92cb8e57 100644 --- a/src/components/views/rooms/EntityTile.js +++ b/src/components/views/rooms/EntityTile.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2018 New Vector Ltd +Copyright 2020 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. @@ -22,7 +23,7 @@ import * as sdk from '../../../index'; import AccessibleButton from '../elements/AccessibleButton'; import { _t } from '../../../languageHandler'; import classNames from "classnames"; - +import E2EIcon from './E2EIcon'; const PRESENCE_CLASS = { "offline": "mx_EntityTile_offline", @@ -30,7 +31,6 @@ const PRESENCE_CLASS = { "unavailable": "mx_EntityTile_unavailable", }; - function presenceClassForMember(presenceState, lastActiveAgo, showPresence) { if (showPresence === false) { return 'mx_EntityTile_online_beenactive'; @@ -69,6 +69,7 @@ const EntityTile = createReactClass({ suppressOnHover: PropTypes.bool, showPresence: PropTypes.bool, subtextLabel: PropTypes.string, + e2eStatus: PropTypes.string, }, getDefaultProps: function() { @@ -156,18 +157,20 @@ const EntityTile = createReactClass({ ); } - let power; + let powerLabel; const powerStatus = this.props.powerStatus; if (powerStatus) { - const src = { - [EntityTile.POWER_STATUS_MODERATOR]: require("../../../../res/img/mod.svg"), - [EntityTile.POWER_STATUS_ADMIN]: require("../../../../res/img/admin.svg"), - }[powerStatus]; - const alt = { - [EntityTile.POWER_STATUS_MODERATOR]: _t("Moderator"), + const powerText = { + [EntityTile.POWER_STATUS_MODERATOR]: _t("Mod"), [EntityTile.POWER_STATUS_ADMIN]: _t("Admin"), }[powerStatus]; - power = {alt}; + powerLabel =
{powerText}
; + } + + let e2eIcon; + const { e2eStatus } = this.props; + if (e2eStatus) { + e2eIcon = ; } const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); @@ -181,9 +184,10 @@ const EntityTile = createReactClass({ onClick={this.props.onClick}>
{ av } - { power } + { e2eIcon }
{ nameEl } + { powerLabel } { inviteButton } @@ -194,5 +198,4 @@ const EntityTile = createReactClass({ EntityTile.POWER_STATUS_MODERATOR = "moderator"; EntityTile.POWER_STATUS_ADMIN = "admin"; - export default EntityTile; diff --git a/src/components/views/rooms/MemberTile.js b/src/components/views/rooms/MemberTile.js index 95e5495339..649e1b4277 100644 --- a/src/components/views/rooms/MemberTile.js +++ b/src/components/views/rooms/MemberTile.js @@ -1,6 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020 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. @@ -22,6 +22,7 @@ import createReactClass from 'create-react-class'; import * as sdk from "../../../index"; import dis from "../../../dispatcher"; import { _t } from '../../../languageHandler'; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; export default createReactClass({ displayName: 'MemberTile', @@ -40,29 +41,101 @@ export default createReactClass({ getInitialState: function() { return { statusMessage: this.getStatusMessage(), + isRoomEncrypted: false, + e2eStatus: null, }; }, componentDidMount() { - if (!SettingsStore.isFeatureEnabled("feature_custom_status")) { - return; + const cli = MatrixClientPeg.get(); + + if (SettingsStore.isFeatureEnabled("feature_custom_status")) { + const { user } = this.props.member; + if (user) { + user.on("User._unstable_statusMessage", this._onStatusMessageCommitted); + } } - const { user } = this.props.member; - if (!user) { - return; + + if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + const { roomId } = this.props.member; + if (roomId) { + const isRoomEncrypted = cli.isRoomEncrypted(roomId); + this.setState({ + isRoomEncrypted, + }); + if (isRoomEncrypted) { + cli.on("userTrustStatusChanged", this.onUserTrustStatusChanged); + this.updateE2EStatus(); + } else { + // Listen for room to become encrypted + cli.on("RoomState.events", this.onRoomStateEvents); + } + } } - user.on("User._unstable_statusMessage", this._onStatusMessageCommitted); }, componentWillUnmount() { + const cli = MatrixClientPeg.get(); + const { user } = this.props.member; - if (!user) { + if (user) { + user.removeListener( + "User._unstable_statusMessage", + this._onStatusMessageCommitted, + ); + } + + if (cli) { + cli.removeListener("RoomState.events", this.onRoomStateEvents); + cli.removeListener("userTrustStatusChanged", this.onUserTrustStatusChanged); + } + }, + + onRoomStateEvents: function(ev) { + if (ev.getType() !== "m.room.encryption") return; + const { roomId } = this.props.member; + if (ev.getRoomId() !== roomId) return; + + // The room is encrypted now. + const cli = MatrixClientPeg.get(); + cli.removeListener("RoomState.events", this.onRoomStateEvents); + this.setState({ + isRoomEncrypted: true, + }); + this.updateE2EStatus(); + }, + + onUserTrustStatusChanged: function(userId, trustStatus) { + if (userId !== this.props.member.userId) return; + this.updateE2EStatus(); + }, + + updateE2EStatus: async function() { + const cli = MatrixClientPeg.get(); + const { userId } = this.props.member; + const isMe = userId === cli.getUserId(); + const userVerified = cli.checkUserTrust(userId).isCrossSigningVerified(); + if (!userVerified) { + this.setState({ + e2eStatus: "normal", + }); return; } - user.removeListener( - "User._unstable_statusMessage", - this._onStatusMessageCommitted, - ); + + const devices = await cli.getStoredDevicesForUser(userId); + const anyDeviceUnverified = devices.some(device => { + const { deviceId } = device; + // For your own devices, we use the stricter check of cross-signing + // verification to encourage everyone to trust their own devices via + // cross-signing so that other users can then safely trust you. + // For other people's devices, the more general verified check that + // includes locally verified devices can be used. + const deviceTrust = cli.checkDeviceTrust(userId, deviceId); + return isMe ? !deviceTrust.isCrossSigningVerified() : !deviceTrust.isVerified(); + }); + this.setState({ + e2eStatus: anyDeviceUnverified ? "warning" : "verified", + }); }, getStatusMessage() { @@ -94,6 +167,12 @@ export default createReactClass({ ) { return true; } + if ( + nextState.isRoomEncrypted !== this.state.isRoomEncrypted || + nextState.e2eStatus !== this.state.e2eStatus + ) { + return true; + } return false; }, @@ -153,14 +232,26 @@ export default createReactClass({ const powerStatus = powerStatusMap.get(powerLevel); + let e2eStatus; + if (this.state.isRoomEncrypted) { + e2eStatus = this.state.e2eStatus; + } + return ( - ); }, diff --git a/src/components/views/rooms/UserTile.js b/src/components/views/rooms/UserTile.js deleted file mode 100644 index 01bbe7d4d1..0000000000 --- a/src/components/views/rooms/UserTile.js +++ /dev/null @@ -1,53 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -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 createReactClass from 'create-react-class'; -import * as Avatar from '../../../Avatar'; -import * as sdk from "../../../index"; - -export default createReactClass({ - displayName: 'UserTile', - - propTypes: { - user: PropTypes.any.isRequired, // User - }, - - render: function() { - const EntityTile = sdk.getComponent("rooms.EntityTile"); - const user = this.props.user; - const name = user.displayName || user.userId; - let active = -1; - - // FIXME: make presence data update whenever User.presence changes... - active = user.lastActiveAgo ? - (Date.now() - (user.lastPresenceTs - user.lastActiveAgo)) : -1; - - const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); - const avatarJsx = ( - - ); - - return ( - - ); - }, -}); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 3c7b515833..aa73777b3c 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -909,6 +909,7 @@ "Some sessions in this encrypted room are not trusted": "Some sessions in this encrypted room are not trusted", "All sessions in this encrypted room are trusted": "All sessions in this encrypted room are trusted", "Edit message": "Edit message", + "Mod": "Mod", "This event could not be displayed": "This event could not be displayed", "%(senderName)s sent an image": "%(senderName)s sent an image", "%(senderName)s sent a video": "%(senderName)s sent a video", diff --git a/test/components/views/groups/GroupMemberList-test.js b/test/components/views/groups/GroupMemberList-test.js index 867190f6f4..39720177cc 100644 --- a/test/components/views/groups/GroupMemberList-test.js +++ b/test/components/views/groups/GroupMemberList-test.js @@ -112,7 +112,9 @@ describe("GroupMemberList", function() { const memberList = ReactTestUtils.findRenderedDOMComponentWithClass(root, "mx_MemberList_joined"); const memberListElement = ReactDOM.findDOMNode(memberList); expect(memberListElement).toBeTruthy(); - expect(memberListElement.textContent).toBe("Test"); + const userNameElement = memberListElement.querySelector(".mx_EntityTile_name"); + expect(userNameElement).toBeTruthy(); + expect(userNameElement.textContent).toBe("Test"); }); httpBackend.when("GET", "/groups/" + groupIdEncoded + "/summary").respond(200, summaryResponse);