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>
This commit is contained in:
Sumner Evans 2023-09-01 04:16:24 -06:00 committed by GitHub
parent d551469543
commit a54f2ff878
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 283 additions and 6 deletions

View file

@ -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;

View file

@ -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<IProps> = ({ mxEvent, reactions }) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
@ -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()
@ -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)

View file

@ -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<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}
@ -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>

View file

@ -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<IProps> {
@ -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(

View file

@ -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",
"<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",

View file

@ -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`

View file

@ -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();
});
});

View file

@ -0,0 +1,101 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ReactionsRowButton renders reaction row button custom image reactions correctly 1`] = `
<DocumentFragment>
<div
aria-label="@user1:example.com and @user2:example.com reacted with :test:"
class="mx_AccessibleButton mx_ReactionsRowButton"
role="button"
tabindex="0"
>
<img
alt=":test:"
class="mx_ReactionsRowButton_content"
height="16"
src="https://not.a.real.url"
width="16"
/>
<span
aria-hidden="true"
class="mx_ReactionsRowButton_count"
>
2
</span>
</div>
</DocumentFragment>
`;
exports[`ReactionsRowButton renders reaction row button custom image reactions correctly 2`] = `
<DocumentFragment>
<div
aria-label="@user1:example.com and @user2:example.com reacted with :test:"
class="mx_AccessibleButton mx_ReactionsRowButton"
role="button"
tabindex="0"
>
<img
alt=":test:"
class="mx_ReactionsRowButton_content"
height="16"
src="https://not.a.real.url"
width="16"
/>
<span
aria-hidden="true"
class="mx_ReactionsRowButton_count"
>
2
</span>
<div />
</div>
</DocumentFragment>
`;
exports[`ReactionsRowButton renders reaction row button emojis correctly 1`] = `
<DocumentFragment>
<div
aria-label="@user1:example.com and @user2:example.com reacted with 👍"
class="mx_AccessibleButton mx_ReactionsRowButton"
role="button"
tabindex="0"
>
<span
aria-hidden="true"
class="mx_ReactionsRowButton_content"
>
👍
</span>
<span
aria-hidden="true"
class="mx_ReactionsRowButton_count"
>
2
</span>
</div>
</DocumentFragment>
`;
exports[`ReactionsRowButton renders reaction row button emojis correctly 2`] = `
<DocumentFragment>
<div
aria-label="@user1:example.com and @user2:example.com reacted with 👍"
class="mx_AccessibleButton mx_ReactionsRowButton"
role="button"
tabindex="0"
>
<span
aria-hidden="true"
class="mx_ReactionsRowButton_content"
>
👍
</span>
<span
aria-hidden="true"
class="mx_ReactionsRowButton_count"
>
2
</span>
<div />
</div>
</DocumentFragment>
`;