From 502b8051642ecefa5cf789e9a91c46e31726d2aa Mon Sep 17 00:00:00 2001 From: Kerry Date: Tue, 25 Jan 2022 10:40:02 +0100 Subject: [PATCH] Add customisation point for mxid display (#7595) * add wrapping component for hiding UI Signed-off-by: Kerry Archibald * add Setting Signed-off-by: Kerry Archibald * apply setting to profile settings, user menu, invite dialog, userinfo Signed-off-by: Kerry Archibald * hide mxids in user autocomplete * remove mxids from title in memeber list and timeline Signed-off-by: Kerry Archibald * hide mxid in ConfirmUserActionDialog Signed-off-by: Kerry Archibald * use name in power level event message when displayMxids is falsy Signed-off-by: Kerry Archibald * add customisation point for mxid display Signed-off-by: Kerry Archibald * use userid customisation Signed-off-by: Kerry Archibald * use customisation in sender profile Signed-off-by: Kerry Archibald * hide profile settings mxid if falsy Signed-off-by: Kerry Archibald * rename and move to components Signed-off-by: Kerry Archibald * remove change to UIFeature.ts Signed-off-by: Kerry Archibald * improvements from pr Signed-off-by: Kerry Archibald * lint fix Signed-off-by: Kerry Archibald --- res/css/views/settings/_ProfileSettings.scss | 4 +- src/TextForEvent.tsx | 8 ++- src/autocomplete/UserProvider.tsx | 6 +- src/components/structures/UserMenu.tsx | 4 +- src/components/views/avatars/MemberAvatar.tsx | 6 +- .../dialogs/ConfirmSpaceUserActionDialog.tsx | 1 + .../views/dialogs/ConfirmUserActionDialog.tsx | 8 ++- src/components/views/dialogs/InviteDialog.tsx | 7 ++- .../views/messages/SenderProfile.tsx | 5 +- src/components/views/right_panel/UserInfo.tsx | 8 ++- src/components/views/rooms/MemberTile.tsx | 7 ++- src/components/views/rooms/RoomPreviewBar.tsx | 4 +- .../views/settings/ProfileSettings.tsx | 11 +++- src/customisations/UserIdentifier.ts | 41 +++++++++++++ test/TextForEvent-test.ts | 57 +++++++++++++++++-- 15 files changed, 154 insertions(+), 23 deletions(-) create mode 100644 src/customisations/UserIdentifier.ts diff --git a/res/css/views/settings/_ProfileSettings.scss b/res/css/views/settings/_ProfileSettings.scss index 46c89347a8..09509f7d9e 100644 --- a/res/css/views/settings/_ProfileSettings.scss +++ b/res/css/views/settings/_ProfileSettings.scss @@ -44,8 +44,8 @@ limitations under the License. margin-top: 0; } -.mx_ProfileSettings_hostingSignup { - margin-left: 20px; +.mx_ProfileSettings_userId { + margin-right: $spacing-20; } .mx_ProfileSettings_avatarUpload { diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index 83ecb58f4b..9f6a3a76f5 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -44,6 +44,7 @@ import { MatrixClientPeg } from "./MatrixClientPeg"; import { ROOM_SECURITY_TAB } from "./components/views/dialogs/RoomSettingsDialog"; import AccessibleButton from './components/views/elements/AccessibleButton'; import RightPanelStore from './stores/right-panel/RightPanelStore'; +import UserIdentifierCustomisations from './customisations/UserIdentifier'; export function getSenderName(event: MatrixEvent): string { return event.sender?.name ?? event.getSender() ?? _t("Someone"); @@ -499,6 +500,7 @@ function textForPowerEvent(event: MatrixEvent): () => string | null { if (users.indexOf(userId) === -1) users.push(userId); }, ); + const diffs = []; users.forEach((userId) => { // Previous power level @@ -513,18 +515,20 @@ function textForPowerEvent(event: MatrixEvent): () => string | null { } if (from === previousUserDefault && to === currentUserDefault) { return; } if (to !== from) { - diffs.push({ userId, from, to }); + const name = UserIdentifierCustomisations.getDisplayUserIdentifier(userId, { roomId: event.getRoomId() }); + diffs.push({ userId, name, from, to }); } }); if (!diffs.length) { return null; } + // XXX: This is also surely broken for i18n return () => _t('%(senderName)s changed the power level of %(powerLevelDiffText)s.', { senderName, powerLevelDiffText: diffs.map(diff => _t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', { - userId: diff.userId, + userId: diff.name, fromPowerLevel: Roles.textualPowerLevel(diff.from, previousUserDefault), toPowerLevel: Roles.textualPowerLevel(diff.to, currentUserDefault), }), diff --git a/src/autocomplete/UserProvider.tsx b/src/autocomplete/UserProvider.tsx index f83f534d84..2c5241c519 100644 --- a/src/autocomplete/UserProvider.tsx +++ b/src/autocomplete/UserProvider.tsx @@ -34,6 +34,7 @@ import { makeUserPermalink } from "../utils/permalinks/Permalinks"; import { ICompletion, ISelectionRange } from "./Autocompleter"; import MemberAvatar from '../components/views/avatars/MemberAvatar'; import { TimelineRenderingType } from '../contexts/RoomContext'; +import UserIdentifierCustomisations from '../customisations/UserIdentifier'; const USER_REGEX = /\B@\S*/g; @@ -127,6 +128,9 @@ export default class UserProvider extends AutocompleteProvider { // Don't include the '@' in our search query - it's only used as a way to trigger completion const query = fullMatch.startsWith('@') ? fullMatch.substring(1) : fullMatch; completions = this.matcher.match(query, limit).map((user) => { + const description = UserIdentifierCustomisations.getDisplayUserIdentifier( + user.userId, { roomId: this.room.roomId, withDisplayName: true }, + ); const displayName = (user.name || user.userId || ''); return { // Length of completion should equal length of text in decorator. draft-js @@ -137,7 +141,7 @@ export default class UserProvider extends AutocompleteProvider { suffix: (selection.beginning && range.start === 0) ? ': ' : ' ', href: makeUserPermalink(user.userId), component: ( - + ), diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 00233d70e5..0b3e9b9f84 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -59,6 +59,7 @@ import { UPDATE_SELECTED_SPACE } from "../../stores/spaces"; import { replaceableComponent } from "../../utils/replaceableComponent"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import { SettingUpdatedPayload } from "../../dispatcher/payloads/SettingUpdatedPayload"; +import UserIdentifierCustomisations from "../../customisations/UserIdentifier"; const CustomStatusSection = () => { const cli = useContext(MatrixClientContext); @@ -499,7 +500,8 @@ export default class UserMenu extends React.Component { { OwnProfileStore.instance.displayName } - { MatrixClientPeg.get().getUserId() } + { UserIdentifierCustomisations.getDisplayUserIdentifier( + MatrixClientPeg.get().getUserId(), { withDisplayName: true }) } diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx index dfd526b1a2..23bdada593 100644 --- a/src/components/views/avatars/MemberAvatar.tsx +++ b/src/components/views/avatars/MemberAvatar.tsx @@ -26,6 +26,7 @@ import BaseAvatar from "./BaseAvatar"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { mediaFromMxc } from "../../../customisations/Media"; import { CardContext } from '../right_panel/BaseCard'; +import UserIdentifierCustomisations from '../../../customisations/UserIdentifier'; interface IProps extends Omit, "name" | "idName" | "url"> { member: RoomMember; @@ -70,6 +71,9 @@ export default class MemberAvatar extends React.Component { private static getState(props: IProps): IState { if (props.member?.name) { let imageUrl = null; + const userTitle = UserIdentifierCustomisations.getDisplayUserIdentifier( + props.member.userId, { roomId: props.member?.roomId }, + ); if (props.member.getMxcAvatarUrl()) { imageUrl = mediaFromMxc(props.member.getMxcAvatarUrl()).getThumbnailOfSourceHttp( props.width, @@ -79,7 +83,7 @@ export default class MemberAvatar extends React.Component { } return { name: props.member.name, - title: props.title || props.member.userId, + title: props.title || userTitle, imageUrl: imageUrl, }; } else if (props.fallbackUserId) { diff --git a/src/components/views/dialogs/ConfirmSpaceUserActionDialog.tsx b/src/components/views/dialogs/ConfirmSpaceUserActionDialog.tsx index 02ad94302c..ff76202330 100644 --- a/src/components/views/dialogs/ConfirmSpaceUserActionDialog.tsx +++ b/src/components/views/dialogs/ConfirmSpaceUserActionDialog.tsx @@ -67,6 +67,7 @@ const ConfirmSpaceUserActionDialog: React.FC = ({ onFinished(success, reason, roomsToLeave); }} className="mx_ConfirmSpaceUserActionDialog" + roomId={space.roomId} > { warning } void; } @@ -126,6 +128,10 @@ export default class ConfirmUserActionDialog extends React.Component; } + const displayUserIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier( + userId, { roomId: this.props.roomId, withDisplayName: true }, + ); + return (
{ name }
-
{ userId }
+
{ displayUserIdentifier }
{ reasonBox } diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index f352393c5b..e2ad08162a 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -72,6 +72,7 @@ import BaseDialog from "./BaseDialog"; import DialPadBackspaceButton from "../elements/DialPadBackspaceButton"; import SpaceStore from "../../../stores/spaces/SpaceStore"; import CallHandler from "../../../CallHandler"; +import UserIdentifierCustomisations from '../../../customisations/UserIdentifier'; // we have a number of types defined from the Matrix spec which can't reasonably be altered here. /* eslint-disable camelcase */ @@ -329,9 +330,13 @@ class DMRoomTile extends React.PureComponent { ); + const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier( + this.props.member.userId, { withDisplayName: true }, + ); + const caption = (this.props.member as ThreepidMember).isEmail ? _t("Invite by email") - : this.highlightName(this.props.member.userId); + : this.highlightName(userIdentifier); return (
diff --git a/src/components/views/messages/SenderProfile.tsx b/src/components/views/messages/SenderProfile.tsx index d4b74db6d0..79cbb7d3aa 100644 --- a/src/components/views/messages/SenderProfile.tsx +++ b/src/components/views/messages/SenderProfile.tsx @@ -23,6 +23,7 @@ import FlairStore from '../../../stores/FlairStore'; import { getUserNameColorClass } from '../../../utils/FormattingUtils'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import UserIdentifier from '../../../customisations/UserIdentifier'; interface IProps { mxEvent: MatrixEvent; @@ -116,7 +117,9 @@ export default class SenderProfile extends React.Component { if (disambiguate) { mxidElement = ( - { mxid } + { UserIdentifier.getDisplayUserIdentifier( + mxid, { withDisplayName: true, roomId: mxEvent.getRoomId() }, + ) } ); } diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 5338d91eff..a79e382401 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -77,6 +77,7 @@ import { TimelineRenderingType } from "../../../contexts/RoomContext"; import RightPanelStore from '../../../stores/right-panel/RightPanelStore'; import { IRightPanelCardState } from '../../../stores/right-panel/RightPanelStoreIPanelState'; import { useUserStatusMessage } from "../../../hooks/useUserStatusMessage"; +import UserIdentifierCustomisations from '../../../customisations/UserIdentifier'; export interface IDevice { deviceId: string; @@ -1517,7 +1518,8 @@ export type Member = User | RoomMember | GroupMember; const UserInfoHeader: React.FC<{ member: Member; e2eStatus: E2EStatus; -}> = ({ member, e2eStatus }) => { + roomId: string; +}> = ({ member, e2eStatus, roomId }) => { const cli = useContext(MatrixClientContext); const statusMessage = useUserStatusMessage(member); @@ -1604,7 +1606,7 @@ const UserInfoHeader: React.FC<{
-
{ member.userId }
+
{ UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, { roomId, withDisplayName: true }) }
{ presenceLabel } { statusLabel } @@ -1708,7 +1710,7 @@ const UserInfo: React.FC = ({ const header = { scopeHeader } - + ; return { private getPowerLabel(): string { return _t("%(userName)s (power %(powerLevelNumber)s)", { - userName: this.props.member.userId, + userName: UserIdentifierCustomisations.getDisplayUserIdentifier( + this.props.member.userId, { roomId: this.props.member.roomId }, + ), powerLevelNumber: this.props.member.powerLevel, - }); + }).trim(); } render() { diff --git a/src/components/views/rooms/RoomPreviewBar.tsx b/src/components/views/rooms/RoomPreviewBar.tsx index f0a4df2473..caabe36320 100644 --- a/src/components/views/rooms/RoomPreviewBar.tsx +++ b/src/components/views/rooms/RoomPreviewBar.tsx @@ -34,9 +34,9 @@ import InviteReason from "../elements/InviteReason"; import { IOOBData } from "../../../stores/ThreepidInviteStore"; import Spinner from "../elements/Spinner"; import AccessibleButton from "../elements/AccessibleButton"; -import { UIFeature } from "../../../settings/UIFeature"; -import SettingsStore from "../../../settings/SettingsStore"; import RoomAvatar from "../avatars/RoomAvatar"; +import SettingsStore from "../../../settings/SettingsStore"; +import { UIFeature } from "../../../settings/UIFeature"; const MemberEventHtmlReasonField = "io.element.html_reason"; diff --git a/src/components/views/settings/ProfileSettings.tsx b/src/components/views/settings/ProfileSettings.tsx index b3e6eb3131..2088ec3abe 100644 --- a/src/components/views/settings/ProfileSettings.tsx +++ b/src/components/views/settings/ProfileSettings.tsx @@ -29,6 +29,7 @@ import { mediaFromMxc } from "../../../customisations/Media"; import AccessibleButton from '../elements/AccessibleButton'; import AvatarSetting from './AvatarSetting'; import ExternalLink from '../elements/ExternalLink'; +import UserIdentifierCustomisations from '../../../customisations/UserIdentifier'; interface IState { userId?: string; @@ -162,7 +163,7 @@ export default class ProfileSettings extends React.Component<{}, IState> { const hostingSignupLink = getHostingLink('user-settings'); let hostingSignup = null; if (hostingSignupLink) { - hostingSignup = + hostingSignup = { _t( "Upgrade to your own domain", {}, { @@ -174,6 +175,10 @@ export default class ProfileSettings extends React.Component<{}, IState> { ; } + const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier( + this.state.userId, { withDisplayName: true }, + ); + return (
{ onChange={this.onDisplayNameChanged} />

- { this.state.userId } + { userIdentifier && + { userIdentifier } + } { hostingSignup }

diff --git a/src/customisations/UserIdentifier.ts b/src/customisations/UserIdentifier.ts new file mode 100644 index 0000000000..3eacc36684 --- /dev/null +++ b/src/customisations/UserIdentifier.ts @@ -0,0 +1,41 @@ +/* +Copyright 2022 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. +*/ + +/** + * Customise display of the user identifier + * hide userId for guests, display 3pid + * + * Set withDisplayName to true when user identifier will be displayed alongside user name + */ +function getDisplayUserIdentifier( + userId: string, + { roomId, withDisplayName }: { roomId?: string, withDisplayName?: boolean }, +): string | null { + return userId; +} + +// This interface summarises all available customisation points and also marks +// them all as optional. This allows customisers to only define and export the +// customisations they need while still maintaining type safety. +export interface IUserIdentifierCustomisations { + getDisplayUserIdentifier?: typeof getDisplayUserIdentifier; +} + +// A real customisation module will define and export one or more of the +// customisation points that make up `IUserIdentifierCustomisations`. +export default { + getDisplayUserIdentifier, +} as IUserIdentifierCustomisations; diff --git a/test/TextForEvent-test.ts b/test/TextForEvent-test.ts index ed5ea82244..650c010e32 100644 --- a/test/TextForEvent-test.ts +++ b/test/TextForEvent-test.ts @@ -5,7 +5,14 @@ import renderer from 'react-test-renderer'; import { getSenderName, textForEvent } from "../src/TextForEvent"; import SettingsStore from "../src/settings/SettingsStore"; -import { SettingLevel } from "../src/settings/SettingLevel"; +import { createTestClient } from './test-utils'; +import { MatrixClientPeg } from '../src/MatrixClientPeg'; +import UserIdentifierCustomisations from '../src/customisations/UserIdentifier'; + +jest.mock("../src/settings/SettingsStore"); +jest.mock('../src/customisations/UserIdentifier', () => ({ + getDisplayUserIdentifier: jest.fn().mockImplementation(userId => userId), +})); function mockPinnedEvent( pinnedMessageIds?: string[], @@ -67,7 +74,10 @@ describe('TextForEvent', () => { }); describe("TextForPinnedEvent", () => { - SettingsStore.setValue("feature_pinning", null, SettingLevel.DEVICE, true); + beforeAll(() => { + // enable feature_pinning setting + (SettingsStore.getValue as jest.Mock).mockImplementation(feature => feature === 'feature_pinning'); + }); it("mentions message when a single message was pinned, with no previously pinned messages", () => { const event = mockPinnedEvent(['message-1']); @@ -141,6 +151,11 @@ describe('TextForEvent', () => { }); describe("textForPowerEvent()", () => { + let mockClient; + const mockRoom = { + getMember: jest.fn(), + }; + const userA = { id: '@a', name: 'Alice', @@ -175,7 +190,23 @@ describe('TextForEvent', () => { }, }); - it("returns empty string when no users have changed power level", () => { + beforeAll(() => { + mockClient = createTestClient(); + MatrixClientPeg.get = () => mockClient; + mockClient.getRoom.mockClear().mockReturnValue(mockRoom); + mockRoom.getMember.mockClear().mockImplementation( + userId => [userA, userB, userC].find(u => u.id === userId), + ); + (SettingsStore.getValue as jest.Mock).mockReturnValue(true); + }); + + beforeEach(() => { + (UserIdentifierCustomisations.getDisplayUserIdentifier as jest.Mock) + .mockClear() + .mockImplementation(userId => userId); + }); + + it("returns falsy when no users have changed power level", () => { const event = mockPowerEvent({ users: { [userA.id]: 100, @@ -187,7 +218,7 @@ describe('TextForEvent', () => { expect(textForEvent(event)).toBeFalsy(); }); - it("returns empty string when users power levels have been changed by default settings", () => { + it("returns false when users power levels have been changed by default settings", () => { const event = mockPowerEvent({ usersDefault: 100, prevDefault: 50, @@ -257,6 +288,24 @@ describe('TextForEvent', () => { "@a changed the power level of @b from Moderator to Admin, @c from Custom (101) to Moderator."; expect(textForEvent(event)).toEqual(expectedText); }); + + it("uses userIdentifier customisation", () => { + (UserIdentifierCustomisations.getDisplayUserIdentifier as jest.Mock) + .mockImplementation(userId => 'customised ' + userId); + const event = mockPowerEvent({ + users: { + [userB.id]: 100, + }, + prevUsers: { + [userB.id]: 50, + }, + }); + // uses customised user id + const expectedText = "@a changed the power level of customised @b from Moderator to Admin."; + expect(textForEvent(event)).toEqual(expectedText); + expect(UserIdentifierCustomisations.getDisplayUserIdentifier) + .toHaveBeenCalledWith(userB.id, { roomId: event.getRoomId() }); + }); }); describe("textForCanonicalAliasEvent()", () => {