From a54f2ff8780eb940e9488fff91379adc81605b18 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Fri, 1 Sep 2023 04:16:24 -0600 Subject: [PATCH] Render custom images in reactions (#11087) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add support for rendering custom emojis in reactions Signed-off-by: Sumner Evans * Include custom reaction short names in tooltips Signed-off-by: Sumner Evans * Use custom reaction shortcode for accessibility This uses the shortcode in the following places: * The aria-label of the reaction buttons * The alt text for the reaction image Signed-off-by: Sumner Evans * Remove explicit instantiation of `customReactionName` variable and add types Co-authored-by: Šimon Brandner * Put custom reaction images behind a labs flag Signed-off-by: Sumner Evans * Use UnstableValue for finding the shortcode Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> Signed-off-by: Sumner Evans * Move calculation of whether to render custom reaction images up to ReactionRow Signed-off-by: Sumner Evans * Make alt text more friendly when custom reaction doesn't have shortcode Signed-off-by: Sumner Evans * Add test for ReactionsRowButton Signed-off-by: Sumner Evans * Apply suggestions from code review Co-authored-by: Šimon Brandner * Don't use Optional Signed-off-by: Sumner Evans * Fix ReactionsRowButton test Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> Signed-off-by: Sumner Evans --------- Signed-off-by: Sumner Evans Co-authored-by: Tulir Asokan Co-authored-by: Šimon Brandner Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- .../views/messages/_ReactionsRowButton.pcss | 1 + .../views/messages/ReactionsRow.tsx | 6 + .../views/messages/ReactionsRowButton.tsx | 41 +++++- .../messages/ReactionsRowButtonTooltip.tsx | 10 +- src/i18n/strings/en_EN.json | 3 + src/settings/Settings.tsx | 8 ++ .../messages/ReactionsRowButton-test.tsx | 119 ++++++++++++++++++ .../ReactionsRowButton-test.tsx.snap | 101 +++++++++++++++ 8 files changed, 283 insertions(+), 6 deletions(-) create mode 100644 test/components/views/messages/ReactionsRowButton-test.tsx create mode 100644 test/components/views/messages/__snapshots__/ReactionsRowButton-test.tsx.snap diff --git a/res/css/views/messages/_ReactionsRowButton.pcss b/res/css/views/messages/_ReactionsRowButton.pcss index d27cb4f188..26d2d25fae 100644 --- a/res/css/views/messages/_ReactionsRowButton.pcss +++ b/res/css/views/messages/_ReactionsRowButton.pcss @@ -22,6 +22,7 @@ limitations under the License. border-radius: 10px; background-color: $secondary-hairline-color; user-select: none; + align-items: center; &:hover { border-color: $quinary-content; diff --git a/src/components/views/messages/ReactionsRow.tsx b/src/components/views/messages/ReactionsRow.tsx index 6e8e3d4589..3344c835cf 100644 --- a/src/components/views/messages/ReactionsRow.tsx +++ b/src/components/views/messages/ReactionsRow.tsx @@ -18,6 +18,7 @@ import React, { SyntheticEvent } from "react"; import classNames from "classnames"; import { MatrixEvent, MatrixEventEvent, Relations, RelationsEvent } from "matrix-js-sdk/src/matrix"; import { uniqBy } from "lodash"; +import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue"; import { _t } from "../../../languageHandler"; import { isContentActionable } from "../../../utils/EventUtils"; @@ -27,10 +28,13 @@ import ReactionPicker from "../emojipicker/ReactionPicker"; import ReactionsRowButton from "./ReactionsRowButton"; import RoomContext from "../../../contexts/RoomContext"; import AccessibleButton from "../elements/AccessibleButton"; +import SettingsStore from "../../../settings/SettingsStore"; // The maximum number of reactions to initially show on a message. const MAX_ITEMS_WHEN_LIMITED = 8; +export const REACTION_SHORTCODE_KEY = new UnstableValue("shortcode", "com.beeper.reaction.shortcode"); + const ReactButton: React.FC = ({ mxEvent, reactions }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); @@ -169,6 +173,7 @@ export default class ReactionsRow extends React.PureComponent { if (!reactions || !isContentActionable(mxEvent)) { return null; } + const customReactionImagesEnabled = SettingsStore.getValue("feature_render_reaction_images"); let items = reactions .getSortedAnnotationsByKey() @@ -195,6 +200,7 @@ export default class ReactionsRow extends React.PureComponent { mxEvent={mxEvent} reactionEvents={deduplicatedEvents} myReactionEvent={myReactionEvent} + customReactionImagesEnabled={customReactionImagesEnabled} disabled={ !this.context.canReact || (myReactionEvent && !myReactionEvent.isRedacted() && !this.context.canSelfRedact) diff --git a/src/components/views/messages/ReactionsRowButton.tsx b/src/components/views/messages/ReactionsRowButton.tsx index 91e1d24773..7890a1a3b7 100644 --- a/src/components/views/messages/ReactionsRowButton.tsx +++ b/src/components/views/messages/ReactionsRowButton.tsx @@ -18,13 +18,15 @@ import React from "react"; import classNames from "classnames"; import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { mediaFromMxc } from "../../../customisations/Media"; import { _t } from "../../../languageHandler"; import { formatCommaSeparatedList } from "../../../utils/FormattingUtils"; import dis from "../../../dispatcher/dispatcher"; import ReactionsRowButtonTooltip from "./ReactionsRowButtonTooltip"; import AccessibleButton from "../elements/AccessibleButton"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -interface IProps { +import { REACTION_SHORTCODE_KEY } from "./ReactionsRow"; +export interface IProps { // The event we're displaying reactions for mxEvent: MatrixEvent; // The reaction content / key / emoji @@ -37,6 +39,8 @@ interface IProps { myReactionEvent?: MatrixEvent; // Whether to prevent quick-reactions by clicking on this reaction disabled?: boolean; + // Whether to render custom image reactions + customReactionImagesEnabled?: boolean; } interface IState { @@ -100,27 +104,56 @@ export default class ReactionsRowButton extends React.PureComponent ); } const room = this.context.getRoom(mxEvent.getRoomId()); let label: string | undefined; + let customReactionName: string | undefined; if (room) { const senders: string[] = []; for (const reactionEvent of reactionEvents) { const member = room.getMember(reactionEvent.getSender()!); senders.push(member?.name || reactionEvent.getSender()!); + customReactionName = + (this.props.customReactionImagesEnabled && + REACTION_SHORTCODE_KEY.findIn(reactionEvent.getContent())) || + undefined; } const reactors = formatCommaSeparatedList(senders, 6); if (content) { - label = _t("%(reactors)s reacted with %(content)s", { reactors, content }); + label = _t("%(reactors)s reacted with %(content)s", { + reactors, + content: customReactionName || content, + }); } else { label = reactors; } } + let reactionContent = ( + + ); + if (this.props.customReactionImagesEnabled && content.startsWith("mxc://")) { + const imageSrc = mediaFromMxc(content).srcHttp; + if (imageSrc) { + reactionContent = ( + {customReactionName + ); + } + } + return ( - + {reactionContent} diff --git a/src/components/views/messages/ReactionsRowButtonTooltip.tsx b/src/components/views/messages/ReactionsRowButtonTooltip.tsx index 04cfe1dc58..74b134dda5 100644 --- a/src/components/views/messages/ReactionsRowButtonTooltip.tsx +++ b/src/components/views/messages/ReactionsRowButtonTooltip.tsx @@ -22,6 +22,7 @@ import { _t } from "../../../languageHandler"; import { formatCommaSeparatedList } from "../../../utils/FormattingUtils"; import Tooltip from "../elements/Tooltip"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { REACTION_SHORTCODE_KEY } from "./ReactionsRow"; interface IProps { // The event we're displaying reactions for mxEvent: MatrixEvent; @@ -30,6 +31,8 @@ interface IProps { // A list of Matrix reaction events for this key reactionEvents: MatrixEvent[]; visible: boolean; + // Whether to render custom image reactions + customReactionImagesEnabled?: boolean; } export default class ReactionsRowButtonTooltip extends React.PureComponent { @@ -43,12 +46,17 @@ export default class ReactionsRowButtonTooltip extends React.PureComponent {_t( diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 020d14db73..b8737469c0 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -948,6 +948,8 @@ "Force 15s voice broadcast chunk length": "Force 15s voice broadcast chunk length", "Enable new native OIDC flows (Under active development)": "Enable new native OIDC flows (Under active development)", "Font size": "Font size", + "Render custom images in reactions": "Render custom images in reactions", + "Sometimes referred to as \"custom emojis\".": "Sometimes referred to as \"custom emojis\".", "Use custom size": "Use custom size", "Show polls button": "Show polls button", "Use a more compact 'Modern' layout": "Use a more compact 'Modern' layout", @@ -2316,6 +2318,7 @@ "Error processing voice message": "Error processing voice message", "Add reaction": "Add reaction", "%(reactors)s reacted with %(content)s": "%(reactors)s reacted with %(content)s", + "Custom reaction": "Custom reaction", "reacted with %(shortName)s": "reacted with %(shortName)s", "Message deleted on %(date)s": "Message deleted on %(date)s", "%(senderDisplayName)s changed the avatar for %(roomName)s": "%(senderDisplayName)s changed the avatar for %(roomName)s", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 58ed0f705e..e04f8be6e5 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -472,6 +472,14 @@ export const SETTINGS: { [setting: string]: ISetting } = { default: "", controller: new FontSizeController(), }, + "feature_render_reaction_images": { + isFeature: true, + labsGroup: LabGroup.Messaging, + displayName: _td("Render custom images in reactions"), + description: _td('Sometimes referred to as "custom emojis".'), + supportedLevels: LEVELS_FEATURE, + default: false, + }, /** * With the transition to Compound we are moving to a base font size * of 16px. We're taking the opportunity to move away from the `baseFontSize` diff --git a/test/components/views/messages/ReactionsRowButton-test.tsx b/test/components/views/messages/ReactionsRowButton-test.tsx new file mode 100644 index 0000000000..b3ec6313d1 --- /dev/null +++ b/test/components/views/messages/ReactionsRowButton-test.tsx @@ -0,0 +1,119 @@ +/* +Copyright 2023 Beeper + +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 { IContent, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { render } from "@testing-library/react"; + +import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; +import { getMockClientWithEventEmitter } from "../../../test-utils"; +import ReactionsRowButton, { IProps } from "../../../../src/components/views/messages/ReactionsRowButton"; + +describe("ReactionsRowButton", () => { + const userId = "@alice:server"; + const roomId = "!randomcharacters:aser.ver"; + const mockClient = getMockClientWithEventEmitter({ + mxcUrlToHttp: jest.fn().mockReturnValue("https://not.a.real.url"), + getRoom: jest.fn(), + }); + const room = new Room(roomId, mockClient, userId); + + const createProps = (relationContent: IContent): IProps => ({ + mxEvent: new MatrixEvent({ + room_id: roomId, + event_id: "$test:example.com", + content: { body: "test" }, + }), + content: relationContent["m.relates_to"]?.key || "", + count: 2, + reactionEvents: [ + new MatrixEvent({ + type: "m.reaction", + sender: "@user1:example.com", + content: relationContent, + }), + new MatrixEvent({ + type: "m.reaction", + sender: "@user2:example.com", + content: relationContent, + }), + ], + customReactionImagesEnabled: true, + }); + + beforeEach(function () { + jest.clearAllMocks(); + mockClient.credentials = { userId: userId }; + mockClient.getRoom.mockImplementation((roomId: string): Room | null => { + return roomId === room.roomId ? room : null; + }); + }); + + it("renders reaction row button emojis correctly", () => { + const props = createProps({ + "m.relates_to": { + event_id: "$user2:example.com", + key: "👍", + rel_type: "m.annotation", + }, + }); + const root = render( + + + , + ); + expect(root.asFragment()).toMatchSnapshot(); + + // Try hover and make sure that the ReactionsRowButtonTooltip works + const reactionButton = root.getByRole("button"); + const event = new MouseEvent("mouseover", { + bubbles: true, + cancelable: true, + }); + reactionButton.dispatchEvent(event); + + expect(root.asFragment()).toMatchSnapshot(); + }); + + it("renders reaction row button custom image reactions correctly", () => { + const props = createProps({ + "com.beeper.reaction.shortcode": ":test:", + "shortcode": ":test:", + "m.relates_to": { + event_id: "$user1:example.com", + key: "mxc://example.com/123456789", + rel_type: "m.annotation", + }, + }); + + const root = render( + + + , + ); + expect(root.asFragment()).toMatchSnapshot(); + + // Try hover and make sure that the ReactionsRowButtonTooltip works + const reactionButton = root.getByRole("button"); + const event = new MouseEvent("mouseover", { + bubbles: true, + cancelable: true, + }); + reactionButton.dispatchEvent(event); + + expect(root.asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/components/views/messages/__snapshots__/ReactionsRowButton-test.tsx.snap b/test/components/views/messages/__snapshots__/ReactionsRowButton-test.tsx.snap new file mode 100644 index 0000000000..a6479956a7 --- /dev/null +++ b/test/components/views/messages/__snapshots__/ReactionsRowButton-test.tsx.snap @@ -0,0 +1,101 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ReactionsRowButton renders reaction row button custom image reactions correctly 1`] = ` + +
+ :test: + +
+
+`; + +exports[`ReactionsRowButton renders reaction row button custom image reactions correctly 2`] = ` + +
+ :test: + +
+
+ +`; + +exports[`ReactionsRowButton renders reaction row button emojis correctly 1`] = ` + +
+ + +
+
+`; + +exports[`ReactionsRowButton renders reaction row button emojis correctly 2`] = ` + +
+ + +
+
+ +`;