diff --git a/res/css/_components.scss b/res/css/_components.scss index 389be11c602..1a3e12c4343 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -187,6 +187,7 @@ @import "./views/right_panel/_PinnedMessagesCard.scss"; @import "./views/right_panel/_RoomSummaryCard.scss"; @import "./views/right_panel/_UserInfo.scss"; +@import "./views/right_panel/_UserInfoSharedRooms.scss"; @import "./views/right_panel/_VerificationPanel.scss"; @import "./views/right_panel/_WidgetCard.scss"; @import "./views/room_settings/_AliasSettings.scss"; diff --git a/res/css/views/right_panel/_BaseCard.scss b/res/css/views/right_panel/_BaseCard.scss index 9a5a59bda8f..2b9fa5dc3d2 100644 --- a/res/css/views/right_panel/_BaseCard.scss +++ b/res/css/views/right_panel/_BaseCard.scss @@ -14,6 +14,46 @@ See the License for the specific language governing permissions and limitations under the License. */ + +.mx_BaseCard_Button { + padding: 10px 38px 10px 12px; + margin: 0; + position: relative; + font-size: $font-13px; + height: 20px; + line-height: 20px; + border-radius: 8px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + &:hover { + background-color: rgba(141, 151, 165, 0.1); + } + + &::after { + content: ''; + position: absolute; + top: 10px; + right: 6px; + height: 20px; + width: 20px; + mask-repeat: no-repeat; + mask-position: center; + background-color: $icon-button-color; + transform: rotate(270deg); + mask-size: 20px; + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); + } + + &.mx_AccessibleButton_disabled { + padding-right: 12px; + &::after { + content: unset; + } + } +} + .mx_BaseCard { padding: 0 8px; overflow: hidden; @@ -97,45 +137,6 @@ limitations under the License. font-size: $font-12px; font-weight: 500; } - - .mx_BaseCard_Button { - padding: 10px 38px 10px 12px; - margin: 0; - position: relative; - font-size: $font-13px; - height: 20px; - line-height: 20px; - border-radius: 8px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - - &:hover { - background-color: rgba(141, 151, 165, 0.1); - } - - &::after { - content: ''; - position: absolute; - top: 10px; - right: 6px; - height: 20px; - width: 20px; - mask-repeat: no-repeat; - mask-position: center; - background-color: $icon-button-color; - transform: rotate(270deg); - mask-size: 20px; - mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); - } - - &.mx_AccessibleButton_disabled { - padding-right: 12px; - &::after { - content: unset; - } - } - } } .mx_BaseCard_footer { diff --git a/res/css/views/right_panel/_UserInfoSharedRooms.scss b/res/css/views/right_panel/_UserInfoSharedRooms.scss new file mode 100644 index 00000000000..28b51647cec --- /dev/null +++ b/res/css/views/right_panel/_UserInfoSharedRooms.scss @@ -0,0 +1,20 @@ +/* +Copyright 2021 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_SharedRoomList { + padding: 10px; + margin-top: 10px; +} diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx index 63027ab627f..a904beb649e 100644 --- a/src/components/structures/RightPanel.tsx +++ b/src/components/structures/RightPanel.tsx @@ -34,6 +34,7 @@ import MatrixClientContext from "../../contexts/MatrixClientContext"; import { Action } from "../../dispatcher/actions"; import RoomSummaryCard from "../views/right_panel/RoomSummaryCard"; import WidgetCard from "../views/right_panel/WidgetCard"; +import SharedRoomList from "../views/right_panel/SharedRoomsList"; import { replaceableComponent } from "../../utils/replaceableComponent"; import SettingsStore from "../../settings/SettingsStore"; import { ActionPayload } from "../../dispatcher/payloads"; @@ -67,6 +68,7 @@ interface IState { groupRoomId?: string; groupId?: string; event: MatrixEvent; + userId?: string; } @replaceableComponent("structures.RightPanel") @@ -201,6 +203,11 @@ export default class RightPanel extends React.Component { space: payload.space, }); } + if (payload.action === Action.SetRightPanelPhase && payload.phase === RightPanelPhases.SharedRoomsList) { + this.setState({ + userId: payload.userId, + }); + } }; private onClose = () => { @@ -234,6 +241,8 @@ export default class RightPanel extends React.Component { let panel =
; const roomId = this.props.room ? this.props.room.roomId : undefined; + console.log(this.state, this.props); + switch (this.state.phase) { case RightPanelPhases.RoomMemberList: if (roomId) { @@ -258,6 +267,10 @@ export default class RightPanel extends React.Component { panel = ; break; + case RightPanelPhases.SharedRoomsList: + panel = ; + break; + case RightPanelPhases.RoomMemberInfo: case RightPanelPhases.SpaceMemberInfo: case RightPanelPhases.EncryptionPanel: diff --git a/src/components/views/elements/UserInfoRoomTile.tsx b/src/components/views/elements/UserInfoRoomTile.tsx new file mode 100644 index 00000000000..c4f73c9a066 --- /dev/null +++ b/src/components/views/elements/UserInfoRoomTile.tsx @@ -0,0 +1,88 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 New Vector Ltd +Copyright 2018 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +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 { Room } from "matrix-js-sdk/src/models/room"; +import classNames from "classnames"; + +import AccessibleButton from "../../views/elements/AccessibleButton"; +import ActiveRoomObserver from "../../../ActiveRoomObserver"; +import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; +import dis from '../../../dispatcher/dispatcher'; +import { Key } from "../../../Keyboard"; + +interface IProps { + room: Room; +} + +interface IState { + selected: boolean; + messagePreview?: string; +} + +export default class UserInfoRoomTile extends React.PureComponent { + constructor(props: IProps) { + super(props); + + this.state = { + selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId, + }; + } + + private onTileClick = (ev: React.KeyboardEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + dis.dispatch({ + action: 'view_room', + show_room_tile: true, // make sure the room is visible in the list + room_id: this.props.room.roomId, + clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)), + }); + }; + + public render(): React.ReactElement { + const classes = classNames("mx_RoomTile", { + "mx_RoomTile_selected": this.state.selected, + }); + + const roomAvatar = ; + + const name = this.props.room.name; + const nameContainer = ( +
+
+ {name} +
+
+ ); + + return ( + + { roomAvatar } + { nameContainer } + + ); + } +} diff --git a/src/components/views/right_panel/SharedRoomsList.tsx b/src/components/views/right_panel/SharedRoomsList.tsx new file mode 100644 index 00000000000..087d31f72d9 --- /dev/null +++ b/src/components/views/right_panel/SharedRoomsList.tsx @@ -0,0 +1,90 @@ +/* +Copyright 2021 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 { RecentAlgorithm } from '../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm'; +import { DefaultTagID } from '../../../stores/room-list/models'; +import BaseCard from './BaseCard'; +import UserInfoSharedRooms from './UserInfoSharedRooms'; +import { Room } from "matrix-js-sdk/src/models/room"; +import Spinner from "../elements/Spinner"; +import TruncatedList from "../elements/TruncatedList"; +import UserInfoRoomTile from '../elements/UserInfoRoomTile'; +import { _t } from "../../../languageHandler"; + +interface IProps { + onClose: () => void; + userId: string; +} + +interface IState { + rooms?: Room[]; + error: boolean; +} + +const TRUNCATE_AT = 30; + +/* + * Component which shows the global notification list using a TimelinePanel + */ +export default class SharedRoomList extends React.PureComponent { + algorithm: RecentAlgorithm; + constructor(props: IProps) { + super(props); + this.state = { + error: false, + }; + this.algorithm = new RecentAlgorithm(); + } + + async componentDidMount() { + try { + const rooms = await UserInfoSharedRooms.getSharedRoomsForUser(this.props.userId); + const sortedRooms = await this.algorithm.sortRooms(rooms, DefaultTagID.Untagged); + this.setState({ rooms: sortedRooms }); + } catch (ex) { + console.log("Error fetching shared rooms for user", ex); + this.setState({ error: true }); + } + } + + private makeRoomTiles() { + return this.state.rooms.map(r => ); + } + + private renderContent() { + if (this.state.error) { + // In theory this shouldn't happen, because the button for this view + // validates that the client can fetch shared rooms for this user. + return

{ _t("Could not fetch shared rooms for user.") }

; + } + if (!this.state.rooms) { + return ; + } + return + { this.makeRoomTiles() } + ; + } + + render() { + return + { this.renderContent() } + ; + } +} diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index e9d80d49c52..280c2492438 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -48,6 +48,7 @@ import EncryptionPanel from "./EncryptionPanel"; import { useAsyncMemo } from '../../../hooks/useAsyncMemo'; import { legacyVerifyUser, verifyDevice, verifyUser } from '../../../verification'; import { Action } from "../../../dispatcher/actions"; +import UserInfoSharedRooms from "./UserInfoSharedRooms"; import { UserTab } from "../dialogs/UserSettingsDialog"; import { useIsEncrypted } from "../../../hooks/useIsEncrypted"; import BaseCard from "./BaseCard"; @@ -1342,6 +1343,8 @@ const BasicUserInfo: React.FC<{ const canVerify = cryptoEnabled && homeserverSupportsCrossSigning && !userVerified && !isMe && devices && devices.length > 0; + const isSharedRoomsFeatureEnabled = SettingsStore.getValue("feature_show_shared_rooms"); + const setUpdating = (updating) => { setPendingUpdateCount(count => count + (updating ? 1 : -1)); }; @@ -1397,10 +1400,19 @@ const BasicUserInfo: React.FC<{
); + let sharedRooms = null; + if (isSharedRoomsFeatureEnabled && !isMe) { + sharedRooms =
+

{ _t("Shared Rooms") }

+ +
; + } + return { memberDetails } { securitySection } + { sharedRooms } { + public static async getSharedRoomsForUser(userId: string): Promise { + const client = MatrixClientPeg.get(); + const roomIds = await client._unstable_getSharedRooms(userId); + return roomIds.map(roomId => client.getRoom(roomId)).filter(room => { + return room && !room.currentState.getStateEvents(EventType.RoomTombstone, ""); + }); + } + + constructor(props: IProps) { + super(props); + this.state = { + error: false, + }; + } + + componentDidMount() { + return this.componentDidUpdate(); + } + + async componentDidUpdate(prevProps?: IProps) { + const userId = this.props.userId; + if (prevProps?.userId === userId) return; // Nothing to update. + + // Reset because this is a new user + this.setState({ + error: false, + sharedRoomCount: undefined, + }); + + try { + const sharedRooms = await UserInfoSharedRooms.getSharedRoomsForUser(userId); + if (this.props.userId !== userId) return; // stale + this.setState({ + sharedRoomCount: sharedRooms.length, + }); + } catch (ex) { + console.log(`Failed to get shared rooms for ${userId}`, ex); + this.setState({ error: true }); + } + } + + private onShowClicked = () => { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.SharedRoomsList, + userId: this.props.userId, + }); + }; + + render(): React.ReactNode { + const { sharedRoomCount } = this.state; + + if (this.state.error) { + return

{ _t("There was an error fetching shared rooms with this user.") }

; + } else if (sharedRoomCount === 0) { + return

{ _t("You share no rooms in common with this user.") }

; + } else if (typeof sharedRoomCount === "number") { + return + { _t("%(count)s rooms in common", { count: sharedRoomCount }) } + ; + } else { + return ; + } + } +} diff --git a/src/components/views/rooms/RoomPreviewBar.js b/src/components/views/rooms/RoomPreviewBar.js index 155b7ffe639..1c0e61168be 100644 --- a/src/components/views/rooms/RoomPreviewBar.js +++ b/src/components/views/rooms/RoomPreviewBar.js @@ -23,6 +23,8 @@ import classNames from 'classnames'; import { _t } from '../../../languageHandler'; import SdkConfig from "../../../SdkConfig"; import IdentityAuthClient from '../../../IdentityAuthClient'; +import SettingsStore from '../../../settings/SettingsStore'; +import UserInfoSharedRooms from '../right_panel/UserInfoSharedRooms'; import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { replaceableComponent } from "../../../utils/replaceableComponent"; @@ -311,6 +313,7 @@ export default class RoomPreviewBar extends React.Component { let secondaryActionHandler; let secondaryActionLabel; let footer; + let extraContext; const extraComponents = []; const messageCase = this._getMessageCase(); @@ -501,6 +504,11 @@ export default class RoomPreviewBar extends React.Component { secondaryActionLabel = _t("Reject"); secondaryActionHandler = this.props.onRejectClick; + if (SettingsStore.getValue("feature_show_shared_rooms")) { + // TODO: Fix this + extraContext = ; + } + if (this.props.onRejectAndIgnoreClick) { extraComponents.push( @@ -508,6 +516,7 @@ export default class RoomPreviewBar extends React.Component { , ); } + break; } case MessageCase.ViewingRoom: { @@ -587,6 +596,7 @@ export default class RoomPreviewBar extends React.Component {
{ titleElement } { subTitleElements } + { extraContext }
{ reasonElement }
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index bbf69544353..879995f04c4 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -818,6 +818,7 @@ "Show message previews for reactions in all rooms": "Show message previews for reactions in all rooms", "Offline encrypted messaging using dehydrated devices": "Offline encrypted messaging using dehydrated devices", "Enable advanced debugging for the room list": "Enable advanced debugging for the room list", + "Show rooms in common with another user in the member info panel": "Show rooms in common with another user in the member info panel", "Show info about bridges in room settings": "Show info about bridges in room settings", "Font size": "Font size", "Use custom size": "Use custom size", @@ -1772,6 +1773,7 @@ "Show files": "Show files", "Share room": "Share room", "Room settings": "Room settings", + "Could not fetch shared rooms for user.": "Could not fetch shared rooms for user.", "Trusted": "Trusted", "Not trusted": "Not trusted", "%(count)s verified sessions|other": "%(count)s verified sessions", @@ -1826,6 +1828,11 @@ "This client does not support end-to-end encryption.": "This client does not support end-to-end encryption.", "Edit devices": "Edit devices", "Security": "Security", + "Shared Rooms": "Shared Rooms", + "There was an error fetching shared rooms with this user.": "There was an error fetching shared rooms with this user.", + "You share no rooms in common with this user.": "You share no rooms in common with this user.", + "%(count)s rooms in common|other": "%(count)s rooms in common", + "%(count)s rooms in common|one": "%(count)s room in common", "The session you are trying to verify doesn't support scanning a QR code or emoji verification, which is what %(brand)s supports. Try with a different client.": "The session you are trying to verify doesn't support scanning a QR code or emoji verification, which is what %(brand)s supports. Try with a different client.", "Scan this unique code": "Scan this unique code", "Compare unique emoji": "Compare unique emoji", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 1751eddb2cb..c9c67eb245a 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -307,6 +307,12 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: [SettingLevel.DEVICE], default: false, }, + "feature_show_shared_rooms": { + supportedLevels: LEVELS_FEATURE, + displayName: _td('Show rooms in common with another user in the member info panel'), + default: false, + isFeature: true, + }, "mjolnirRooms": { supportedLevels: [SettingLevel.ACCOUNT], default: [], diff --git a/src/stores/RightPanelStorePhases.ts b/src/stores/RightPanelStorePhases.ts index d62f6c61103..9920045ac76 100644 --- a/src/stores/RightPanelStorePhases.ts +++ b/src/stores/RightPanelStorePhases.ts @@ -37,6 +37,9 @@ export enum RightPanelPhases { SpaceMemberList = "SpaceMemberList", SpaceMemberInfo = "SpaceMemberInfo", Space3pidMemberInfo = "Space3pidMemberInfo", + + // Shared rooms + SharedRoomsList = 'SharedRoomsList' } // These are the phases that are safe to persist (the ones that don't require additional