Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit a54f2ff

Browse files
sumnerevanstulirSimonBrandnert3chguy
authored
Render custom images in reactions (#11087)
* Add support for rendering custom emojis in reactions Signed-off-by: Sumner Evans <sumner@beeper.com> * Include custom reaction short names in tooltips Signed-off-by: Sumner Evans <sumner@beeper.com> * 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 <sumner@beeper.com> * Remove explicit instantiation of `customReactionName` variable and add types Co-authored-by: Šimon Brandner <simon.bra.ag@gmail.com> * Put custom reaction images behind a labs flag Signed-off-by: Sumner Evans <sumner@beeper.com> * Use UnstableValue for finding the shortcode Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> Signed-off-by: Sumner Evans <sumner@beeper.com> * Move calculation of whether to render custom reaction images up to ReactionRow Signed-off-by: Sumner Evans <sumner@beeper.com> * Make alt text more friendly when custom reaction doesn't have shortcode Signed-off-by: Sumner Evans <sumner@beeper.com> * Add test for ReactionsRowButton Signed-off-by: Sumner Evans <sumner@beeper.com> * Apply suggestions from code review Co-authored-by: Šimon Brandner <simon.bra.ag@gmail.com> * Don't use Optional Signed-off-by: Sumner Evans <sumner@beeper.com> * Fix ReactionsRowButton test Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> Signed-off-by: Sumner Evans <sumner@beeper.com> --------- Signed-off-by: Sumner Evans <sumner@beeper.com> Co-authored-by: Tulir Asokan <tulir@maunium.net> Co-authored-by: Šimon Brandner <simon.bra.ag@gmail.com> Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
1 parent d551469 commit a54f2ff

File tree

8 files changed

+283
-6
lines changed

8 files changed

+283
-6
lines changed

res/css/views/messages/_ReactionsRowButton.pcss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ limitations under the License.
2222
border-radius: 10px;
2323
background-color: $secondary-hairline-color;
2424
user-select: none;
25+
align-items: center;
2526

2627
&:hover {
2728
border-color: $quinary-content;

src/components/views/messages/ReactionsRow.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import React, { SyntheticEvent } from "react";
1818
import classNames from "classnames";
1919
import { MatrixEvent, MatrixEventEvent, Relations, RelationsEvent } from "matrix-js-sdk/src/matrix";
2020
import { uniqBy } from "lodash";
21+
import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue";
2122

2223
import { _t } from "../../../languageHandler";
2324
import { isContentActionable } from "../../../utils/EventUtils";
@@ -27,10 +28,13 @@ import ReactionPicker from "../emojipicker/ReactionPicker";
2728
import ReactionsRowButton from "./ReactionsRowButton";
2829
import RoomContext from "../../../contexts/RoomContext";
2930
import AccessibleButton from "../elements/AccessibleButton";
31+
import SettingsStore from "../../../settings/SettingsStore";
3032

3133
// The maximum number of reactions to initially show on a message.
3234
const MAX_ITEMS_WHEN_LIMITED = 8;
3335

36+
export const REACTION_SHORTCODE_KEY = new UnstableValue("shortcode", "com.beeper.reaction.shortcode");
37+
3438
const ReactButton: React.FC<IProps> = ({ mxEvent, reactions }) => {
3539
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
3640

@@ -169,6 +173,7 @@ export default class ReactionsRow extends React.PureComponent<IProps, IState> {
169173
if (!reactions || !isContentActionable(mxEvent)) {
170174
return null;
171175
}
176+
const customReactionImagesEnabled = SettingsStore.getValue("feature_render_reaction_images");
172177

173178
let items = reactions
174179
.getSortedAnnotationsByKey()
@@ -195,6 +200,7 @@ export default class ReactionsRow extends React.PureComponent<IProps, IState> {
195200
mxEvent={mxEvent}
196201
reactionEvents={deduplicatedEvents}
197202
myReactionEvent={myReactionEvent}
203+
customReactionImagesEnabled={customReactionImagesEnabled}
198204
disabled={
199205
!this.context.canReact ||
200206
(myReactionEvent && !myReactionEvent.isRedacted() && !this.context.canSelfRedact)

src/components/views/messages/ReactionsRowButton.tsx

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ import React from "react";
1818
import classNames from "classnames";
1919
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
2020

21+
import { mediaFromMxc } from "../../../customisations/Media";
2122
import { _t } from "../../../languageHandler";
2223
import { formatCommaSeparatedList } from "../../../utils/FormattingUtils";
2324
import dis from "../../../dispatcher/dispatcher";
2425
import ReactionsRowButtonTooltip from "./ReactionsRowButtonTooltip";
2526
import AccessibleButton from "../elements/AccessibleButton";
2627
import MatrixClientContext from "../../../contexts/MatrixClientContext";
27-
interface IProps {
28+
import { REACTION_SHORTCODE_KEY } from "./ReactionsRow";
29+
export interface IProps {
2830
// The event we're displaying reactions for
2931
mxEvent: MatrixEvent;
3032
// The reaction content / key / emoji
@@ -37,6 +39,8 @@ interface IProps {
3739
myReactionEvent?: MatrixEvent;
3840
// Whether to prevent quick-reactions by clicking on this reaction
3941
disabled?: boolean;
42+
// Whether to render custom image reactions
43+
customReactionImagesEnabled?: boolean;
4044
}
4145

4246
interface IState {
@@ -100,27 +104,56 @@ export default class ReactionsRowButton extends React.PureComponent<IProps, ISta
100104
content={content}
101105
reactionEvents={reactionEvents}
102106
visible={this.state.tooltipVisible}
107+
customReactionImagesEnabled={this.props.customReactionImagesEnabled}
103108
/>
104109
);
105110
}
106111

107112
const room = this.context.getRoom(mxEvent.getRoomId());
108113
let label: string | undefined;
114+
let customReactionName: string | undefined;
109115
if (room) {
110116
const senders: string[] = [];
111117
for (const reactionEvent of reactionEvents) {
112118
const member = room.getMember(reactionEvent.getSender()!);
113119
senders.push(member?.name || reactionEvent.getSender()!);
120+
customReactionName =
121+
(this.props.customReactionImagesEnabled &&
122+
REACTION_SHORTCODE_KEY.findIn(reactionEvent.getContent())) ||
123+
undefined;
114124
}
115125

116126
const reactors = formatCommaSeparatedList(senders, 6);
117127
if (content) {
118-
label = _t("%(reactors)s reacted with %(content)s", { reactors, content });
128+
label = _t("%(reactors)s reacted with %(content)s", {
129+
reactors,
130+
content: customReactionName || content,
131+
});
119132
} else {
120133
label = reactors;
121134
}
122135
}
123136

137+
let reactionContent = (
138+
<span className="mx_ReactionsRowButton_content" aria-hidden="true">
139+
{content}
140+
</span>
141+
);
142+
if (this.props.customReactionImagesEnabled && content.startsWith("mxc://")) {
143+
const imageSrc = mediaFromMxc(content).srcHttp;
144+
if (imageSrc) {
145+
reactionContent = (
146+
<img
147+
className="mx_ReactionsRowButton_content"
148+
alt={customReactionName || _t("Custom reaction")}
149+
src={imageSrc}
150+
width="16"
151+
height="16"
152+
/>
153+
);
154+
}
155+
}
156+
124157
return (
125158
<AccessibleButton
126159
className={classes}
@@ -130,9 +163,7 @@ export default class ReactionsRowButton extends React.PureComponent<IProps, ISta
130163
onMouseOver={this.onMouseOver}
131164
onMouseLeave={this.onMouseLeave}
132165
>
133-
<span className="mx_ReactionsRowButton_content" aria-hidden="true">
134-
{content}
135-
</span>
166+
{reactionContent}
136167
<span className="mx_ReactionsRowButton_count" aria-hidden="true">
137168
{count}
138169
</span>

src/components/views/messages/ReactionsRowButtonTooltip.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { _t } from "../../../languageHandler";
2222
import { formatCommaSeparatedList } from "../../../utils/FormattingUtils";
2323
import Tooltip from "../elements/Tooltip";
2424
import MatrixClientContext from "../../../contexts/MatrixClientContext";
25+
import { REACTION_SHORTCODE_KEY } from "./ReactionsRow";
2526
interface IProps {
2627
// The event we're displaying reactions for
2728
mxEvent: MatrixEvent;
@@ -30,6 +31,8 @@ interface IProps {
3031
// A list of Matrix reaction events for this key
3132
reactionEvents: MatrixEvent[];
3233
visible: boolean;
34+
// Whether to render custom image reactions
35+
customReactionImagesEnabled?: boolean;
3336
}
3437

3538
export default class ReactionsRowButtonTooltip extends React.PureComponent<IProps> {
@@ -43,12 +46,17 @@ export default class ReactionsRowButtonTooltip extends React.PureComponent<IProp
4346
let tooltipLabel: JSX.Element | undefined;
4447
if (room) {
4548
const senders: string[] = [];
49+
let customReactionName: string | undefined;
4650
for (const reactionEvent of reactionEvents) {
4751
const member = room.getMember(reactionEvent.getSender()!);
4852
const name = member?.name ?? reactionEvent.getSender()!;
4953
senders.push(name);
54+
customReactionName =
55+
(this.props.customReactionImagesEnabled &&
56+
REACTION_SHORTCODE_KEY.findIn(reactionEvent.getContent())) ||
57+
undefined;
5058
}
51-
const shortName = unicodeToShortcode(content);
59+
const shortName = unicodeToShortcode(content) || customReactionName;
5260
tooltipLabel = (
5361
<div>
5462
{_t(

src/i18n/strings/en_EN.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,6 +948,8 @@
948948
"Force 15s voice broadcast chunk length": "Force 15s voice broadcast chunk length",
949949
"Enable new native OIDC flows (Under active development)": "Enable new native OIDC flows (Under active development)",
950950
"Font size": "Font size",
951+
"Render custom images in reactions": "Render custom images in reactions",
952+
"Sometimes referred to as \"custom emojis\".": "Sometimes referred to as \"custom emojis\".",
951953
"Use custom size": "Use custom size",
952954
"Show polls button": "Show polls button",
953955
"Use a more compact 'Modern' layout": "Use a more compact 'Modern' layout",
@@ -2316,6 +2318,7 @@
23162318
"Error processing voice message": "Error processing voice message",
23172319
"Add reaction": "Add reaction",
23182320
"%(reactors)s reacted with %(content)s": "%(reactors)s reacted with %(content)s",
2321+
"Custom reaction": "Custom reaction",
23192322
"<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>": "<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>",
23202323
"Message deleted on %(date)s": "Message deleted on %(date)s",
23212324
"%(senderDisplayName)s changed the avatar for %(roomName)s": "%(senderDisplayName)s changed the avatar for %(roomName)s",

src/settings/Settings.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,14 @@ export const SETTINGS: { [setting: string]: ISetting } = {
472472
default: "",
473473
controller: new FontSizeController(),
474474
},
475+
"feature_render_reaction_images": {
476+
isFeature: true,
477+
labsGroup: LabGroup.Messaging,
478+
displayName: _td("Render custom images in reactions"),
479+
description: _td('Sometimes referred to as "custom emojis".'),
480+
supportedLevels: LEVELS_FEATURE,
481+
default: false,
482+
},
475483
/**
476484
* With the transition to Compound we are moving to a base font size
477485
* of 16px. We're taking the opportunity to move away from the `baseFontSize`
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*
2+
Copyright 2023 Beeper
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React from "react";
18+
import { IContent, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
19+
import { render } from "@testing-library/react";
20+
21+
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
22+
import { getMockClientWithEventEmitter } from "../../../test-utils";
23+
import ReactionsRowButton, { IProps } from "../../../../src/components/views/messages/ReactionsRowButton";
24+
25+
describe("ReactionsRowButton", () => {
26+
const userId = "@alice:server";
27+
const roomId = "!randomcharacters:aser.ver";
28+
const mockClient = getMockClientWithEventEmitter({
29+
mxcUrlToHttp: jest.fn().mockReturnValue("https://not.a.real.url"),
30+
getRoom: jest.fn(),
31+
});
32+
const room = new Room(roomId, mockClient, userId);
33+
34+
const createProps = (relationContent: IContent): IProps => ({
35+
mxEvent: new MatrixEvent({
36+
room_id: roomId,
37+
event_id: "$test:example.com",
38+
content: { body: "test" },
39+
}),
40+
content: relationContent["m.relates_to"]?.key || "",
41+
count: 2,
42+
reactionEvents: [
43+
new MatrixEvent({
44+
type: "m.reaction",
45+
sender: "@user1:example.com",
46+
content: relationContent,
47+
}),
48+
new MatrixEvent({
49+
type: "m.reaction",
50+
sender: "@user2:example.com",
51+
content: relationContent,
52+
}),
53+
],
54+
customReactionImagesEnabled: true,
55+
});
56+
57+
beforeEach(function () {
58+
jest.clearAllMocks();
59+
mockClient.credentials = { userId: userId };
60+
mockClient.getRoom.mockImplementation((roomId: string): Room | null => {
61+
return roomId === room.roomId ? room : null;
62+
});
63+
});
64+
65+
it("renders reaction row button emojis correctly", () => {
66+
const props = createProps({
67+
"m.relates_to": {
68+
event_id: "$user2:example.com",
69+
key: "👍",
70+
rel_type: "m.annotation",
71+
},
72+
});
73+
const root = render(
74+
<MatrixClientContext.Provider value={mockClient}>
75+
<ReactionsRowButton {...props} />
76+
</MatrixClientContext.Provider>,
77+
);
78+
expect(root.asFragment()).toMatchSnapshot();
79+
80+
// Try hover and make sure that the ReactionsRowButtonTooltip works
81+
const reactionButton = root.getByRole("button");
82+
const event = new MouseEvent("mouseover", {
83+
bubbles: true,
84+
cancelable: true,
85+
});
86+
reactionButton.dispatchEvent(event);
87+
88+
expect(root.asFragment()).toMatchSnapshot();
89+
});
90+
91+
it("renders reaction row button custom image reactions correctly", () => {
92+
const props = createProps({
93+
"com.beeper.reaction.shortcode": ":test:",
94+
"shortcode": ":test:",
95+
"m.relates_to": {
96+
event_id: "$user1:example.com",
97+
key: "mxc://example.com/123456789",
98+
rel_type: "m.annotation",
99+
},
100+
});
101+
102+
const root = render(
103+
<MatrixClientContext.Provider value={mockClient}>
104+
<ReactionsRowButton {...props} />
105+
</MatrixClientContext.Provider>,
106+
);
107+
expect(root.asFragment()).toMatchSnapshot();
108+
109+
// Try hover and make sure that the ReactionsRowButtonTooltip works
110+
const reactionButton = root.getByRole("button");
111+
const event = new MouseEvent("mouseover", {
112+
bubbles: true,
113+
cancelable: true,
114+
});
115+
reactionButton.dispatchEvent(event);
116+
117+
expect(root.asFragment()).toMatchSnapshot();
118+
});
119+
});

0 commit comments

Comments
 (0)