Merge pull request #4006 from matrix-org/jryans/user-list-deco

Add shields to member list, move power label to text
pull/21833/head
J. Ryan Stinnett 2020-01-31 10:20:13 +00:00 committed by GitHub
commit 68b2454920
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 151 additions and 264 deletions

View File

@ -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;
}

View File

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="16px" height="17px" viewBox="-1 -1 15 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
<!-- Generator: sketchtool 3.4.4 (395) - http://www.bohemiancoding.com/sketch -->
<title>icons_owner</title>
<desc>Created with sketchtool.</desc>
<defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
<g id="02_19-Room-contextual-menu-hover" sketch:type="MSArtboardGroup" transform="translate(-1000.000000, -128.000000)">
<g id="people_open" sketch:type="MSLayerGroup" transform="translate(966.000000, 59.000000)">
<g id="icons_owner" transform="translate(35.000000, 70.000000)" sketch:type="MSShapeGroup">
<path d="M0.441894529,1.80537109 C2.59277353,3.03442388 4.25305977,2.17675781 5.9832796,0.805371094 C8.01666135,2.17675787 9.50756797,3.12670903 11.6293941,1.80537109 C11.6293941,7.01538067 11.9379879,12.2253912 5.9832796,12.2253906 C0.0285712975,12.2253901 0.441894531,7.01538067 0.441894529,1.80537109 Z" id="Path-2-Copy" stroke="#FFFFFF" fill="#F6A623"></path>
<polygon id="Star-1" fill="#FFFFFF" points="6 8.8 3.88397309 9.91246118 4.28809827 7.55623059 2.57619654 5.88753882 4.94198655 5.54376941 6 3.4 7.05801345 5.54376941 9.42380346 5.88753882 7.71190173 7.55623059 8.11602691 9.91246118 "></polygon>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="16px" height="17px" viewBox="-1 -1 15 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
<!-- Generator: sketchtool 3.4.4 (395) - http://www.bohemiancoding.com/sketch -->
<title>icons_admin</title>
<desc>Created with sketchtool.</desc>
<defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
<g id="02_19-Room-contextual-menu-hover" sketch:type="MSArtboardGroup" transform="translate(-1000.000000, -172.000000)" stroke="#FFFFFF" fill="#C2C5AF">
<g id="people_open" sketch:type="MSLayerGroup" transform="translate(966.000000, 59.000000)">
<g id="icons_admin" transform="translate(35.000000, 114.000000)" sketch:type="MSShapeGroup">
<path d="M0.441894529,1.80537109 C2.59277353,3.03442388 4.25305977,2.17675781 5.9832796,0.805371094 C8.01666135,2.17675787 9.50756797,3.12670903 11.6293941,1.80537109 C11.6293941,7.01538067 11.9379879,12.2253912 5.9832796,12.2253906 C0.0285712975,12.2253901 0.441894531,7.01538067 0.441894529,1.80537109 Z" id="Path-2-Copy-2"></path>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -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 (
<MemberTile key={this.model.userId} member={this.model} />
);
}
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 (
<UserTile key={this.model.userId} user={this.model}
showInviteButton={this.showInviteButton} onClick={this.onClick} />
);
}
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);
});
}

View File

@ -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 = <img src={src} className="mx_EntityTile_power" width="16" height="17" alt={alt} />;
powerLabel = <div className="mx_EntityTile_power">{powerText}</div>;
}
let e2eIcon;
const { e2eStatus } = this.props;
if (e2eStatus) {
e2eIcon = <E2EIcon status={e2eStatus} isUser={true} />;
}
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
@ -181,9 +184,10 @@ const EntityTile = createReactClass({
onClick={this.props.onClick}>
<div className="mx_EntityTile_avatar">
{ av }
{ power }
{ e2eIcon }
</div>
{ nameEl }
{ powerLabel }
{ inviteButton }
</AccessibleButton>
</div>
@ -194,5 +198,4 @@ const EntityTile = createReactClass({
EntityTile.POWER_STATUS_MODERATOR = "moderator";
EntityTile.POWER_STATUS_ADMIN = "admin";
export default EntityTile;

View File

@ -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 (
<EntityTile {...this.props} presenceState={presenceState}
<EntityTile
{...this.props}
presenceState={presenceState}
presenceLastActiveAgo={member.user ? member.user.lastActiveAgo : 0}
presenceLastTs={member.user ? member.user.lastPresenceTs : 0}
presenceCurrentlyActive={member.user ? member.user.currentlyActive : false}
avatarJsx={av} title={this.getPowerLabel()} onClick={this.onClick}
name={name} powerStatus={powerStatus} showPresence={this.props.showPresence}
avatarJsx={av}
title={this.getPowerLabel()}
name={name}
powerStatus={powerStatus}
showPresence={this.props.showPresence}
subtextLabel={statusMessage}
e2eStatus={e2eStatus}
onClick={this.onClick}
/>
);
},

View File

@ -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 = (
<BaseAvatar width={36} height={36} name={name} idName={user.userId}
url={Avatar.avatarUrlForUser(user, 36, 36, "crop")} />
);
return (
<EntityTile {...this.props} presenceState={user.presence} presenceActiveAgo={active}
presenceCurrentlyActive={user.currentlyActive}
name={name} title={user.userId} avatarJsx={avatarJsx} />
);
},
});

View File

@ -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",

View File

@ -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);