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

Render custom images in reactions #11087

Merged
Merged
1 change: 1 addition & 0 deletions res/css/views/messages/_ReactionsRowButton.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions src/components/views/messages/ReactionsRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<IProps> = ({ mxEvent, reactions }) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();

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

let items = reactions
.getSortedAnnotationsByKey()
Expand All @@ -195,6 +200,7 @@ export default class ReactionsRow extends React.PureComponent<IProps, IState> {
mxEvent={mxEvent}
reactionEvents={deduplicatedEvents}
myReactionEvent={myReactionEvent}
customReactionImagesEnabled={customReactionImagesEnabled}
disabled={
!this.context.canReact ||
(myReactionEvent && !myReactionEvent.isRedacted() && !this.context.canSelfRedact)
Expand Down
41 changes: 36 additions & 5 deletions src/components/views/messages/ReactionsRowButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -100,27 +104,56 @@ export default class ReactionsRowButton extends React.PureComponent<IProps, ISta
content={content}
reactionEvents={reactionEvents}
visible={this.state.tooltipVisible}
customReactionImagesEnabled={this.props.customReactionImagesEnabled}
/>
);
}

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 = (
<span className="mx_ReactionsRowButton_content" aria-hidden="true">
{content}
</span>
);
if (this.props.customReactionImagesEnabled && content.startsWith("mxc://")) {
const imageSrc = mediaFromMxc(content).srcHttp;
if (imageSrc) {
reactionContent = (
<img
className="mx_ReactionsRowButton_content"
alt={customReactionName || _t("Custom reaction")}
src={imageSrc}
width="16"
height="16"
/>
);
}
}

return (
<AccessibleButton
className={classes}
Expand All @@ -130,9 +163,7 @@ export default class ReactionsRowButton extends React.PureComponent<IProps, ISta
onMouseOver={this.onMouseOver}
onMouseLeave={this.onMouseLeave}
>
<span className="mx_ReactionsRowButton_content" aria-hidden="true">
{content}
</span>
{reactionContent}
<span className="mx_ReactionsRowButton_count" aria-hidden="true">
{count}
</span>
Expand Down
10 changes: 9 additions & 1 deletion src/components/views/messages/ReactionsRowButtonTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<IProps> {
Expand All @@ -43,12 +46,17 @@ export default class ReactionsRowButtonTooltip extends React.PureComponent<IProp
let tooltipLabel: JSX.Element | undefined;
if (room) {
const senders: string[] = [];
let customReactionName: string | undefined;
for (const reactionEvent of reactionEvents) {
const member = room.getMember(reactionEvent.getSender()!);
const name = member?.name ?? reactionEvent.getSender()!;
senders.push(name);
customReactionName =
(this.props.customReactionImagesEnabled &&
REACTION_SHORTCODE_KEY.findIn(reactionEvent.getContent())) ||
undefined;
}
const shortName = unicodeToShortcode(content);
const shortName = unicodeToShortcode(content) || customReactionName;
tooltipLabel = (
<div>
{_t(
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1004,6 +1004,8 @@
"Enable new native OIDC flows (Under active development)": "Enable new native OIDC flows (Under active development)",
"Rust cryptography implementation": "Rust cryptography implementation",
"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",
"Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing",
"Show stickers button": "Show stickers button",
Expand Down Expand Up @@ -2481,6 +2483,7 @@
"Add reaction": "Add reaction",
"Reactions": "Reactions",
"%(reactors)s reacted with %(content)s": "%(reactors)s reacted with %(content)s",
"Custom reaction": "Custom reaction",
"<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>": "<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>",
"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",
Expand Down
8 changes: 8 additions & 0 deletions src/settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,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`
Expand Down
119 changes: 119 additions & 0 deletions test/components/views/messages/ReactionsRowButton-test.tsx
Original file line number Diff line number Diff line change
@@ -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.
*/
sumnerevans marked this conversation as resolved.
Show resolved Hide resolved

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(
<MatrixClientContext.Provider value={mockClient}>
<ReactionsRowButton {...props} />
</MatrixClientContext.Provider>,
);
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(
<MatrixClientContext.Provider value={mockClient}>
<ReactionsRowButton {...props} />
</MatrixClientContext.Provider>,
);
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();
});
});
Loading