diff --git a/.eslintrc.js b/.eslintrc.js index fdf0bb351e..81c3752301 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -12,6 +12,7 @@ module.exports = { extends: [matrixJsSdkPath + "/.eslintrc.js"], plugins: [ "react", + "react-hooks", "flowtype", "babel" ], @@ -104,6 +105,9 @@ module.exports = { // crashes currently: https://github.com/eslint/eslint/issues/6274 "generator-star-spacing": "off", + + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", }, settings: { flowtype: { diff --git a/package.json b/package.json index d2955f89be..e24901070f 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "test-multi": "karma start" }, "dependencies": { + "@use-it/event-listener": "^0.1.3", "babel-plugin-syntax-dynamic-import": "^6.18.0", "babel-runtime": "^6.26.0", "bluebird": "^3.5.0", @@ -134,6 +135,7 @@ "eslint-plugin-babel": "^5.2.1", "eslint-plugin-flowtype": "^2.30.0", "eslint-plugin-react": "^7.7.0", + "eslint-plugin-react-hooks": "^2.0.1", "estree-walker": "^0.5.0", "expect": "^24.1.0", "file-loader": "^3.0.1", diff --git a/res/css/_components.scss b/res/css/_components.scss index f627fe3a29..70d4e8e6ae 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -132,6 +132,7 @@ @import "./views/messages/_TextualEvent.scss"; @import "./views/messages/_UnknownBody.scss"; @import "./views/messages/_ViewSourceEvent.scss"; +@import "./views/right_panel/_UserInfo.scss"; @import "./views/room_settings/_AliasSettings.scss"; @import "./views/room_settings/_ColorSettings.scss"; @import "./views/rooms/_AppsDrawer.scss"; diff --git a/res/css/views/right_panel/_UserInfo.scss b/res/css/views/right_panel/_UserInfo.scss new file mode 100644 index 0000000000..df536a7388 --- /dev/null +++ b/res/css/views/right_panel/_UserInfo.scss @@ -0,0 +1,175 @@ +/* +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. +*/ + +.mx_UserInfo { + display: flex; + flex-direction: column; + flex: 1; + overflow-y: auto; +} + +.mx_UserInfo_profile .mx_E2EIcon { + display: inline; + margin: auto; + padding-right: 25px; + mask-size: contain; +} + +.mx_UserInfo_cancel { + height: 16px; + width: 16px; + padding: 10px 0 10px 10px; + cursor: pointer; + mask-image: url('$(res)/img/minimise.svg'); + mask-repeat: no-repeat; + mask-position: 16px center; + background-color: $rightpanel-button-color; +} + +.mx_UserInfo_profile h2 { + flex: 1; + overflow-x: auto; + max-height: 50px; +} + +.mx_UserInfo h2 { + font-size: 16px; + font-weight: 600; + margin: 16px 0 8px 0; +} + +.mx_UserInfo_container { + padding: 0 16px 16px 16px; + border-bottom: 1px solid lightgray; +} + +.mx_UserInfo_memberDetailsContainer { + padding-bottom: 0; +} + +.mx_UserInfo .mx_RoomTile_nameContainer { + width: 154px; +} + +.mx_UserInfo .mx_RoomTile_badge { + display: none; +} + +.mx_UserInfo .mx_RoomTile_name { + width: 160px; +} + +.mx_UserInfo_avatar { + background: $tagpanel-bg-color; +} + +.mx_UserInfo_avatar > img { + height: auto; + width: 100%; + max-height: 30vh; + object-fit: contain; + display: block; +} + +.mx_UserInfo_avatar .mx_BaseAvatar.mx_BaseAvatar_image { + cursor: zoom-in; +} + +.mx_UserInfo h3 { + text-transform: uppercase; + color: $input-darker-fg-color; + font-weight: bold; + font-size: 12px; + margin: 4px 0; +} + +.mx_UserInfo_profileField { + font-size: 15px; + position: relative; + text-align: center; +} + +.mx_UserInfo_memberDetails { + text-align: center; +} + +.mx_UserInfo_field { + cursor: pointer; + font-size: 15px; + color: $primary-fg-color; + margin-left: 8px; + line-height: 23px; +} + +.mx_UserInfo_createRoom { + cursor: pointer; + display: flex; + align-items: center; + padding: 0 8px; +} + +.mx_UserInfo_createRoom_label { + width: initial !important; + cursor: pointer; +} + +.mx_UserInfo_statusMessage { + font-size: 11px; + opacity: 0.5; + overflow: hidden; + white-space: nowrap; + text-overflow: clip; +} +.mx_UserInfo .mx_UserInfo_scrollContainer { + flex: 1; + padding-bottom: 16px; +} + +.mx_UserInfo .mx_UserInfo_scrollContainer .mx_UserInfo_container { + padding-top: 16px; + padding-bottom: 0; + border-bottom: none; +} + +.mx_UserInfo_container_header { + display: flex; +} + +.mx_UserInfo_container_header_right { + position: relative; + margin-left: auto; +} + +.mx_UserInfo_newDmButton { + background-color: $roomheader-addroom-bg-color; + border-radius: 10px; // 16/2 + 2 padding + height: 16px; + flex: 0 0 16px; + + &::before { + background-color: $roomheader-addroom-fg-color; + mask: url('$(res)/img/icons-room-add.svg'); + mask-repeat: no-repeat; + mask-position: center; + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + } +} diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index 31e4788a0b..48d272f6c9 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -27,6 +27,7 @@ import { MatrixClient } from 'matrix-js-sdk'; import RateLimitedFunc from '../../ratelimitedfunc'; import { showGroupInviteDialog, showGroupAddRoomDialog } from '../../GroupAddressPicker'; import GroupStore from '../../stores/GroupStore'; +import SettingsStore from "../../settings/SettingsStore"; export default class RightPanel extends React.Component { static get propTypes() { @@ -165,6 +166,7 @@ export default class RightPanel extends React.Component { render() { const MemberList = sdk.getComponent('rooms.MemberList'); const MemberInfo = sdk.getComponent('rooms.MemberInfo'); + const UserInfo = sdk.getComponent('right_panel.UserInfo'); const ThirdPartyMemberInfo = sdk.getComponent('rooms.ThirdPartyMemberInfo'); const NotificationPanel = sdk.getComponent('structures.NotificationPanel'); const FilePanel = sdk.getComponent('structures.FilePanel'); @@ -183,14 +185,46 @@ export default class RightPanel extends React.Component { } else if (this.state.phase === RightPanel.Phase.GroupRoomList) { panel = ; } else if (this.state.phase === RightPanel.Phase.RoomMemberInfo) { - panel = ; + if (SettingsStore.isFeatureEnabled("feature_user_info_panel")) { + const onClose = () => { + dis.dispatch({ + action: "view_user", + member: null, + }); + }; + panel = ; + } else { + panel = ; + } } else if (this.state.phase === RightPanel.Phase.Room3pidMemberInfo) { panel = ; } else if (this.state.phase === RightPanel.Phase.GroupMemberInfo) { - panel = ; + if (SettingsStore.isFeatureEnabled("feature_user_info_panel")) { + const onClose = () => { + dis.dispatch({ + action: "view_user", + member: null, + }); + }; + panel = ; + } else { + panel = ( + + ); + } } else if (this.state.phase === RightPanel.Phase.GroupRoomInfo) { panel = +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, {useCallback, useMemo, useState, useEffect} from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import useEventListener from '@use-it/event-listener'; +import {Group, MatrixClient, RoomMember, User} from 'matrix-js-sdk'; +import dis from '../../../dispatcher'; +import Modal from '../../../Modal'; +import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; +import createRoom from '../../../createRoom'; +import DMRoomMap from '../../../utils/DMRoomMap'; +import Unread from '../../../Unread'; +import AccessibleButton from '../elements/AccessibleButton'; +import SdkConfig from '../../../SdkConfig'; +import SettingsStore from "../../../settings/SettingsStore"; +import {EventTimeline} from "matrix-js-sdk"; +import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; +import * as RoomViewStore from "../../../stores/RoomViewStore"; +import MultiInviter from "../../../utils/MultiInviter"; +import GroupStore from "../../../stores/GroupStore"; +import MatrixClientPeg from "../../../MatrixClientPeg"; +import E2EIcon from "../rooms/E2EIcon"; + +const _disambiguateDevices = (devices) => { + const names = Object.create(null); + for (let i = 0; i < devices.length; i++) { + const name = devices[i].getDisplayName(); + const indexList = names[name] || []; + indexList.push(i); + names[name] = indexList; + } + for (const name in names) { + if (names[name].length > 1) { + names[name].forEach((j)=>{ + devices[j].ambiguous = true; + }); + } + } +}; + +const withLegacyMatrixClient = (Component) => class extends React.PureComponent { + static contextTypes = { + matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, + }; + + render() { + return ; + } +}; + +const _getE2EStatus = (devices) => { + const hasUnverifiedDevice = devices.some((device) => device.isUnverified()); + return hasUnverifiedDevice ? "warning" : "verified"; +}; + +const DevicesSection = withLegacyMatrixClient(({devices, userId, loading}) => { + const MemberDeviceInfo = sdk.getComponent('rooms.MemberDeviceInfo'); + const Spinner = sdk.getComponent("elements.Spinner"); + + if (loading) { + // still loading + return ; + } + if (devices === null) { + return _t("Unable to load device list"); + } + if (devices.length === 0) { + return _t("No devices with registered encryption keys"); + } + + return ( +
+

{ _t("Trust & Devices") }

+
+ { devices.map((device, i) => ) } +
+
+ ); +}); + +const onRoomTileClick = (roomId) => { + dis.dispatch({ + action: 'view_room', + room_id: roomId, + }); +}; + +const DirectChatsSection = withLegacyMatrixClient(({cli, userId, startUpdating, stopUpdating}) => { + const onNewDMClick = async () => { + startUpdating(); + await createRoom({dmUserId: userId}); + stopUpdating(); + }; + + // TODO: Immutable DMs replaces a lot of this + // dmRooms will not include dmRooms that we have been invited into but did not join. + // Because DMRoomMap runs off account_data[m.direct] which is only set on join of dm room. + // XXX: we potentially want DMs we have been invited to, to also show up here :L + // especially as logic below concerns specially if we haven't joined but have been invited + const [dmRooms, setDmRooms] = useState(new DMRoomMap(cli).getDMRoomsForUserId(userId)); + + // TODO bind the below + // cli.on("Room", this.onRoom); + // cli.on("Room.name", this.onRoomName); + // cli.on("deleteRoom", this.onDeleteRoom); + + const accountDataHandler = useCallback((ev) => { + if (ev.getType() === "m.direct") { + const dmRoomMap = new DMRoomMap(cli); + setDmRooms(dmRoomMap.getDMRoomsForUserId(userId)); + } + }, [cli, userId]); + + useEventListener("accountData", accountDataHandler, cli); + + 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( + , + ); + } + } + + const labelClasses = classNames({ + mx_UserInfo_createRoom_label: true, + mx_RoomTile_name: true, + }); + + let body = tiles; + if (!body) { + body = ( + +
+ {_t("Start +
+
{ _t("Start a chat") }
+
+ ); + } + + return ( +
+
+

{ _t("Direct messages") }

+ +
+ { body } +
+ ); +}); + +const UserOptionsSection = withLegacyMatrixClient(({cli, member, isIgnored, canInvite}) => { + let ignoreButton = null; + let insertPillButton = null; + let inviteUserButton = null; + let readReceiptButton = null; + + const onShareUserClick = () => { + const ShareDialog = sdk.getComponent("dialogs.ShareDialog"); + Modal.createTrackedDialog('share room member dialog', '', ShareDialog, { + target: member, + }); + }; + + // Only allow the user to ignore the user if its not ourselves + // same goes for jumping to read receipt + if (member.userId !== cli.getUserId()) { + const onIgnoreToggle = () => { + const ignoredUsers = cli.getIgnoredUsers(); + if (isIgnored) { + const index = ignoredUsers.indexOf(member.userId); + if (index !== -1) ignoredUsers.splice(index, 1); + } else { + ignoredUsers.push(member.userId); + } + + cli.setIgnoredUsers(ignoredUsers).then(() => { + // return this.setState({isIgnoring: !this.state.isIgnoring}); + }); + }; + + ignoreButton = ( + + { isIgnored ? _t("Unignore") : _t("Ignore") } + + ); + + if (member.roomId) { + const onReadReceiptButton = function() { + const room = cli.getRoom(member.roomId); + dis.dispatch({ + action: 'view_room', + highlighted: true, + event_id: room.getEventReadUpTo(member.userId), + room_id: member.roomId, + }); + }; + + const onInsertPillButton = function() { + dis.dispatch({ + action: 'insert_mention', + user_id: member.userId, + }); + }; + + readReceiptButton = ( + + { _t('Jump to read receipt') } + + ); + + insertPillButton = ( + + { _t('Mention') } + + ); + } + + if (canInvite && (!member || !member.membership || member.membership === 'leave')) { + const roomId = member && member.roomId ? member.roomId : RoomViewStore.getRoomId(); + const onInviteUserButton = async () => { + try { + // We use a MultiInviter to re-use the invite logic, even though + // we're only inviting one user. + const inviter = new MultiInviter(roomId); + await inviter.invite([member.userId]).then(() => { + if (inviter.getCompletionState(member.userId) !== "invited") { + throw new Error(inviter.getErrorText(member.userId)); + } + }); + } catch (err) { + const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); + Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, { + title: _t('Failed to invite'), + description: ((err && err.message) ? err.message : _t("Operation failed")), + }); + } + }; + + inviteUserButton = ( + + { _t('Invite') } + + ); + } + } + + const shareUserButton = ( + + { _t('Share Link to User') } + + ); + + return ( +
+

{ _t("User Options") }

+
+ { readReceiptButton } + { shareUserButton } + { insertPillButton } + { ignoreButton } + { inviteUserButton } +
+
+ ); +}); + +const _warnSelfDemote = async () => { + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + const {finished} = Modal.createTrackedDialog('Demoting Self', '', QuestionDialog, { + title: _t("Demote yourself?"), + description: +
+ { _t("You will not be able to undo this change as you are demoting yourself, " + + "if you are the last privileged user in the room it will be impossible " + + "to regain privileges.") } +
, + button: _t("Demote"), + }); + + const [confirmed] = await finished; + return confirmed; +}; + +const GenericAdminToolsContainer = ({children}) => { + return ( +
+

{ _t("Admin Tools") }

+
+ { children } +
+
+ ); +}; + +const _isMuted = (member, powerLevelContent) => { + if (!powerLevelContent || !member) return false; + + const levelToSend = ( + (powerLevelContent.events ? powerLevelContent.events["m.room.message"] : null) || + powerLevelContent.events_default + ); + return member.powerLevel < levelToSend; +}; + +const useRoomPowerLevels = (room) => { + const [powerLevels, setPowerLevels] = useState({}); + + const update = useCallback(() => { + const event = room.currentState.getStateEvents("m.room.power_levels", ""); + if (event) { + setPowerLevels(event.getContent()); + } else { + setPowerLevels({}); + } + return () => { + setPowerLevels({}); + }; + }, [room]); + + useEventListener("RoomState.events", update, room); + useEffect(() => { + update(); + return () => { + setPowerLevels({}); + }; + }, [update]); + return powerLevels; +}; + +const RoomAdminToolsContainer = withLegacyMatrixClient(({cli, room, children, member, startUpdating, stopUpdating}) => { + let kickButton; + let banButton; + let muteButton; + let redactButton; + + const powerLevels = useRoomPowerLevels(room); + const editPowerLevel = ( + (powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) || + powerLevels.state_default + ); + + const me = room.getMember(cli.getUserId()); + const isMe = me.userId === member.userId; + const canAffectUser = member.powerLevel < me.powerLevel || isMe; + const membership = member.membership; + + if (canAffectUser && me.powerLevel >= powerLevels.kick) { + const onKick = async () => { + const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog"); + const {finished} = Modal.createTrackedDialog( + 'Confirm User Action Dialog', + 'onKick', + ConfirmUserActionDialog, + { + member, + action: membership === "invite" ? _t("Disinvite") : _t("Kick"), + title: membership === "invite" ? _t("Disinvite this user?") : _t("Kick this user?"), + askReason: membership === "join", + danger: true, + }, + ); + + const [proceed, reason] = await finished; + if (!proceed) return; + + startUpdating(); + cli.kick(member.roomId, member.userId, reason || undefined).then(() => { + // 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("Kick success"); + }, function(err) { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Kick error: " + err); + Modal.createTrackedDialog('Failed to kick', '', ErrorDialog, { + title: _t("Failed to kick"), + description: ((err && err.message) ? err.message : "Operation failed"), + }); + }).finally(() => { + stopUpdating(); + }); + }; + + const kickLabel = membership === "invite" ? _t("Disinvite") : _t("Kick"); + kickButton = ( + + { kickLabel } + + ); + } + if (me.powerLevel >= powerLevels.redact) { + const onRedactAllMessages = async () => { + const {roomId, userId} = member; + const room = cli.getRoom(roomId); + if (!room) { + return; + } + let timeline = room.getLiveTimeline(); + let eventsToRedact = []; + while (timeline) { + eventsToRedact = timeline.getEvents().reduce((events, event) => { + if (event.getSender() === userId && !event.isRedacted()) { + return events.concat(event); + } else { + return events; + } + }, eventsToRedact); + timeline = timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS); + } + + const count = eventsToRedact.length; + const user = member.name; + + if (count === 0) { + const InfoDialog = sdk.getComponent("dialogs.InfoDialog"); + Modal.createTrackedDialog('No user messages found to remove', '', InfoDialog, { + title: _t("No recent messages by %(user)s found", {user}), + description: +
+

{ _t("Try scrolling up in the timeline to see if there are any earlier ones.") }

+
, + }); + } else { + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + + const {finished} = Modal.createTrackedDialog('Remove recent messages by user', '', QuestionDialog, { + title: _t("Remove recent messages by %(user)s", {user}), + description: +
+

{ _t("You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?", {count, user}) }

+

{ _t("For a large amount of messages, this might take some time. Please don't refresh your client in the meantime.") }

+
, + button: _t("Remove %(count)s messages", {count}), + }); + + const [confirmed] = await finished; + if (!confirmed) { + return; + } + + // Submitting a large number of redactions freezes the UI, + // so first yield to allow to rerender after closing the dialog. + await Promise.resolve(); + + console.info(`Started redacting recent ${count} messages for ${user} in ${roomId}`); + await Promise.all(eventsToRedact.map(async event => { + try { + await cli.redactEvent(roomId, event.getId()); + } catch (err) { + // log and swallow errors + console.error("Could not redact", event.getId()); + console.error(err); + } + })); + console.info(`Finished redacting recent ${count} messages for ${user} in ${roomId}`); + } + }; + + redactButton = ( + + { _t("Remove recent messages") } + + ); + } + if (canAffectUser && me.powerLevel >= powerLevels.ban) { + const onBanOrUnban = async () => { + const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog"); + const {finished} = Modal.createTrackedDialog( + 'Confirm User Action Dialog', + 'onBanOrUnban', + ConfirmUserActionDialog, + { + member, + action: membership === 'ban' ? _t("Unban") : _t("Ban"), + title: membership === 'ban' ? _t("Unban this user?") : _t("Ban this user?"), + askReason: membership !== 'ban', + danger: membership !== 'ban', + }, + ); + + const [proceed, reason] = await finished; + if (!proceed) return; + + startUpdating(); + let promise; + if (membership === 'ban') { + promise = cli.unban(member.roomId, member.userId); + } else { + promise = cli.ban(member.roomId, member.userId, reason || undefined); + } + promise.then(() => { + // 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("Ban success"); + }, function(err) { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Ban error: " + err); + Modal.createTrackedDialog('Failed to ban user', '', ErrorDialog, { + title: _t("Error"), + description: _t("Failed to ban user"), + }); + }).finally(() => { + stopUpdating(); + }); + }; + + let label = _t("Ban"); + if (membership === 'ban') { + label = _t("Unban"); + } + banButton = ( + + { label } + + ); + } + if (canAffectUser && me.powerLevel >= editPowerLevel) { + const isMuted = _isMuted(member, powerLevels); + const onMuteToggle = async () => { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + const roomId = member.roomId; + const target = member.userId; + + // if muting self, warn as it may be irreversible + if (target === cli.getUserId()) { + try { + if (!(await _warnSelfDemote())) return; + } catch (e) { + console.error("Failed to warn about self demotion: ", e); + return; + } + } + + const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); + if (!powerLevelEvent) return; + + const powerLevels = powerLevelEvent.getContent(); + const levelToSend = ( + (powerLevels.events ? powerLevels.events["m.room.message"] : null) || + powerLevels.events_default + ); + let level; + if (isMuted) { // unmute + level = levelToSend; + } else { // mute + level = levelToSend - 1; + } + level = parseInt(level); + + if (!isNaN(level)) { + startUpdating(); + cli.setPowerLevel(roomId, target, level, powerLevelEvent).then(() => { + // 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("Mute toggle success"); + }, function(err) { + console.error("Mute error: " + err); + Modal.createTrackedDialog('Failed to mute user', '', ErrorDialog, { + title: _t("Error"), + description: _t("Failed to mute user"), + }); + }).finally(() => { + stopUpdating(); + }); + } + }; + + const muteLabel = isMuted ? _t("Unmute") : _t("Mute"); + muteButton = ( + + { muteLabel } + + ); + } + + if (kickButton || banButton || muteButton || redactButton || children) { + return + { muteButton } + { kickButton } + { banButton } + { redactButton } + { children } + ; + } + + return
; +}); + +const GroupAdminToolsSection = withLegacyMatrixClient( + ({cli, children, groupId, groupMember, startUpdating, stopUpdating}) => { + const [isPrivileged, setIsPrivileged] = useState(false); + const [isInvited, setIsInvited] = useState(false); + + // Listen to group store changes + useEffect(() => { + let unmounted = false; + + const onGroupStoreUpdated = () => { + if (unmounted) return; + setIsPrivileged(GroupStore.isUserPrivileged(groupId)); + setIsInvited(GroupStore.getGroupInvitedMembers(groupId).some( + (m) => m.userId === groupMember.userId, + )); + }; + + GroupStore.registerListener(groupId, onGroupStoreUpdated); + onGroupStoreUpdated(); + // Handle unmount + return () => { + unmounted = true; + GroupStore.unregisterListener(onGroupStoreUpdated); + }; + }, [groupId, groupMember.userId]); + + if (isPrivileged) { + const _onKick = async () => { + const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog"); + const {finished} = Modal.createDialog(ConfirmUserActionDialog, { + matrixClient: cli, + groupMember, + action: isInvited ? _t('Disinvite') : _t('Remove from community'), + title: isInvited ? _t('Disinvite this user from community?') + : _t('Remove this user from community?'), + danger: true, + }); + + const [proceed] = await finished; + if (!proceed) return; + + startUpdating(); + cli.removeUserFromGroup(groupId, groupMember.userId).then(() => { + // return to the user list + dis.dispatch({ + action: "view_user", + member: null, + }); + }).catch((e) => { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Failed to remove user from group', '', ErrorDialog, { + title: _t('Error'), + description: isInvited ? + _t('Failed to withdraw invitation') : + _t('Failed to remove user from community'), + }); + console.log(e); + }).finally(() => { + stopUpdating(); + }); + }; + + const kickButton = ( + + { isInvited ? _t('Disinvite') : _t('Remove from community') } + + ); + + // No make/revoke admin API yet + /*const opLabel = this.state.isTargetMod ? _t("Revoke Moderator") : _t("Make Moderator"); + giveModButton = + {giveOpLabel} + ;*/ + + return + { kickButton } + { children } + ; + } + + return
; + }, +); + +const GroupMember = PropTypes.shape({ + userId: PropTypes.string.isRequired, + displayname: PropTypes.string, // XXX: GroupMember objects are inconsistent :(( + avatarUrl: PropTypes.string, +}); + +const useIsSynapseAdmin = (cli) => { + const [isAdmin, setIsAdmin] = useState(false); + useEffect(() => { + cli.isSynapseAdministrator().then((isAdmin) => { + setIsAdmin(isAdmin); + }, () => { + setIsAdmin(false); + }); + }, []); + return isAdmin; +}; + +// cli is injected by withLegacyMatrixClient +const UserInfo = withLegacyMatrixClient(({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]); + useEventListener("accountData", accountDataHandler, cli); + + // 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({ + // modifyLevelMax is the max PL we can set this user to, typically min(their PL, our PL) && canSetPL + modifyLevelMax: -1, + canInvite: false, + }); + const updateRoomPermissions = useCallback(async () => { + if (!room) return; + + const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); + if (!powerLevelEvent) return; + const powerLevels = powerLevelEvent.getContent(); + if (!powerLevels) return; + + const me = room.getMember(cli.getUserId()); + if (!me) return; + + const them = user; + const isMe = me.userId === them.userId; + const canAffectUser = them.powerLevel < me.powerLevel || isMe; + + let modifyLevelMax = -1; + if (canAffectUser) { + const editPowerLevel = ( + (powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) || + powerLevels.state_default + ); + if (me.powerLevel >= editPowerLevel && (isMe || me.powerLevel > them.powerLevel)) { + modifyLevelMax = me.powerLevel; + } + } + + setRoomPermissions({ + canInvite: me.powerLevel >= powerLevels.invite, + modifyLevelMax, + }); + }, [cli, user, room]); + useEventListener("RoomState.events", updateRoomPermissions, cli); + useEffect(() => { + updateRoomPermissions(); + return () => { + setRoomPermissions({ + maximalPowerLevel: -1, + canInvite: false, + }); + }; + }, [updateRoomPermissions]); + + const onSynapseDeactivate = useCallback(async () => { + const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog'); + const {finished} = Modal.createTrackedDialog('Synapse User Deactivation', '', QuestionDialog, { + title: _t("Deactivate user?"), + description: +
{ _t( + "Deactivating this user will log them out and prevent them from logging back in. Additionally, " + + "they will leave all the rooms they are in. This action cannot be reversed. Are you sure you " + + "want to deactivate this user?", + ) }
, + button: _t("Deactivate user"), + danger: true, + }); + + const [accepted] = await finished; + if (!accepted) return; + try { + cli.deactivateSynapseUser(user.userId); + } catch (err) { + const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); + Modal.createTrackedDialog('Failed to deactivate user', '', ErrorDialog, { + title: _t('Failed to deactivate user'), + description: ((err && err.message) ? err.message : _t("Operation failed")), + }); + } + }, [cli, user.userId]); + + const onPowerChange = useCallback(async (powerLevel) => { + const _applyPowerChange = (roomId, target, powerLevel, powerLevelEvent) => { + startUpdating(); + cli.setPowerLevel(roomId, target, parseInt(powerLevel), powerLevelEvent).then( + function() { + // NO-OP; rely on the m.room.member event coming down else we could + // get out of sync if we force setState here! + console.log("Power change success"); + }, function(err) { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Failed to change power level " + err); + Modal.createTrackedDialog('Failed to change power level', '', ErrorDialog, { + title: _t("Error"), + description: _t("Failed to change power level"), + }); + }, + ).finally(() => { + stopUpdating(); + }).done(); + }; + + const roomId = user.roomId; + const target = user.userId; + + const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); + if (!powerLevelEvent) return; + + if (!powerLevelEvent.getContent().users) { + _applyPowerChange(roomId, target, powerLevel, powerLevelEvent); + return; + } + + const myUserId = cli.getUserId(); + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + + // If we are changing our own PL it can only ever be decreasing, which we cannot reverse. + if (myUserId === target) { + try { + if (!(await _warnSelfDemote())) return; + _applyPowerChange(roomId, target, powerLevel, powerLevelEvent); + } catch (e) { + console.error("Failed to warn about self demotion: ", e); + } + return; + } + + const myPower = powerLevelEvent.getContent().users[myUserId]; + if (parseInt(myPower) === parseInt(powerLevel)) { + const {finished} = Modal.createTrackedDialog('Promote to PL100 Warning', '', QuestionDialog, { + title: _t("Warning!"), + description: +
+ { _t("You will not be able to undo this change as you are promoting the user " + + "to have the same power level as yourself.") }
+ { _t("Are you sure?") } +
, + button: _t("Continue"), + }); + + const [confirmed] = await finished; + if (confirmed) return; + } + _applyPowerChange(roomId, target, powerLevel, powerLevelEvent); + }, [user.roomId, user.userId, room && room.currentState, cli]); // eslint-disable-line + + const onMemberAvatarClick = useCallback(() => { + const member = user; + const avatarUrl = member.getMxcAvatarUrl(); + if (!avatarUrl) return; + + const httpUrl = cli.mxcUrlToHttp(avatarUrl); + const ImageView = sdk.getComponent("elements.ImageView"); + const params = { + src: httpUrl, + name: member.name, + }; + + Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); + }, [cli, user]); + + let synapseDeactivateButton; + let spinner; + + let directChatsSection; + if (user.userId !== cli.getUserId()) { + directChatsSection = ; + } + + // We don't need a perfect check here, just something to pass as "probably not our homeserver". If + // someone does figure out how to bypass this check the worst that happens is an error. + // FIXME this should be using cli instead of MatrixClientPeg.matrixClient + if (isSynapseAdmin && user.userId.endsWith(`:${MatrixClientPeg.getHomeserverName()}`)) { + synapseDeactivateButton = ( + + {_t("Deactivate user")} + + ); + } + + let adminToolsContainer; + if (room && user.roomId) { + adminToolsContainer = ( + + { synapseDeactivateButton } + + ); + } else if (groupId) { + adminToolsContainer = ( + + { synapseDeactivateButton } + + ); + } else if (synapseDeactivateButton) { + adminToolsContainer = ( + + { synapseDeactivateButton } + + ); + } + + if (pendingUpdateCount > 0) { + const Loader = sdk.getComponent("elements.Spinner"); + spinner = ; + } + + const displayName = user.name || user.displayname; + + let presenceState; + let presenceLastActiveAgo; + let presenceCurrentlyActive; + let statusMessage; + + if (user instanceof RoomMember) { + presenceState = user.user.presence; + presenceLastActiveAgo = user.user.lastActiveAgo; + presenceCurrentlyActive = user.user.currentlyActive; + + if (SettingsStore.isFeatureEnabled("feature_custom_status")) { + statusMessage = user.user._unstable_statusMessage; + } + } + + const enablePresenceByHsUrl = SdkConfig.get()["enable_presence_by_hs_url"]; + let showPresence = true; + if (enablePresenceByHsUrl && enablePresenceByHsUrl[cli.baseUrl] !== undefined) { + showPresence = enablePresenceByHsUrl[cli.baseUrl]; + } + + let presenceLabel = null; + if (showPresence) { + const PresenceLabel = sdk.getComponent('rooms.PresenceLabel'); + presenceLabel = ; + } + + let statusLabel = null; + if (statusMessage) { + statusLabel = { statusMessage }; + } + + let memberDetails = null; + + if (room && user.roomId) { // is in room + const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", ""); + const powerLevelUsersDefault = powerLevelEvent ? powerLevelEvent.getContent().users_default : 0; + + const PowerSelector = sdk.getComponent('elements.PowerSelector'); + memberDetails =
+
+ +
+ +
; + } + + const avatarUrl = user.getMxcAvatarUrl ? user.getMxcAvatarUrl() : user.avatarUrl; + let avatarElement; + if (avatarUrl) { + const httpUrl = cli.mxcUrlToHttp(avatarUrl, 800, 800); + avatarElement =
+ {_t("Profile +
; + } + + let closeButton; + if (onClose) { + closeButton = ; + } + + // undefined means yet to be loaded, null means failed to load, otherwise list of devices + const [devices, setDevices] = useState(undefined); + // Download device lists + useEffect(() => { + setDevices(undefined); + + let cancelled = false; + + async function _downloadDeviceList() { + try { + await cli.downloadKeys([user.userId], true); + const devices = await cli.getStoredDevicesForUser(user.userId); + + if (cancelled) { + // we got cancelled - presumably a different user now + return; + } + + _disambiguateDevices(devices); + setDevices(devices); + } catch (err) { + setDevices(null); + } + } + + _downloadDeviceList(); + + // Handle being unmounted + return () => { + cancelled = true; + }; + }, [cli, user.userId]); + + // Listen to changes + useEffect(() => { + let cancel = false; + const onDeviceVerificationChanged = (_userId, device) => { + if (_userId === user.userId) { + // no need to re-download the whole thing; just update our copy of the list. + + // Promise.resolve to handle transition from static result to promise; can be removed in future + Promise.resolve(cli.getStoredDevicesForUser(user.userId)).then((devices) => { + if (cancel) return; + setDevices(devices); + }); + } + }; + + cli.on("deviceVerificationChanged", onDeviceVerificationChanged); + // Handle being unmounted + return () => { + cancel = true; + cli.removeListener("deviceVerificationChanged", onDeviceVerificationChanged); + }; + }, [cli, user.userId]); + + let devicesSection; + const isRoomEncrypted = _enableDevices && room && cli.isRoomEncrypted(room.roomId); + if (isRoomEncrypted) { + devicesSection = ; + } else { + let text; + + if (!_enableDevices) { + text = _t("This client does not support end-to-end encryption."); + } else if (room) { + text = _t("Messages in this room are not end-to-end encrypted."); + } else { + // TODO what to render for GroupMember + } + + if (text) { + devicesSection = ( +
+

{ _t("Trust & Devices") }

+
+ { text } +
+
+ ); + } + } + + let e2eIcon; + if (isRoomEncrypted && devices) { + e2eIcon = ; + } + + return ( +
+ { closeButton } + { avatarElement } + +
+
+
+

+ { e2eIcon } + { displayName } +

+
+
+ { user.userId } +
+
+ {presenceLabel} + {statusLabel} +
+
+
+ + { memberDetails &&
+
+ { memberDetails } +
+
} + + + { devicesSection } + + { directChatsSection } + + + + { adminToolsContainer } + + { spinner } + +
+ ); +}); + +UserInfo.propTypes = { + user: PropTypes.oneOfType([ + PropTypes.instanceOf(User), + PropTypes.instanceOf(RoomMember), + GroupMember, + ]).isRequired, + group: PropTypes.instanceOf(Group), + groupId: PropTypes.string, + roomId: PropTypes.string, + + onClose: PropTypes.func, +}; + +export default UserInfo; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index b303b7a94d..03c9070db5 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -332,6 +332,7 @@ "Group & filter rooms by custom tags (refresh to apply changes)": "Group & filter rooms by custom tags (refresh to apply changes)", "Render simple counters in room header": "Render simple counters in room header", "Multiple integration managers": "Multiple integration managers", + "Use the new, consistent UserInfo panel for Room Members and Group Members": "Use the new, consistent UserInfo panel for Room Members and Group Members", "Use the new, faster, composer for writing messages": "Use the new, faster, composer for writing messages", "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", "Use compact timeline layout": "Use compact timeline layout", @@ -1203,6 +1204,11 @@ "This alias is already in use": "This alias is already in use", "Room directory": "Room directory", "And %(count)s more...|other": "And %(count)s more...", + "Trust & Devices": "Trust & Devices", + "Direct messages": "Direct messages", + "Failed to deactivate user": "Failed to deactivate user", + "This client does not support end-to-end encryption.": "This client does not support end-to-end encryption.", + "Messages in this room are not end-to-end encrypted.": "Messages in this room are not end-to-end encrypted.", "ex. @bob:example.com": "ex. @bob:example.com", "Add User": "Add User", "Matrix ID": "Matrix ID", diff --git a/src/settings/Settings.js b/src/settings/Settings.js index 3c33ae57fe..7470641359 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -120,6 +120,12 @@ export const SETTINGS = { supportedLevels: LEVELS_FEATURE, default: false, }, + "feature_user_info_panel": { + isFeature: true, + displayName: _td("Use the new, consistent UserInfo panel for Room Members and Group Members"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "useCiderComposer": { displayName: _td("Use the new, faster, composer for writing messages"), supportedLevels: LEVELS_ACCOUNT_SETTINGS, diff --git a/yarn.lock b/yarn.lock index ba7fea21ba..e40a1272cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -293,6 +293,11 @@ dependencies: "@types/yargs-parser" "*" +"@use-it/event-listener@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@use-it/event-listener/-/event-listener-0.1.3.tgz#a9920b2819d211cf55e68e830997546eec6886d3" + integrity sha512-UCHkLOVU+xj3/1R8jXz8GzDTowkzfIDPESOBlVC2ndgwUSBEqiFdwCoUEs2lcGhJOOiEdmWxF+T23C5+60eEew== + "@webassemblyjs/ast@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.8.5.tgz#51b1c5fe6576a34953bf4b253df9f0d490d9e359" @@ -2891,6 +2896,11 @@ eslint-plugin-flowtype@^2.30.0: dependencies: lodash "^4.17.10" +eslint-plugin-react-hooks@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-2.0.1.tgz#e898ec26a0a335af6f7b0ad1f0bedda7143ed756" + integrity sha512-xir+3KHKo86AasxlCV8AHRtIZPHljqCRRUYgASkbatmt0fad4+5GgC7zkT7o/06hdKM6MIwp8giHVXqBPaarHQ== + eslint-plugin-react@^7.7.0: version "7.14.3" resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.14.3.tgz#911030dd7e98ba49e1b2208599571846a66bdf13"