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:
parent
d551469543
commit
a54f2ff878
8 changed files with 283 additions and 6 deletions
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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`
|
||||
|
|
119
test/components/views/messages/ReactionsRowButton-test.tsx
Normal file
119
test/components/views/messages/ReactionsRowButton-test.tsx
Normal 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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
`;
|
Loading…
Reference in a new issue