Merge pull request #3620 from matrix-org/bwindels/userinfomakeover

New design for member panel
This commit is contained in:
Bruno Windels 2019-11-15 16:37:42 +00:00 committed by GitHub
commit 41f832a549
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 808 additions and 455 deletions

View file

@ -90,6 +90,7 @@
@import "./views/elements/_ErrorBoundary.scss"; @import "./views/elements/_ErrorBoundary.scss";
@import "./views/elements/_EventListSummary.scss"; @import "./views/elements/_EventListSummary.scss";
@import "./views/elements/_Field.scss"; @import "./views/elements/_Field.scss";
@import "./views/elements/_IconButton.scss";
@import "./views/elements/_ImageView.scss"; @import "./views/elements/_ImageView.scss";
@import "./views/elements/_InlineSpinner.scss"; @import "./views/elements/_InlineSpinner.scss";
@import "./views/elements/_InteractiveTooltip.scss"; @import "./views/elements/_InteractiveTooltip.scss";

View file

@ -49,6 +49,7 @@ limitations under the License.
color: $primary-fg-color; color: $primary-fg-color;
background-color: $primary-bg-color; background-color: $primary-bg-color;
flex: 1; flex: 1;
min-width: 0;
} }
.mx_Field select { .mx_Field select {

View file

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

View file

@ -25,7 +25,7 @@ limitations under the License.
width: 12px; width: 12px;
height: 16px; height: 16px;
content: ""; content: "";
mask: url("$(res)/img/e2e/verified.svg"); mask: url("$(res)/img/e2e/normal.svg");
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-size: 100%; mask-size: 100%;
margin-top: 4px; margin-top: 4px;
@ -33,6 +33,7 @@ limitations under the License.
} }
&.mx_KeyVerification_icon_verified::after { &.mx_KeyVerification_icon_verified::after {
mask: url("$(res)/img/e2e/verified.svg");
background-color: $accent-color; background-color: $accent-color;
} }

View file

@ -20,156 +20,222 @@ limitations under the License.
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
overflow-y: auto; 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; font-size: 12px;
margin: 4px 0;
}
.mx_UserInfo_profileField { .mx_UserInfo_cancel {
font-size: 15px; height: 16px;
position: relative; width: 16px;
text-align: center; padding: 10px 0 10px 10px;
} cursor: pointer;
mask-image: url('$(res)/img/minimise.svg');
.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');
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-position: center; mask-position: 16px center;
content: ''; background-color: $rightpanel-button-color;
position: absolute; position: absolute;
top: 0; }
bottom: 0;
left: 0; h2 {
right: 0; 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;
} }
} }

View file

@ -17,17 +17,56 @@ limitations under the License.
.mx_E2EIcon { .mx_E2EIcon {
width: 25px; width: 25px;
height: 25px; height: 25px;
mask-repeat: no-repeat;
mask-position: center 0;
margin: 0 9px; margin: 0 9px;
position: relative;
display: block;
} }
.mx_E2EIcon_verified { .mx_E2EIcon_verified::before, .mx_E2EIcon_warning::before {
mask-image: url('$(res)/img/e2e/lock-verified.svg'); 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; 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; background-color: $warning-color;
} }

View file

