From a54f2ff8780eb940e9488fff91379adc81605b18 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Fri, 1 Sep 2023 04:16:24 -0600 Subject: [PATCH 1/3] Render custom images in reactions (#11087) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add support for rendering custom emojis in reactions Signed-off-by: Sumner Evans * Include custom reaction short names in tooltips Signed-off-by: Sumner Evans * 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 * Remove explicit instantiation of `customReactionName` variable and add types Co-authored-by: Šimon Brandner * Put custom reaction images behind a labs flag Signed-off-by: Sumner Evans * Use UnstableValue for finding the shortcode Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> Signed-off-by: Sumner Evans * Move calculation of whether to render custom reaction images up to ReactionRow Signed-off-by: Sumner Evans * Make alt text more friendly when custom reaction doesn't have shortcode Signed-off-by: Sumner Evans * Add test for ReactionsRowButton Signed-off-by: Sumner Evans * Apply suggestions from code review Co-authored-by: Šimon Brandner * Don't use Optional Signed-off-by: Sumner Evans * Fix ReactionsRowButton test Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> Signed-off-by: Sumner Evans --------- Signed-off-by: Sumner Evans Co-authored-by: Tulir Asokan Co-authored-by: Šimon Brandner Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- .../views/messages/_ReactionsRowButton.pcss | 1 + .../views/messages/ReactionsRow.tsx | 6 + .../views/messages/ReactionsRowButton.tsx | 41 +++++- .../messages/ReactionsRowButtonTooltip.tsx | 10 +- src/i18n/strings/en_EN.json | 3 + src/settings/Settings.tsx | 8 ++ .../messages/ReactionsRowButton-test.tsx | 119 ++++++++++++++++++ .../ReactionsRowButton-test.tsx.snap | 101 +++++++++++++++ 8 files changed, 283 insertions(+), 6 deletions(-) create mode 100644 test/components/views/messages/ReactionsRowButton-test.tsx create mode 100644 test/components/views/messages/__snapshots__/ReactionsRowButton-test.tsx.snap diff --git a/res/css/views/messages/_ReactionsRowButton.pcss b/res/css/views/messages/_ReactionsRowButton.pcss index d27cb4f188..26d2d25fae 100644 --- a/res/css/views/messages/_ReactionsRowButton.pcss +++ b/res/css/views/messages/_ReactionsRowButton.pcss @@ -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; diff --git a/src/components/views/messages/ReactionsRow.tsx b/src/components/views/messages/ReactionsRow.tsx index 6e8e3d4589..3344c835cf 100644 --- a/src/components/views/messages/ReactionsRow.tsx +++ b/src/components/views/messages/ReactionsRow.tsx @@ -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 = ({ mxEvent, reactions }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); @@ -169,6 +173,7 @@ export default class ReactionsRow extends React.PureComponent { 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 { mxEvent={mxEvent} reactionEvents={deduplicatedEvents} myReactionEvent={myReactionEvent} + customReactionImagesEnabled={customReactionImagesEnabled} disabled={ !this.context.canReact || (myReactionEvent && !myReactionEvent.isRedacted() && !this.context.canSelfRedact) diff --git a/src/components/views/messages/ReactionsRowButton.tsx b/src/components/views/messages/ReactionsRowButton.tsx index 91e1d24773..7890a1a3b7 100644 --- a/src/components/views/messages/ReactionsRowButton.tsx +++ b/src/components/views/messages/ReactionsRowButton.tsx @@ -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 ); } 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 = ( + + ); + if (this.props.customReactionImagesEnabled && content.startsWith("mxc://")) { + const imageSrc = mediaFromMxc(content).srcHttp; + if (imageSrc) { + reactionContent = ( + {customReactionName + ); + } + } + return ( - + {reactionContent} diff --git a/src/components/views/messages/ReactionsRowButtonTooltip.tsx b/src/components/views/messages/ReactionsRowButtonTooltip.tsx index 04cfe1dc58..74b134dda5 100644 --- a/src/components/views/messages/ReactionsRowButtonTooltip.tsx +++ b/src/components/views/messages/ReactionsRowButtonTooltip.tsx @@ -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 { @@ -43,12 +46,17 @@ export default class ReactionsRowButtonTooltip extends React.PureComponent {_t( diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 020d14db73..b8737469c0 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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", "reacted with %(shortName)s": "reacted with %(shortName)s", "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", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 58ed0f705e..e04f8be6e5 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -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` diff --git a/test/components/views/messages/ReactionsRowButton-test.tsx b/test/components/views/messages/ReactionsRowButton-test.tsx new file mode 100644 index 0000000000..b3ec6313d1 --- /dev/null +++ b/test/components/views/messages/ReactionsRowButton-test.tsx @@ -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( + + + , + ); + 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( + + + , + ); + 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(); + }); +}); diff --git a/test/components/views/messages/__snapshots__/ReactionsRowButton-test.tsx.snap b/test/components/views/messages/__snapshots__/ReactionsRowButton-test.tsx.snap new file mode 100644 index 0000000000..a6479956a7 --- /dev/null +++ b/test/components/views/messages/__snapshots__/ReactionsRowButton-test.tsx.snap @@ -0,0 +1,101 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ReactionsRowButton renders reaction row button custom image reactions correctly 1`] = ` + +
+ :test: + +
+
+`; + +exports[`ReactionsRowButton renders reaction row button custom image reactions correctly 2`] = ` + +
+ :test: + +
+
+ +`; + +exports[`ReactionsRowButton renders reaction row button emojis correctly 1`] = ` + +
+ + +
+
+`; + +exports[`ReactionsRowButton renders reaction row button emojis correctly 2`] = ` + +
+ + +
+
+ +`; From fbe5a7d4b8181c050889519238dd16514dcdad42 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 1 Sep 2023 12:52:24 +0100 Subject: [PATCH 2/3] Use Intl.RelativeTimeFormat for "last week" and "last month" (#11511) * Use Intl.RelativeTimeFormat for "last week" and "last month" * i18n --- src/components/views/messages/DateSeparator.tsx | 15 ++++++++++----- src/i18n/strings/en_EN.json | 4 +--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/components/views/messages/DateSeparator.tsx b/src/components/views/messages/DateSeparator.tsx index 69f85f73d6..8c8b1eefb8 100644 --- a/src/components/views/messages/DateSeparator.tsx +++ b/src/components/views/messages/DateSeparator.tsx @@ -18,6 +18,7 @@ limitations under the License. import React from "react"; import { Direction, ConnectionError, MatrixError, HTTPError } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; +import { capitalize } from "lodash"; import { _t, getUserLanguage } from "../../../languageHandler"; import { formatFullDateNoDay, formatFullDateNoTime, getDaysArray } from "../../../DateUtils"; @@ -92,6 +93,10 @@ export default class DateSeparator extends React.Component { }); }; + private get relativeTimeFormat(): Intl.RelativeTimeFormat { + return new Intl.RelativeTimeFormat(getUserLanguage(), { style: "long", numeric: "auto" }); + } + private getLabel(): string { const date = new Date(this.props.ts); const disableRelativeTimestamps = !SettingsStore.getValue(UIFeature.TimelineEnableRelativeDates); @@ -104,11 +109,10 @@ export default class DateSeparator extends React.Component { const days = getDaysArray("long"); yesterday.setDate(today.getDate() - 1); - const relativeTimeFormat = new Intl.RelativeTimeFormat(getUserLanguage(), { style: "long", numeric: "auto" }); if (date.toDateString() === today.toDateString()) { - return relativeTimeFormat.format(0, "day"); // Today + return this.relativeTimeFormat.format(0, "day"); // Today } else if (date.toDateString() === yesterday.toDateString()) { - return relativeTimeFormat.format(-1, "day"); // Yesterday + return this.relativeTimeFormat.format(-1, "day"); // Yesterday } else if (today.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) { return days[date.getDay()]; // Sunday-Saturday } else { @@ -263,6 +267,7 @@ export default class DateSeparator extends React.Component { private renderJumpToDateMenu(): React.ReactElement { let contextMenu: JSX.Element | undefined; if (this.state.contextMenuPosition) { + const relativeTimeFormat = this.relativeTimeFormat; contextMenu = ( { > diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index b8737469c0..96bc6e52cd 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -218,9 +218,7 @@ "short_seconds": "%(value)ss", "short_days_hours_minutes_seconds": "%(days)sd %(hours)sh %(minutes)sm %(seconds)ss", "short_hours_minutes_seconds": "%(hours)sh %(minutes)sm %(seconds)ss", - "short_minutes_seconds": "%(minutes)sm %(seconds)ss", - "last_week": "Last week", - "last_month": "Last month" + "short_minutes_seconds": "%(minutes)sm %(seconds)ss" }, "Identity server has no terms of service": "Identity server has no terms of service", "This action requires accessing the default identity server to validate an email address or phone number, but the server does not have any terms of service.": "This action requires accessing the default identity server to validate an email address or phone number, but the server does not have any terms of service.", From ce8d07fa7205d1dd808482c17b9b910f97c8ef26 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Fri, 1 Sep 2023 13:03:53 +0100 Subject: [PATCH 3/3] Only display RoomKnocksBar when feature flag is enabled (#11513) --- src/components/views/rooms/LegacyRoomHeader.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/views/rooms/LegacyRoomHeader.tsx b/src/components/views/rooms/LegacyRoomHeader.tsx index 8bee8f3fad..8f20acab71 100644 --- a/src/components/views/rooms/LegacyRoomHeader.tsx +++ b/src/components/views/rooms/LegacyRoomHeader.tsx @@ -480,6 +480,7 @@ export interface IProps { interface IState { contextMenuPosition?: DOMRect; rightPanelOpen: boolean; + featureAskToJoin: boolean; } /** @@ -496,6 +497,7 @@ export default class RoomHeader extends React.Component { public static contextType = RoomContext; public context!: React.ContextType; private readonly client = this.props.room.client; + private readonly featureAskToJoinWatcher: string; public constructor(props: IProps, context: IState) { super(props, context); @@ -503,7 +505,15 @@ export default class RoomHeader extends React.Component { notiStore.on(NotificationStateEvents.Update, this.onNotificationUpdate); this.state = { rightPanelOpen: RightPanelStore.instance.isOpen, + featureAskToJoin: SettingsStore.getValue("feature_ask_to_join"), }; + this.featureAskToJoinWatcher = SettingsStore.watchSetting( + "feature_ask_to_join", + null, + (_settingName, _roomId, _atLevel, _newValAtLevel, featureAskToJoin) => { + this.setState({ featureAskToJoin }); + }, + ); } public componentDidMount(): void { @@ -516,6 +526,7 @@ export default class RoomHeader extends React.Component { const notiStore = RoomNotificationStateStore.instance.getRoomState(this.props.room); notiStore.removeListener(NotificationStateEvents.Update, this.onNotificationUpdate); RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate); + SettingsStore.unwatchSetting(this.featureAskToJoinWatcher); } private onRightPanelStoreUpdate = (): void => { @@ -821,7 +832,7 @@ export default class RoomHeader extends React.Component {
{!isVideoRoom && } - + {this.state.featureAskToJoin && } ); }