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

Commit

Permalink
Render custom images in reactions (#11087)
Browse files Browse the repository at this point in the history
* Add support for rendering custom emojis in reactions

Signed-off-by: Sumner Evans <[email protected]>

* Include custom reaction short names in tooltips

Signed-off-by: Sumner Evans <[email protected]>

* 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 <[email protected]>

* Remove explicit instantiation of `customReactionName` variable and add types

Co-authored-by: Šimon Brandner <[email protected]>

* Put custom reaction images behind a labs flag

Signed-off-by: Sumner Evans <[email protected]>

* Use UnstableValue for finding the shortcode

Co-authored-by: Michael Telatynski <[email protected]>
Signed-off-by: Sumner Evans <[email protected]>

* Move calculation of whether to render custom reaction images up to ReactionRow

Signed-off-by: Sumner Evans <[email protected]>

* Make alt text more friendly when custom reaction doesn't have shortcode

Signed-off-by: Sumner Evans <[email protected]>

* Add test for ReactionsRowButton

Signed-off-by: Sumner Evans <[email protected]>

* Apply suggestions from code review

Co-authored-by: Šimon Brandner <[email protected]>

* Don't use Optional

Signed-off-by: Sumner Evans <[email protected]>

* Fix ReactionsRowButton test

Co-authored-by: Michael Telatynski <[email protected]>
Signed-off-by: Sumner Evans <[email protected]>

---------

Signed-off-by: Sumner Evans <[email protected]>
Co-authored-by: Tulir Asokan <[email protected]>
Co-authored-by: Šimon Brandner <[email protected]>
Co-authored-by: Michael Telatynski <[email protected]>
  • Loading branch information
4 people authored Sep 1, 2023
1 parent d551469 commit a54f2ff
Show file tree
Hide file tree
Showing 8 changed files with 283 additions and 6 deletions.
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 @@ -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",
Expand Down Expand Up @@ -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",
"<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 @@ -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`
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.
*/

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

0 comments on commit a54f2ff

Please sign in to comment.