@ -78,7 +78,10 @@ limitations under the License.
.mx_MessageComposer_e2eIcon.mx_E2EIcon { .mx_MessageComposer_e2eIcon.mx_E2EIcon {
position: absolute; position: absolute;
left: 60px; left: 60px;
background-color: $composer-e2e-icon-color;
&::after {
background-color: $composer-e2e-icon-color;
}
} }
.mx_MessageComposer_noperm_error { .mx_MessageComposer_noperm_error {

3
res/img/e2e/normal.svg Normal file
View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 21C12 21 21 17.2 21 11.5V4.85L12 2L3 4.85V11.5C3 17.2 12 21 12 21Z" fill="#2E2F32" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 303 B

View file

@ -1,3 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="12" viewBox="0 0 11 12"> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<path fill="#7AC9A1" fill-rule="evenodd" stroke="#7AC9A1" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M5.5 11S10 9 10 6V2.5L5.5 1 1 2.5V6c0 3 4.5 5 4.5 5z"/> <svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none">
<path
style="stroke:none;fill:#03b381;fill-opacity:1"
d="M 12 2 L 3 4.8496094 L 3 11.5 C 3 17.2 12 21 12 21 C 12 21 21 17.2 21 11.5 L 21 4.8496094 L 12 2 z M 16.541016 7.5332031 C 16.789066 7.5332031 17.037312 7.6240256 17.226562 7.8066406 C 17.605062 8.1718706 17.605063 8.7636762 17.226562 9.1289062 L 11.400391 14.75 C 11.021891 15.1152 10.40975 15.1152 10.03125 14.75 L 10.013672 14.734375 C 10.007572 14.728775 10.002044 14.722597 9.9960938 14.716797 L 7.3242188 12.138672 C 6.9267788 11.755172 6.9267788 11.1335 7.3242188 10.75 C 7.7216487 10.3665 8.3662319 10.3665 8.7636719 10.75 L 10.783203 12.699219 L 15.855469 7.8066406 C 16.044719 7.6240256 16.292966 7.5332031 16.541016 7.5332031 z "
id="path2" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 276 B

After

Width:  |  Height:  |  Size: 902 B

View file

@ -1,6 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="12" height="12" viewBox="0 0 12 12"> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<defs> <svg
<path id="a" d="M5 10A5 5 0 1 0 5 0a5 5 0 0 0 0 10zM5 .5A1.5 1.5 0 0 1 6.5 2v3a1.5 1.5 0 0 1-3 0V2A1.5 1.5 0 0 1 5 .5zm0 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z"/> xmlns="http://www.w3.org/2000/svg"
</defs> width="24"
<use fill="#F56679" fill-rule="evenodd" stroke="#F56679" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" transform="translate(1 1)" xlink:href="#a"/> height="24"
fill="none"
viewBox="0 0 24 24">
<path
style="fill-opacity:1;fill:#ff4b55;stroke:none"
d="M 12 2 L 3 4.8496094 L 3 11.5 C 3 17.2 12 21 12 21 C 12 21 21 17.2 21 11.5 L 21 4.8496094 L 12 2 z M 12.050781 5.5 C 12.743281 5.5 13.300781 6.0575 13.300781 6.75 L 13.300781 12.25 C 13.300781 12.9425 12.743281 13.5 12.050781 13.5 C 11.358281 13.5 10.800781 12.9425 10.800781 12.25 L 10.800781 6.75 C 10.800781 6.0575 11.358281 5.5 12.050781 5.5 z M 12.050781 15 C 12.743281 15 13.300781 15.5575 13.300781 16.25 C 13.300781 16.9425 12.743281 17.5 12.050781 17.5 C 11.358281 17.5 10.800781 16.9425 10.800781 16.25 C 10.800781 15.5575 11.358281 15 12.050781 15 z "
id="path2" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 498 B

After

Width:  |  Height:  |  Size: 824 B

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14 2L18 6L7 17H3V13L14 2V2Z" stroke="#2E2F32" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 22H21" stroke="#2E2F32" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 333 B

View file

@ -28,8 +28,8 @@ export function levelRoleMap(usersDefault) {
export function textualPowerLevel(level, usersDefault) { export function textualPowerLevel(level, usersDefault) {
const LEVEL_ROLE_MAP = levelRoleMap(usersDefault); const LEVEL_ROLE_MAP = levelRoleMap(usersDefault);
if (LEVEL_ROLE_MAP[level]) { if (LEVEL_ROLE_MAP[level]) {
return LEVEL_ROLE_MAP[level] + (level !== undefined ? ` (${level})` : ` (${usersDefault})`); return LEVEL_ROLE_MAP[level];
} else { } else {
return level; return _t("Custom (%(level)s)", {level});
} }
} }

View file

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

View file

@ -129,10 +129,11 @@ module.exports = createReactClass({
render: function() { render: function() {
let picker; let picker;
const label = typeof this.props.label === "undefined" ? _t("Power level") : this.props.label;
if (this.state.custom) { if (this.state.custom) {
picker = ( picker = (
<Field id={`powerSelector_custom_${this.props.powerLevelKey}`} type="number" <Field id={`powerSelector_custom_${this.props.powerLevelKey}`} type="number"
label={this.props.label || _t("Power level")} max={this.props.maxValue} label={label} max={this.props.maxValue}
onBlur={this.onCustomBlur} onKeyDown={this.onCustomKeyDown} onChange={this.onCustomChange} onBlur={this.onCustomBlur} onKeyDown={this.onCustomKeyDown} onChange={this.onCustomChange}
value={String(this.state.customValue)} disabled={this.props.disabled} /> value={String(this.state.customValue)} disabled={this.props.disabled} />
); );
@ -151,7 +152,7 @@ module.exports = createReactClass({
picker = ( picker = (
<Field id={`powerSelector_notCustom_${this.props.powerLevelKey}`} element="select" <Field id={`powerSelector_notCustom_${this.props.powerLevelKey}`} element="select"
label={this.props.label || _t("Power level")} onChange={this.onSelectChange} label={label} onChange={this.onSelectChange}
value={String(this.state.selectValue)} disabled={this.props.disabled}> value={String(this.state.selectValue)} disabled={this.props.disabled}>
{options} {options}
</Field> </Field>

View file

@ -27,7 +27,6 @@ import sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import createRoom from '../../../createRoom'; import createRoom from '../../../createRoom';
import DMRoomMap from '../../../utils/DMRoomMap'; import DMRoomMap from '../../../utils/DMRoomMap';
import Unread from '../../../Unread';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
@ -40,6 +39,7 @@ import MatrixClientPeg from "../../../MatrixClientPeg";
import E2EIcon from "../rooms/E2EIcon"; import E2EIcon from "../rooms/E2EIcon";
import withLegacyMatrixClient from "../../../utils/withLegacyMatrixClient"; import withLegacyMatrixClient from "../../../utils/withLegacyMatrixClient";
import {useEventEmitter} from "../../../hooks/useEventEmitter"; import {useEventEmitter} from "../../../hooks/useEventEmitter";
import {textualPowerLevel} from '../../../Roles';
const _disambiguateDevices = (devices) => { const _disambiguateDevices = (devices) => {
const names = Object.create(null); const names = Object.create(null);
@ -63,10 +63,92 @@ const _getE2EStatus = (devices) => {
return hasUnverifiedDevice ? "warning" : "verified"; return hasUnverifiedDevice ? "warning" : "verified";
}; };
const DevicesSection = ({devices, userId, loading}) => { async function unverifyUser(matrixClient, userId) {
const MemberDeviceInfo = sdk.getComponent('rooms.MemberDeviceInfo'); 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 (<AccessibleButton className={classes} onClick={onDeviceClick}>
<div className={iconClasses} />
<div className="mx_UserInfo_device_name">{deviceName}</div>
<div className="mx_UserInfo_device_trusted">{trustedLabel}</div>
</AccessibleButton>);
}
function DevicesSection({devices, userId, loading}) {
const Spinner = sdk.getComponent("elements.Spinner"); const Spinner = sdk.getComponent("elements.Spinner");
const [isExpanded, setExpanded] = useState(false);
if (loading) { if (loading) {
// still loading // still loading
return <Spinner />; return <Spinner />;
@ -74,123 +156,50 @@ const DevicesSection = ({devices, userId, loading}) => {
if (devices === null) { if (devices === null) {
return _t("Unable to load device list"); return _t("Unable to load device list");
} }
if (devices.length === 0) {
return _t("No devices with registered encryption keys");
}
return ( const unverifiedDevices = devices.filter(d => !d.isVerified());
<div className="mx_UserInfo_container"> const verifiedDevices = devices.filter(d => d.isVerified());
<h3>{ _t("Trust & Devices") }</h3>
<div className="mx_UserInfo_devices">
{ devices.map((device, i) => <MemberDeviceInfo key={i} userId={userId} device={device} />) }
</div>
</div>
);
};
const onRoomTileClick = (roomId) => { let expandButton;
dis.dispatch({ if (verifiedDevices.length) {
action: 'view_room', if (isExpanded) {
room_id: roomId, expandButton = (<AccessibleButton className="mx_UserInfo_expand" onClick={() => setExpanded(false)}>
}); <div>{_t("Hide verified Sign-In's")}</div>
}; </AccessibleButton>);
} else {
const DirectChatsSection = withLegacyMatrixClient(({matrixClient: cli, userId, startUpdating, stopUpdating}) => { expandButton = (<AccessibleButton className="mx_UserInfo_expand" onClick={() => setExpanded(true)}>
const onNewDMClick = async () => { <div className="mx_E2EIcon mx_E2EIcon_verified" />
startUpdating(); <div>{_t("%(count)s verified Sign-In's", {count: verifiedDevices.length})}</div>
await createRoom({dmUserId: userId}); </AccessibleButton>);
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(
<RoomTile key={room.roomId}
room={room}
transparent={true}
collapsed={false}
selected={false}
unread={Unread.doesRoomHaveUnreadMessages(room)}
highlight={highlight}
isInvite={false}
onClick={onRoomTileClick}
/>,
);
} }
} }
const labelClasses = classNames({ let deviceList = unverifiedDevices.map((device, i) => {
mx_UserInfo_createRoom_label: true, return (<DeviceItem key={i} userId={userId} device={device} />);
mx_RoomTile_name: true,
}); });
if (isExpanded) {
let body = tiles; const keyStart = unverifiedDevices.length;
if (!body) { deviceList = deviceList.concat(verifiedDevices.map((device, i) => {
body = ( return (<DeviceItem key={i + keyStart} userId={userId} device={device} />);
<AccessibleButton className="mx_UserInfo_createRoom" onClick={onNewDMClick}> }));
<div className="mx_RoomTile_avatar">
<img src={require("../../../../res/img/create-big.svg")} width="26" height="26" alt={_t("Start a chat")} />
</div>
<div className={labelClasses}><i>{ _t("Start a chat") }</i></div>
</AccessibleButton>
);
} }
return ( return (
<div className="mx_UserInfo_container"> <div className="mx_UserInfo_devices">
<div className="mx_UserInfo_container_header"> <div>{deviceList}</div>
<h3>{ _t("Direct messages") }</h3> <div>{expandButton}</div>
<AccessibleButton
className="mx_UserInfo_container_header_right mx_UserInfo_newDmButton"
onClick={onNewDMClick}
title={_t("Start a chat")}
/>
</div>
{ body }
</div> </div>
); );
}); }
const UserOptionsSection = withLegacyMatrixClient(({matrixClient: cli, member, isIgnored, canInvite}) => { const UserOptionsSection = withLegacyMatrixClient(({matrixClient: cli, member, isIgnored, canInvite, devices}) => {
let ignoreButton = null; let ignoreButton = null;
let insertPillButton = null; let insertPillButton = null;
let inviteUserButton = null; let inviteUserButton = null;
let readReceiptButton = null; let readReceiptButton = null;
const isMe = member.userId === cli.getUserId();
const onShareUserClick = () => { const onShareUserClick = () => {
const ShareDialog = sdk.getComponent("dialogs.ShareDialog"); const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
Modal.createTrackedDialog('share room member dialog', '', 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 // Only allow the user to ignore the user if its not ourselves
// same goes for jumping to read receipt // same goes for jumping to read receipt
if (member.userId !== cli.getUserId()) { if (!isMe) {
const onIgnoreToggle = () => { const onIgnoreToggle = () => {
const ignoredUsers = cli.getIgnoredUsers(); const ignoredUsers = cli.getIgnoredUsers();
if (isIgnored) { if (isIgnored) {
@ -214,7 +223,7 @@ const UserOptionsSection = withLegacyMatrixClient(({matrixClient: cli, member, i
}; };
ignoreButton = ( ignoreButton = (
<AccessibleButton onClick={onIgnoreToggle} className="mx_UserInfo_field"> <AccessibleButton onClick={onIgnoreToggle} className={classNames("mx_UserInfo_field", {mx_UserInfo_destructive: !isIgnored})}>
{ isIgnored ? _t("Unignore") : _t("Ignore") } { isIgnored ? _t("Unignore") : _t("Ignore") }
</AccessibleButton> </AccessibleButton>
); );
@ -285,15 +294,34 @@ const UserOptionsSection = withLegacyMatrixClient(({matrixClient: cli, member, i
</AccessibleButton> </AccessibleButton>
); );
let directMessageButton;
if (!isMe) {
directMessageButton = (
<AccessibleButton onClick={() => openDMForUser(cli, member.userId)} className="mx_UserInfo_field">
{ _t('Direct message') }
</AccessibleButton>
);
}
let unverifyButton;
if (devices && devices.some(device => device.isVerified())) {
unverifyButton = (
<AccessibleButton onClick={() => unverifyUser(cli, member.userId)} className="mx_UserInfo_field mx_UserInfo_destructive">
{ _t('Unverify user') }
</AccessibleButton>
);
}
return ( return (
<div className="mx_UserInfo_container"> <div className="mx_UserInfo_container">
<h3>{ _t("User Options") }</h3> <h3>{ _t("Options") }</h3>
<div className="mx_UserInfo_buttons"> <div>
{ directMessageButton }
{ readReceiptButton } { readReceiptButton }
{ shareUserButton } { shareUserButton }
{ insertPillButton } { insertPillButton }
{ ignoreButton }
{ inviteUserButton } { inviteUserButton }
{ ignoreButton }
{ unverifyButton }
</div> </div>
</div> </div>
); );
@ -337,10 +365,13 @@ const _isMuted = (member, powerLevelContent) => {
return member.powerLevel < levelToSend; return member.powerLevel < levelToSend;
}; };
const useRoomPowerLevels = (room) => { const useRoomPowerLevels = (cli, room) => {
const [powerLevels, setPowerLevels] = useState({}); const [powerLevels, setPowerLevels] = useState({});
const update = useCallback(() => { const update = useCallback(() => {
if (!room) {
return;
}
const event = room.currentState.getStateEvents("m.room.power_levels", ""); const event = room.currentState.getStateEvents("m.room.power_levels", "");
if (event) { if (event) {
setPowerLevels(event.getContent()); setPowerLevels(event.getContent());
@ -352,7 +383,7 @@ const useRoomPowerLevels = (room) => {
}; };
}, [room]); }, [room]);
useEventEmitter(room, "RoomState.events", update); useEventEmitter(cli, "RoomState.members", update);
useEffect(() => { useEffect(() => {
update(); update();
return () => { return () => {
@ -399,7 +430,7 @@ const RoomKickButton = withLegacyMatrixClient(({matrixClient: cli, member, start
}; };
const kickLabel = member.membership === "invite" ? _t("Disinvite") : _t("Kick"); const kickLabel = member.membership === "invite" ? _t("Disinvite") : _t("Kick");
return <AccessibleButton className="mx_UserInfo_field" onClick={onKick}> return <AccessibleButton className="mx_UserInfo_field mx_UserInfo_destructive" onClick={onKick}>
{ kickLabel } { kickLabel }
</AccessibleButton>; </AccessibleButton>;
}); });
@ -472,7 +503,7 @@ const RedactMessagesButton = withLegacyMatrixClient(({matrixClient: cli, member}
} }
}; };
return <AccessibleButton className="mx_UserInfo_field" onClick={onRedactAllMessages}> return <AccessibleButton className="mx_UserInfo_field mx_UserInfo_destructive" onClick={onRedactAllMessages}>
{ _t("Remove recent messages") } { _t("Remove recent messages") }
</AccessibleButton>; </AccessibleButton>;
}); });
@ -524,7 +555,11 @@ const BanToggleButton = withLegacyMatrixClient(({matrixClient: cli, member, star
label = _t("Unban"); label = _t("Unban");
} }
return <AccessibleButton className="mx_UserInfo_field" onClick={onBanOrUnban}> const classes = classNames("mx_UserInfo_field", {
mx_UserInfo_destructive: member.membership !== 'ban',
});
return <AccessibleButton className={classes} onClick={onBanOrUnban}>
{ label } { label }
</AccessibleButton>; </AccessibleButton>;
}); });
@ -581,21 +616,24 @@ const MuteToggleButton = withLegacyMatrixClient(
} }
}; };
const classes = classNames("mx_UserInfo_field", {
mx_UserInfo_destructive: !isMuted,
});
const muteLabel = isMuted ? _t("Unmute") : _t("Mute"); const muteLabel = isMuted ? _t("Unmute") : _t("Mute");
return <AccessibleButton className="mx_UserInfo_field" onClick={onMuteToggle}> return <AccessibleButton className={classes} onClick={onMuteToggle}>
{ muteLabel } { muteLabel }
</AccessibleButton>; </AccessibleButton>;
}, },
); );
const RoomAdminToolsContainer = withLegacyMatrixClient( const RoomAdminToolsContainer = withLegacyMatrixClient(
({matrixClient: cli, room, children, member, startUpdating, stopUpdating}) => { ({matrixClient: cli, room, children, member, startUpdating, stopUpdating, powerLevels}) => {
let kickButton; let kickButton;
let banButton; let banButton;
let muteButton; let muteButton;
let redactButton; let redactButton;
const powerLevels = useRoomPowerLevels(room);
const editPowerLevel = ( const editPowerLevel = (
(powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) || (powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) ||
powerLevels.state_default powerLevels.state_default
@ -705,7 +743,7 @@ const GroupAdminToolsSection = withLegacyMatrixClient(
}; };
const kickButton = ( const kickButton = (
<AccessibleButton className="mx_UserInfo_field" onClick={_onKick}> <AccessibleButton className="mx_UserInfo_field mx_UserInfo_destructive" onClick={_onKick}>
{ isInvited ? _t('Disinvite') : _t('Remove from community') } { isInvited ? _t('Disinvite') : _t('Remove from community') }
</AccessibleButton> </AccessibleButton>
); );
@ -744,47 +782,17 @@ const useIsSynapseAdmin = (cli) => {
return isAdmin; return isAdmin;
}; };
// cli is injected by withLegacyMatrixClient function useRoomPermissions(cli, room, user) {
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]);
const [roomPermissions, setRoomPermissions] = useState({ const [roomPermissions, setRoomPermissions] = useState({
// modifyLevelMax is the max PL we can set this user to, typically min(their PL, our PL) && canSetPL // modifyLevelMax is the max PL we can set this user to, typically min(their PL, our PL) && canSetPL
modifyLevelMax: -1, modifyLevelMax: -1,
canEdit: false,
canInvite: false, canInvite: false,
}); });
const updateRoomPermissions = useCallback(async () => { const updateRoomPermissions = useCallback(() => {
if (!room) return; if (!room) {
return;
}
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
if (!powerLevelEvent) return; if (!powerLevelEvent) return;
@ -811,20 +819,197 @@ const UserInfo = withLegacyMatrixClient(({matrixClient: cli, user, groupId, room
setRoomPermissions({ setRoomPermissions({
canInvite: me.powerLevel >= powerLevels.invite, canInvite: me.powerLevel >= powerLevels.invite,
canEdit: modifyLevelMax >= 0,
modifyLevelMax, modifyLevelMax,
}); });
}, [cli, user, room]); }, [cli, user, room]);
useEventEmitter(cli, "RoomState.events", updateRoomPermissions); useEventEmitter(cli, "RoomState.members", updateRoomPermissions);
useEffect(() => { useEffect(() => {
updateRoomPermissions(); updateRoomPermissions();
return () => { return () => {
setRoomPermissions({ setRoomPermissions({
maximalPowerLevel: -1, maximalPowerLevel: -1,
canEdit: false,
canInvite: false, canInvite: false,
}); });
}; };
}, [updateRoomPermissions]); }, [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 (<PowerLevelEditor
user={user} room={room} roomPermissions={roomPermissions}
onFinished={() => 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 ?
(<IconButton icon="edit" onClick={() => setEditing(true)} />) : null;
const role = textualPowerLevel(powerLevel, powerLevelUsersDefault);
const label = _t("<strong>%(role)s</strong> in %(roomName)s",
{role, roomName: room.name},
{strong: label => <strong>{label}</strong>},
);
return (
<div className="mx_UserInfo_profileField">
<div className="mx_UserInfo_roleDescription">{label}{modifyButton}</div>
</div>
);
}
} 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:
<div>
{ _t("You will not be able to undo this change as you are promoting the user " +
"to have the same power level as yourself.") }<br />
{ _t("Are you sure?") }
</div>,
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 ? <Spinner w={16} h={16} /> :
<IconButton icon="check" onClick={changePowerLevel} />;
const PowerSelector = sdk.getComponent('elements.PowerSelector');
return (
<div className="mx_UserInfo_profileField">
<PowerSelector
label={null}
value={selectedPowerLevel}
maxValue={roomPermissions.modifyLevelMax}
usersDefault={powerLevelUsersDefault}
onChange={onPowerChange}
disabled={isUpdating}
/>
{buttonOrSpinner}
</div>
);
});
// 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 onSynapseDeactivate = useCallback(async () => {
const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog'); const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog');
const {finished} = Modal.createTrackedDialog('Synapse User Deactivation', '', QuestionDialog, { const {finished} = Modal.createTrackedDialog('Synapse User Deactivation', '', QuestionDialog, {
@ -852,70 +1037,12 @@ const UserInfo = withLegacyMatrixClient(({matrixClient: cli, user, groupId, room
} }
}, [cli, user.userId]); }, [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 onMemberAvatarKey = e => {
const target = user.userId; if (e.key === "Enter") {
onMemberAvatarClick();
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;
_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:
<div>
{ _t("You will not be able to undo this change as you are promoting the user " +
"to have the same power level as yourself.") }<br />
{ _t("Are you sure?") }
</div>,
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 onMemberAvatarClick = useCallback(() => {
const member = user; const member = user;
@ -935,17 +1062,12 @@ const UserInfo = withLegacyMatrixClient(({matrixClient: cli, user, groupId, room
let synapseDeactivateButton; let synapseDeactivateButton;
let spinner; let spinner;
let directChatsSection;
if (user.userId !== cli.getUserId()) {
directChatsSection = <DirectChatsSection userId={user.userId} />;
}
// We don't need a perfect check here, just something to pass as "probably not our homeserver". If // 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. // 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 // FIXME this should be using cli instead of MatrixClientPeg.matrixClient
if (isSynapseAdmin && user.userId.endsWith(`:${MatrixClientPeg.getHomeserverName()}`)) { if (isSynapseAdmin && user.userId.endsWith(`:${MatrixClientPeg.getHomeserverName()}`)) {
synapseDeactivateButton = ( synapseDeactivateButton = (
<AccessibleButton onClick={onSynapseDeactivate} className="mx_UserInfo_field"> <AccessibleButton onClick={onSynapseDeactivate} className="mx_UserInfo_field mx_UserInfo_destructive">
{_t("Deactivate user")} {_t("Deactivate user")}
</AccessibleButton> </AccessibleButton>
); );
@ -955,6 +1077,7 @@ const UserInfo = withLegacyMatrixClient(({matrixClient: cli, user, groupId, room
if (room && user.roomId) { if (room && user.roomId) {
adminToolsContainer = ( adminToolsContainer = (
<RoomAdminToolsContainer <RoomAdminToolsContainer
powerLevels={powerLevels}
member={user} member={user}
room={room} room={room}
startUpdating={startUpdating} startUpdating={startUpdating}
@ -992,7 +1115,7 @@ const UserInfo = withLegacyMatrixClient(({matrixClient: cli, user, groupId, room
let presenceCurrentlyActive; let presenceCurrentlyActive;
let statusMessage; let statusMessage;
if (user instanceof RoomMember) { if (user instanceof RoomMember && user.user) {
presenceState = user.user.presence; presenceState = user.user.presence;
presenceLastActiveAgo = user.user.lastActiveAgo; presenceLastActiveAgo = user.user.lastActiveAgo;
presenceCurrentlyActive = user.user.currentlyActive; presenceCurrentlyActive = user.user.currentlyActive;
@ -1021,32 +1144,19 @@ const UserInfo = withLegacyMatrixClient(({matrixClient: cli, user, groupId, room
statusLabel = <span className="mx_UserInfo_statusMessage">{ statusMessage }</span>; statusLabel = <span className="mx_UserInfo_statusMessage">{ statusMessage }</span>;
} }
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 = <div>
<div className="mx_UserInfo_profileField">
<PowerSelector
value={parseInt(user.powerLevel)}
maxValue={roomPermissions.modifyLevelMax}
disabled={roomPermissions.modifyLevelMax < 0}
usersDefault={powerLevelUsersDefault}
onChange={onPowerChange} />
</div>
</div>;
}
const avatarUrl = user.getMxcAvatarUrl ? user.getMxcAvatarUrl() : user.avatarUrl; const avatarUrl = user.getMxcAvatarUrl ? user.getMxcAvatarUrl() : user.avatarUrl;
let avatarElement; let avatarElement;
if (avatarUrl) { if (avatarUrl) {
const httpUrl = cli.mxcUrlToHttp(avatarUrl, 800, 800); const httpUrl = cli.mxcUrlToHttp(avatarUrl, 800, 800);
avatarElement = <div className="mx_UserInfo_avatar" onClick={onMemberAvatarClick}> avatarElement = <div
<img src={httpUrl} alt={_t("Profile picture")} /> className="mx_UserInfo_avatar"
onClick={onMemberAvatarClick}
onKeyDown={onMemberAvatarKey}
tabIndex="0"
role="img"
aria-label={_t("Profile picture")}
>
<div><div style={{backgroundImage: `url(${httpUrl})`}} /></div>
</div>; </div>;
} }
@ -1058,6 +1168,12 @@ const UserInfo = withLegacyMatrixClient(({matrixClient: cli, user, groupId, room
title={_t('Close')} />; title={_t('Close')} />;
} }
const memberDetails = <PowerLevelSection
powerLevels={powerLevels}
user={user} room={room} roomPermissions={roomPermissions}
/>;
const isRoomEncrypted = useIsEncrypted(cli, room);
// undefined means yet to be loaded, null means failed to load, otherwise list of devices // undefined means yet to be loaded, null means failed to load, otherwise list of devices
const [devices, setDevices] = useState(undefined); const [devices, setDevices] = useState(undefined);
// Download device lists // Download device lists
@ -1082,14 +1198,15 @@ const UserInfo = withLegacyMatrixClient(({matrixClient: cli, user, groupId, room
setDevices(null); setDevices(null);
} }
} }
if (isRoomEncrypted) {
_downloadDeviceList(); _downloadDeviceList();
}
// Handle being unmounted // Handle being unmounted
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [cli, user.userId]); }, [cli, user.userId, isRoomEncrypted]);
// Listen to changes // Listen to changes
useEffect(() => { 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 // Handle being unmounted
return () => { return () => {
cancel = true; cancel = true;
cli.removeListener("deviceVerificationChanged", onDeviceVerificationChanged); if (isRoomEncrypted) {
cli.removeListener("deviceVerificationChanged", onDeviceVerificationChanged);
}
}; };
}, [cli, user.userId]); }, [cli, user.userId, isRoomEncrypted]);
let devicesSection;
const isRoomEncrypted = _enableDevices && room && cli.isRoomEncrypted(room.roomId);
if (isRoomEncrypted) {
devicesSection = <DevicesSection loading={devices === undefined} devices={devices} userId={user.userId} />;
} else {
let text;
let text;
if (!isRoomEncrypted) {
if (!_enableDevices) { if (!_enableDevices) {
text = _t("This client does not support end-to-end encryption."); text = _t("This client does not support end-to-end encryption.");
} else if (room) { } else if (room) {
@ -1128,22 +1244,24 @@ const UserInfo = withLegacyMatrixClient(({matrixClient: cli, user, groupId, room
} else { } else {
// TODO what to render for GroupMember // TODO what to render for GroupMember
} }
} else {
if (text) { text = _t("Messages in this room are end-to-end encrypted.");
devicesSection = (
<div className="mx_UserInfo_container">
<h3>{ _t("Trust & Devices") }</h3>
<div className="mx_UserInfo_devices">
{ text }
</div>
</div>
);
}
} }
const devicesSection = isRoomEncrypted ?
(<DevicesSection loading={devices === undefined} devices={devices} userId={user.userId} />) : null;
const securitySection = (
<div className="mx_UserInfo_container">
<h3>{ _t("Security") }</h3>
<p>{ text }</p>
<AccessibleButton className="mx_UserInfo_verify" onClick={() => verifyDevice(user.userId, null)}>{_t("Verify")}</AccessibleButton>
{ devicesSection }
</div>
);
let e2eIcon; let e2eIcon;
if (isRoomEncrypted && devices) { if (isRoomEncrypted && devices) {
e2eIcon = <E2EIcon status={_getE2EStatus(devices)} isUser={true} />; e2eIcon = <E2EIcon size={18} status={_getE2EStatus(devices)} isUser={true} />;
} }
return ( return (
@ -1153,16 +1271,14 @@ const UserInfo = withLegacyMatrixClient(({matrixClient: cli, user, groupId, room
<div className="mx_UserInfo_container"> <div className="mx_UserInfo_container">
<div className="mx_UserInfo_profile"> <div className="mx_UserInfo_profile">
<div className="mx_UserInfo_profileField"> <div >
<h2> <h2 aria-label={displayName}>
{ e2eIcon } { e2eIcon }
{ displayName } { displayName }
</h2> </h2>
</div> </div>
<div className="mx_UserInfo_profileField"> <div>{ user.userId }</div>
{ user.userId } <div className="mx_UserInfo_profileStatus">
</div>
<div className="mx_UserInfo_profileField">
{presenceLabel} {presenceLabel}
{statusLabel} {statusLabel}
</div> </div>
@ -1176,11 +1292,9 @@ const UserInfo = withLegacyMatrixClient(({matrixClient: cli, user, groupId, room
</div> } </div> }
<AutoHideScrollbar className="mx_UserInfo_scrollContainer"> <AutoHideScrollbar className="mx_UserInfo_scrollContainer">
{ devicesSection } { securitySection }
{ directChatsSection }
<UserOptionsSection <UserOptionsSection
devices={devices}
canInvite={roomPermissions.canInvite} canInvite={roomPermissions.canInvite}
isIgnored={isIgnored} isIgnored={isIgnored}
member={user} /> member={user} />

View file

@ -36,7 +36,13 @@ export default function(props) {
_t("All devices for this user are trusted") : _t("All devices for this user are trusted") :
_t("All devices in this encrypted room are trusted"); _t("All devices in this encrypted room are trusted");
} }
const icon = (<div className={e2eIconClasses} title={e2eTitle} />);
let style = null;
if (props.size) {
style = {width: `${props.size}px`, height: `${props.size}px`};
}
const icon = (<div className={e2eIconClasses} style={style} title={e2eTitle} />);
if (props.onClick) { if (props.onClick) {
return (<AccessibleButton onClick={props.onClick}>{ icon }</AccessibleButton>); return (<AccessibleButton onClick={props.onClick}>{ icon }</AccessibleButton>);
} else { } else {

View file

@ -606,8 +606,8 @@ module.exports = createReactClass({
mx_EventTile_last: this.props.last, mx_EventTile_last: this.props.last,
mx_EventTile_contextual: this.props.contextual, mx_EventTile_contextual: this.props.contextual,
mx_EventTile_actionBarFocused: this.state.actionBarFocused, mx_EventTile_actionBarFocused: this.state.actionBarFocused,
mx_EventTile_verified: this.state.verified === true, mx_EventTile_verified: !isBubbleMessage && this.state.verified === true,
mx_EventTile_unverified: this.state.verified === false, mx_EventTile_unverified: !isBubbleMessage && this.state.verified === false,
mx_EventTile_bad: isEncryptionFailure, mx_EventTile_bad: isEncryptionFailure,
mx_EventTile_emote: msgtype === 'm.emote', mx_EventTile_emote: msgtype === 'm.emote',
mx_EventTile_redacted: isRedacted, mx_EventTile_redacted: isRedacted,
@ -800,7 +800,7 @@ module.exports = createReactClass({
<a href={permalink} onClick={this.onPermalinkClicked}> <a href={permalink} onClick={this.onPermalinkClicked}>
{ timestamp } { timestamp }
</a> </a>
{ this._renderE2EPadlock() } { !isBubbleMessage && this._renderE2EPadlock() }
{ thread } { thread }
<EventTileType ref="tile" <EventTileType ref="tile"
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
@ -826,7 +826,7 @@ module.exports = createReactClass({
{ readAvatars } { readAvatars }
</div> </div>
{ sender } { sender }
<div className={classNames("mx_EventTile_line", {mx_EventTile_bubbleLine: isBubbleMessage})}> <div className="mx_EventTile_line">
<a <a
href={permalink} href={permalink}
onClick={this.onPermalinkClicked} onClick={this.onPermalinkClicked}
@ -834,7 +834,7 @@ module.exports = createReactClass({
> >
{ timestamp } { timestamp }
</a> </a>
{ this._renderE2EPadlock() } { !isBubbleMessage && this._renderE2EPadlock() }
{ thread } { thread }
<EventTileType ref="tile" <EventTileType ref="tile"
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}

View file

@ -118,6 +118,7 @@
"Restricted": "Restricted", "Restricted": "Restricted",
"Moderator": "Moderator", "Moderator": "Moderator",
"Admin": "Admin", "Admin": "Admin",
"Custom (%(level)s)": "Custom (%(level)s)",
"Start a chat": "Start a chat", "Start a chat": "Start a chat",
"Who would you like to communicate with?": "Who would you like to communicate with?", "Who would you like to communicate with?": "Who would you like to communicate with?",
"Email, name or Matrix ID": "Email, name or Matrix ID", "Email, name or Matrix ID": "Email, name or Matrix ID",
@ -1066,16 +1067,26 @@
"When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.": "When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.", "When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.": "When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.",
"Members": "Members", "Members": "Members",
"Files": "Files", "Files": "Files",
"Trust & Devices": "Trust & Devices", "Trusted": "Trusted",
"Direct messages": "Direct messages", "Not trusted": "Not trusted",
"Hide verified Sign-In's": "Hide verified Sign-In's",
"%(count)s verified Sign-In's|other": "%(count)s verified Sign-In's",
"%(count)s verified Sign-In's|one": "1 verified Sign-In",
"Direct message": "Direct message",
"Unverify user": "Unverify user",
"Options": "Options",
"Remove from community": "Remove from community", "Remove from community": "Remove from community",
"Disinvite this user from community?": "Disinvite this user from community?", "Disinvite this user from community?": "Disinvite this user from community?",
"Remove this user from community?": "Remove this user from community?", "Remove this user from community?": "Remove this user from community?",
"Failed to withdraw invitation": "Failed to withdraw invitation", "Failed to withdraw invitation": "Failed to withdraw invitation",
"Failed to remove user from community": "Failed to remove user from community", "Failed to remove user from community": "Failed to remove user from community",
"<strong>%(role)s</strong> in %(roomName)s": "<strong>%(role)s</strong> in %(roomName)s",
"Failed to deactivate user": "Failed to deactivate user", "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.", "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 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", "Sunday": "Sunday",
"Monday": "Monday", "Monday": "Monday",
"Tuesday": "Tuesday", "Tuesday": "Tuesday",
@ -1091,7 +1102,6 @@
"Reply": "Reply", "Reply": "Reply",
"Edit": "Edit", "Edit": "Edit",
"Message Actions": "Message Actions", "Message Actions": "Message Actions",
"Options": "Options",
"Attachment": "Attachment", "Attachment": "Attachment",
"Error decrypting attachment": "Error decrypting attachment", "Error decrypting attachment": "Error decrypting attachment",
"Decrypt %(text)s": "Decrypt %(text)s", "Decrypt %(text)s": "Decrypt %(text)s",