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/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/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/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 && } ); } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 020d14db73..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.", @@ -948,6 +946,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 +2316,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`] = ` + +
+ + +
+
+ +`;