diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 4414a422d5f..189c8dee679 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -273,6 +273,7 @@ @import "./views/rooms/_E2EIcon.pcss"; @import "./views/rooms/_EditMessageComposer.pcss"; @import "./views/rooms/_EmojiButton.pcss"; +@import "./views/rooms/_Emotes.pcss"; @import "./views/rooms/_EntityTile.pcss"; @import "./views/rooms/_EventBubbleTile.pcss"; @import "./views/rooms/_EventTile.pcss"; @@ -321,6 +322,7 @@ @import "./views/settings/_AvatarSetting.pcss"; @import "./views/settings/_CrossSigningPanel.pcss"; @import "./views/settings/_CryptographyPanel.pcss"; +@import "./views/settings/_EmoteSettings.pcss"; @import "./views/settings/_FontScalingPanel.pcss"; @import "./views/settings/_ImageSizePanel.pcss"; @import "./views/settings/_IntegrationManager.pcss"; diff --git a/res/css/views/dialogs/_RoomSettingsDialog.pcss b/res/css/views/dialogs/_RoomSettingsDialog.pcss index b0a08919aa6..78feacce114 100644 --- a/res/css/views/dialogs/_RoomSettingsDialog.pcss +++ b/res/css/views/dialogs/_RoomSettingsDialog.pcss @@ -49,6 +49,9 @@ limitations under the License. .mx_RoomSettingsDialog_warningIcon::before { mask-image: url("$(res)/img/element-icons/room/settings/advanced.svg"); } +.mx_RoomSettingsDialog_emotesIcon::before { + mask-image: url("$(res)/img/element-icons/room/message-bar/emoji.svg"); +} .mx_RoomSettingsDialog_peopleIcon::before { mask-image: url("$(res)/img/element-icons/group-members.svg"); diff --git a/res/css/views/rooms/_Emotes.pcss b/res/css/views/rooms/_Emotes.pcss new file mode 100644 index 00000000000..248e8dd7828 --- /dev/null +++ b/res/css/views/rooms/_Emotes.pcss @@ -0,0 +1,19 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +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. +*/ + +.mx_Emote { + height: 32px; +} diff --git a/res/css/views/settings/_EmoteSettings.pcss b/res/css/views/settings/_EmoteSettings.pcss new file mode 100644 index 00000000000..e5bbe0b0eaa --- /dev/null +++ b/res/css/views/settings/_EmoteSettings.pcss @@ -0,0 +1,78 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +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. +*/ + +.mx_EmoteSettings { + margin-inline-end: var(--SettingsTab_fullWidthField-margin-inline-end); + border-bottom: 1px solid $quinary-content; + + .mx_EmoteSettings_emoteUpload { + display: none; + } + .mx_EmoteSettings_addEmoteField { + display: flex; + width: 100%; + } + .mx_EmoteSettings_uploadButton { + margin-left: auto; + align-self: center; + } + .mx_EmoteSettings_deleteButton { + margin-left: auto; + align-self: center; + } + .mx_EmoteSettings_uploadedEmoteImage { + height: 30px; + width: var(--emote-image-width) * 30 / var(--emote-image-height); // Sets emote height to 30px and scales the width accordingly + margin-left: 30px; + align-self: center; + } + .mx_EmoteSettings_Emote { + display: flex; + + .mx_EmoteSettings_Emote_controls { + flex-grow: 1; + margin-inline-end: 54px; + + .mx_Field:first-child { + margin-top: 0; + } + + .mx_EmoteSettings_Emote_controls_topic { + & > textarea { + font-family: inherit; + resize: vertical; + } + + &.mx_EmoteSettings_Emote_controls_topic--room textarea { + min-height: 4em; + } + } + + .mx_EmoteSettings_Emote_controls_userId { + margin-inline-end: $spacing-20; + } + } + } + + .mx_EmoteSettings_buttons { + margin-top: 10px; /* 18px is already accounted for by the

above the buttons */ + + > .mx_AccessibleButton_kind_link { + font-size: $font-14px; + margin-inline-end: 10px; + } + } +} diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 78dcd4a1396..538c626b839 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -60,6 +60,8 @@ const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, "i"); const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; +const CUSTOM_EMOTES_REGEX = /:[\w+-]+:/g; + const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/; /* @@ -412,6 +414,7 @@ interface IOpts { returnString?: boolean; forComposerQuote?: boolean; ref?: React.Ref; + emotes?: Map; } export interface IOptsReturnNode extends IOpts { @@ -522,6 +525,12 @@ export function bodyToHtml(content: IContent, highlights: Optional, op // by an attempt to search for 'foobar'. Then again, the search query probably wouldn't work either // XXX: hacky bodge to temporarily apply a textFilter to the sanitizeParams structure. sanitizeParams.textFilter = function (safeText) { + if (opts.emotes) { + return highlighter + .applyHighlights(safeText, safeHighlights!) + .join("") + .replace(CUSTOM_EMOTES_REGEX, (m) => (opts?.emotes?.has(m) ? opts.emotes.get(m)! : m)); + } return highlighter.applyHighlights(safeText, safeHighlights!).join(""); }; } @@ -584,7 +593,12 @@ export function bodyToHtml(content: IContent, highlights: Optional, op "mx_EventTile_bigEmoji": emojiBody, "markdown-body": isHtmlMessage && !emojiBody, }); - + if (opts.emotes) { + const tmp = strippedBody?.replace(CUSTOM_EMOTES_REGEX, (m) => (opts?.emotes?.has(m) ? opts.emotes.get(m)! : m)); + if (tmp !== strippedBody) { + safeBody = tmp; + } + } let emojiBodyElements: JSX.Element[] | undefined; if (!safeBody && bodyHasEmoji) { emojiBodyElements = formatEmojis(strippedBody, false) as JSX.Element[]; diff --git a/src/autocomplete/Components.tsx b/src/autocomplete/Components.tsx index b3ded3f76ad..4bb2d548754 100644 --- a/src/autocomplete/Components.tsx +++ b/src/autocomplete/Components.tsx @@ -59,6 +59,8 @@ export const PillCompletion = forwardRef((props, ref) className, children, "aria-selected": ariaSelectedAttribute, + isEmote, + titleComponent, ...restProps } = props; return ( @@ -70,7 +72,9 @@ export const PillCompletion = forwardRef((props, ref) ref={ref} > {children} - {title} + + {isEmote ? : title} + {subtitle} {description} diff --git a/src/autocomplete/EmojiProvider.tsx b/src/autocomplete/EmojiProvider.tsx index a601b6330db..bdded98f266 100644 --- a/src/autocomplete/EmojiProvider.tsx +++ b/src/autocomplete/EmojiProvider.tsx @@ -22,8 +22,10 @@ import React from "react"; import { uniq, sortBy, uniqBy, ListIteratee } from "lodash"; import EMOTICON_REGEX from "emojibase-regex/emoticon"; import { Room } from "matrix-js-sdk/src/matrix"; +import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue"; import { EMOJI, Emoji, getEmojiFromUnicode } from "@matrix-org/emojibase-bindings"; +import { MatrixClientPeg } from "../MatrixClientPeg"; import { _t } from "../languageHandler"; import AutocompleteProvider from "./AutocompleteProvider"; import QueryMatcher from "./QueryMatcher"; @@ -33,12 +35,16 @@ import SettingsStore from "../settings/SettingsStore"; import { TimelineRenderingType } from "../contexts/RoomContext"; import * as recent from "../emojipicker/recent"; import { filterBoolean } from "../utils/arrays"; +import { decryptFile } from "../utils/DecryptFile"; +import { mediaFromMxc } from "../customisations/Media"; +import { EncryptedFile } from "../customisations/models/IMediaEventContent"; const LIMIT = 20; // Match for ascii-style ";-)" emoticons or ":wink:" shortcodes provided by emojibase // anchored to only match from the start of parts otherwise it'll show emoji suggestions whilst typing matrix IDs const EMOJI_REGEX = new RegExp("(" + EMOTICON_REGEX.source + "|(?:^|\\s):[+-\\w]*:?)$", "g"); +const EMOTES_STATE = new UnstableValue("m.room.emotes", "org.matrix.msc3892.emotes"); interface ISortedEmoji { emoji: Emoji; @@ -80,9 +86,21 @@ export default class EmojiProvider extends AutocompleteProvider { public matcher: QueryMatcher; public nameMatcher: QueryMatcher; private readonly recentlyUsed: Emoji[]; + private emotes: Map = new Map(); + private emotesPromise?: Promise>; public constructor(room: Room, renderingType?: TimelineRenderingType) { super({ commandRegex: EMOJI_REGEX, renderingType }); + const emotesEvent = room?.currentState.getStateEvents(EMOTES_STATE.name, ""); + const rawEmotes = emotesEvent ? emotesEvent.getContent() || {} : {}; + const emotesMap = new Map(); + const customEmotesEnabled = SettingsStore.getValue("feature_custom_emotes"); + for (const shortcode in rawEmotes) { + emotesMap.set(shortcode, rawEmotes[shortcode]); + } + if (room && customEmotesEnabled) { + this.emotesPromise = this.decryptEmotes(emotesMap, room?.roomId); + } this.matcher = new QueryMatcher(SORTED_EMOJI, { keys: [], funcs: [(o) => o.emoji.shortcodes.map((s) => `:${s}:`)], @@ -98,6 +116,25 @@ export default class EmojiProvider extends AutocompleteProvider { this.recentlyUsed = Array.from(new Set(filterBoolean(recent.get().map(getEmojiFromUnicode)))); } + private async decryptEmotes( + emotes: Map, + roomId: string, + ): Promise> { + const decryptedEmoteMap = new Map(); + let decryptedurl = ""; + const isEnc = MatrixClientPeg.get()?.isRoomEncrypted(roomId); + for (const [shortcode, val] of emotes) { + if (isEnc) { + const blob = await decryptFile(val as EncryptedFile); + decryptedurl = URL.createObjectURL(blob); + } else { + decryptedurl = mediaFromMxc(val as string).srcHttp!; + } + decryptedEmoteMap.set(":" + shortcode + ":", decryptedurl); + } + return decryptedEmoteMap; + } + public async getCompletions( query: string, selection: ISelectionRange, @@ -108,6 +145,21 @@ export default class EmojiProvider extends AutocompleteProvider { return []; // don't give any suggestions if the user doesn't want them } + const returnedEmotes = await this.emotesPromise; + if (!returnedEmotes) { + this.emotes = new Map(); + } else { + this.emotes = returnedEmotes; + } + const emojisAndEmotes = [...SORTED_EMOJI]; + for (const [key, val] of this.emotes) { + emojisAndEmotes.push({ + emoji: { label: key, shortcodes: [key], hexcode: key, unicode: val as string, order: 0, group: 0 }, + _orderBy: 0, + }); + } + this.matcher.setObjects(emojisAndEmotes); + this.nameMatcher.setObjects(emojisAndEmotes); let completions: ISortedEmoji[] = []; const { command, range } = this.getCurrentCommand(query, selection); @@ -163,10 +215,15 @@ export default class EmojiProvider extends AutocompleteProvider { completions = uniqBy(completions, "emoji"); return completions.map((c) => ({ - completion: c.emoji.unicode, + completion: this.emotes.get(c.emoji.hexcode) ? c.emoji.hexcode : c.emoji.unicode, component: ( - - {c.emoji.unicode} + + {this.emotes.get(c.emoji.hexcode) ? c.emoji.hexcode : c.emoji.unicode} ), range: range!, diff --git a/src/components/views/dialogs/RoomSettingsDialog.tsx b/src/components/views/dialogs/RoomSettingsDialog.tsx index d251c5c745d..c3a94f1f321 100644 --- a/src/components/views/dialogs/RoomSettingsDialog.tsx +++ b/src/components/views/dialogs/RoomSettingsDialog.tsx @@ -28,6 +28,7 @@ import GeneralRoomSettingsTab from "../settings/tabs/room/GeneralRoomSettingsTab import SecurityRoomSettingsTab from "../settings/tabs/room/SecurityRoomSettingsTab"; import NotificationSettingsTab from "../settings/tabs/room/NotificationSettingsTab"; import BridgeSettingsTab from "../settings/tabs/room/BridgeSettingsTab"; +import EmoteSettingsTab from "../settings/tabs/room/EmoteSettingsTab"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import dis from "../../../dispatcher/dispatcher"; import SettingsStore from "../../../settings/SettingsStore"; @@ -50,6 +51,7 @@ export const enum RoomSettingsTab { Notifications = "ROOM_NOTIFICATIONS_TAB", Bridges = "ROOM_BRIDGES_TAB", Advanced = "ROOM_ADVANCED_TAB", + Emotes = "ROOM_EMOTES_TAB", PollHistory = "ROOM_POLL_HISTORY_TAB", } @@ -193,6 +195,17 @@ class RoomSettingsDialog extends React.Component { ), ); + if (SettingsStore.getValue("feature_custom_emotes")) { + tabs.push( + new Tab( + RoomSettingsTab.Emotes, + _td("Emotes"), + "mx_RoomSettingsDialog_emotesIcon", + , + ), + ); + } + if (SettingsStore.getValue("feature_bridge_state")) { tabs.push( new Tab( diff --git a/src/components/views/emojipicker/Category.tsx b/src/components/views/emojipicker/Category.tsx index ce5e5c81c30..f6f8c35f335 100644 --- a/src/components/views/emojipicker/Category.tsx +++ b/src/components/views/emojipicker/Category.tsx @@ -25,7 +25,7 @@ import { ButtonEvent } from "../elements/AccessibleButton"; const OVERFLOW_ROWS = 3; -export type CategoryKey = keyof typeof DATA_BY_CATEGORY | "recent"; +export type CategoryKey = keyof typeof DATA_BY_CATEGORY | "recent" | "custom"; export interface ICategory { id: CategoryKey; diff --git a/src/components/views/emojipicker/Emoji.tsx b/src/components/views/emojipicker/Emoji.tsx index b210b628594..e443e08e493 100644 --- a/src/components/views/emojipicker/Emoji.tsx +++ b/src/components/views/emojipicker/Emoji.tsx @@ -22,7 +22,7 @@ import { ButtonEvent } from "../elements/AccessibleButton"; import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex"; interface IProps { - emoji: IEmoji; + emoji: EmojiandEmotes; selectedEmojis?: Set; onClick(ev: ButtonEvent, emoji: IEmoji): void; onMouseEnter(emoji: IEmoji): void; @@ -31,7 +31,10 @@ interface IProps { id?: string; role?: string; } - +interface EmojiandEmotes extends IEmoji { + customLabel?: string; // Custom label for custom emotes in emojipicker + customComponent?: JSX.Element; // Custom react component for rendering custom emotes in emojipicker +} class Emoji extends React.PureComponent { public render(): React.ReactNode { const { onClick, onMouseEnter, onMouseLeave, emoji, selectedEmojis } = this.props; @@ -48,7 +51,7 @@ class Emoji extends React.PureComponent { focusOnMouseOver >

- {emoji.unicode} + {emoji.customComponent ?? emoji.unicode}
); diff --git a/src/components/views/emojipicker/EmojiPicker.tsx b/src/components/views/emojipicker/EmojiPicker.tsx index 51b3b777964..0be2ee293ad 100644 --- a/src/components/views/emojipicker/EmojiPicker.tsx +++ b/src/components/views/emojipicker/EmojiPicker.tsx @@ -16,6 +16,8 @@ limitations under the License. */ import React, { Dispatch } from "react"; +import { Room } from "matrix-js-sdk/src/matrix"; +import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue"; import { DATA_BY_CATEGORY, getEmojiFromUnicode, Emoji as IEmoji } from "@matrix-org/emojibase-bindings"; import { _t } from "../../../languageHandler"; @@ -37,6 +39,11 @@ import { Key } from "../../../Keyboard"; import { clamp } from "../../../utils/numbers"; import { ButtonEvent } from "../elements/AccessibleButton"; import { Ref } from "../../../accessibility/roving/types"; +import { mediaFromMxc } from "../../../customisations/Media"; +import { decryptFile } from "../../../utils/DecryptFile"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { EncryptedFile } from "../../../customisations/models/IMediaEventContent"; +import SettingsStore from "../../../settings/SettingsStore"; export const CATEGORY_HEADER_HEIGHT = 20; export const EMOJI_HEIGHT = 35; @@ -44,33 +51,60 @@ export const EMOJIS_PER_ROW = 8; const ZERO_WIDTH_JOINER = "\u200D"; +const EMOTES_STATE = new UnstableValue("m.room.emotes", "org.matrix.msc3892.emotes"); + interface IProps { selectedEmojis?: Set; onChoose(unicode: string): boolean; onFinished(): void; isEmojiDisabled?: (unicode: string) => boolean; + room?: Room; } interface IState { filter: string; - previewEmoji?: IEmoji; + previewEmoji?: EmojiandEmotes; scrollTop: number; // initial estimation of height, dialog is hardcoded to 450px height. // should be enough to never have blank rows of emojis as // 3 rows of overflow are also rendered. The actual value is updated on scroll. viewportHeight: number; } - +interface EmojiandEmotes extends IEmoji { + customLabel?: string; // Custom label for custom emotes in emojipicker + customComponent?: JSX.Element; // Custom react component for rendering custom emotes in emojipicker +} class EmojiPicker extends React.Component { - private readonly recentlyUsed: IEmoji[]; + private recentlyUsed: IEmoji[]; private readonly memoizedDataByCategory: Record; private readonly categories: ICategory[]; private scrollRef = React.createRef>(); + private emotes?: Map; + private emotesPromise?: Promise>; + private finalEmotes: EmojiandEmotes[]; + private finalEmotesMap: Map; public constructor(props: IProps) { super(props); + const emotesEvent = props.room?.currentState.getStateEvents(EMOTES_STATE.name, ""); + const rawEmotes = emotesEvent?.getContent() ?? {}; + const emotesMap = new Map(); + const customEmotesEnabled = SettingsStore.getValue("feature_custom_emotes"); + for (const shortcode in rawEmotes) { + emotesMap.set(shortcode, rawEmotes[shortcode]); + } + if (props.room && customEmotesEnabled) { + this.emotesPromise = this.decryptEmotes(emotesMap, props.room?.roomId); + } + + this.finalEmotes = []; + this.finalEmotesMap = new Map(); + if (props.room && customEmotesEnabled) { + this.loadEmotes(); + } + this.state = { filter: "", scrollTop: 0, @@ -81,9 +115,9 @@ class EmojiPicker extends React.Component { this.recentlyUsed = Array.from(new Set(filterBoolean(recent.get().map(getEmojiFromUnicode)))); this.memoizedDataByCategory = { recent: this.recentlyUsed, + custom: this.finalEmotes, ...DATA_BY_CATEGORY, }; - this.categories = [ { id: "recent", @@ -92,6 +126,13 @@ class EmojiPicker extends React.Component { visible: this.recentlyUsed.length > 0, ref: React.createRef(), }, + { + id: "custom", + name: _t("Custom"), + enabled: customEmotesEnabled, + visible: customEmotesEnabled, + ref: React.createRef(), + }, { id: "people", name: _t("Smileys & People"), @@ -151,6 +192,85 @@ class EmojiPicker extends React.Component { ]; } + private async loadEmotes(): Promise { + this.emotes = await this.emotesPromise; + if (!this.emotes) { + return; + } + for (const [key, val] of this.emotes) { + this.finalEmotes.push({ + label: key, + shortcodes: [key], + hexcode: key, + unicode: ":" + key + ":", + customLabel: key, + customComponent: val, + group: 0, + order: 0, + }); + this.finalEmotesMap.set((":" + key + ":").trim(), { + label: key, + shortcodes: [key], + hexcode: key, + unicode: ":" + key + ":", + customLabel: key, + customComponent: val, + group: 0, + order: 0, + }); + } + + const recentEmotes = Array.from( + new Set( + filterBoolean(recent.get().map((x) => getEmojiFromUnicode(x) ?? this.finalEmotesMap.get(x as string))), + ), + ); + recentEmotes.forEach((v, i) => { + if (this.finalEmotesMap.get(v.unicode)) { + if (i >= this.recentlyUsed.length) { + this.recentlyUsed.push(this.finalEmotesMap.get(v.unicode)!); + } else { + this.recentlyUsed[i] = this.finalEmotesMap.get(v.unicode)!; + } + } else if (getEmojiFromUnicode(v.unicode)) { + if (i >= this.recentlyUsed.length) { + this.recentlyUsed.push(getEmojiFromUnicode(v.unicode)!); + } else { + this.recentlyUsed[i] = getEmojiFromUnicode(v.unicode)!; + } + } + }); + + this.onScroll(); + } + + private async decryptEmotes( + emotes: Map, + roomId: string, + ): Promise> { + const decryptedemotes = new Map(); + let decryptedurl = ""; + const isEnc = MatrixClientPeg.get()?.isRoomEncrypted(roomId); + for (const [shortcode, val] of emotes) { + if (isEnc) { + const blob = await decryptFile(val as EncryptedFile); + decryptedurl = URL.createObjectURL(blob); + } else { + decryptedurl = mediaFromMxc(val as string).srcHttp!; + } + decryptedemotes.set( + shortcode, + {":", + ); + } + return decryptedemotes; + } + private onScroll = (): void => { const body = this.scrollRef.current?.containerRef.current; if (!body) return; @@ -265,6 +385,8 @@ class EmojiPicker extends React.Component { // If the new filter string includes the old filter string, we don't have to re-filter the whole dataset. if (lcFilter.includes(this.state.filter)) { emojis = this.memoizedDataByCategory[cat.id]; + } else if (cat.id === "custom") { + emojis = this.finalEmotes; } else { emojis = cat.id === "recent" ? this.recentlyUsed : DATA_BY_CATEGORY[cat.id]; } @@ -294,6 +416,9 @@ class EmojiPicker extends React.Component { this.memoizedDataByCategory[cat.id] = emojis; cat.enabled = emojis.length > 0; + if (cat.id == "custom") { + cat.enabled = SettingsStore.getValue("feature_custom_emotes"); + } // The setState below doesn't re-render the header and we already have the refs for updateVisibility, so... if (cat.ref.current) { cat.ref.current.disabled = !cat.enabled; diff --git a/src/components/views/emojipicker/Preview.tsx b/src/components/views/emojipicker/Preview.tsx index 08035929278..39a923d5c4a 100644 --- a/src/components/views/emojipicker/Preview.tsx +++ b/src/components/views/emojipicker/Preview.tsx @@ -19,20 +19,24 @@ import React from "react"; import { Emoji } from "@matrix-org/emojibase-bindings"; interface IProps { - emoji: Emoji; + emoji: EmojiandEmotes; +} +interface EmojiandEmotes extends Emoji { + customLabel?: string; // Custom label for custom emotes in emojipicker + customComponent?: JSX.Element; // Custom react component for rendering custom emotes in emojipicker } - class Preview extends React.PureComponent { public render(): React.ReactNode { const { unicode, label, shortcodes: [shortcode], + customComponent, } = this.props.emoji; return (
-
{unicode}
+
{customComponent ?? unicode}
{label}
{shortcode}
diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index 7a36f8c58d3..3ee189196df 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -18,6 +18,7 @@ import React, { createRef, SyntheticEvent, MouseEvent } from "react"; import ReactDOM from "react-dom"; import highlight from "highlight.js"; import { MsgType } from "matrix-js-sdk/src/matrix"; +import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue"; import * as HtmlUtils from "../../../HtmlUtils"; import { formatDate } from "../../../DateUtils"; @@ -49,8 +50,11 @@ import { getParentEventId } from "../../../utils/Reply"; import { EditWysiwygComposer } from "../rooms/wysiwyg_composer"; import { IEventTileOps } from "../rooms/EventTile"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { decryptFile } from "../../../utils/DecryptFile"; +import { mediaFromMxc } from "../../../customisations/Media"; const MAX_HIGHLIGHT_LENGTH = 4096; +const EMOTES_STATE = new UnstableValue("m.room.emotes", "org.matrix.msc3892.emotes"); interface IState { // the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody. @@ -58,6 +62,7 @@ interface IState { // track whether the preview widget is hidden widgetHidden: boolean; + finalEmotes: Map; } export default class TextualBody extends React.Component { @@ -76,6 +81,7 @@ export default class TextualBody extends React.Component { this.state = { links: [], widgetHidden: false, + finalEmotes: new Map(), }; } @@ -86,9 +92,13 @@ export default class TextualBody extends React.Component { } private applyFormatting(): void { - // Function is only called from render / componentDidMount → contentRef is set + if ( + MatrixClientPeg.get()?.getRoom(this.props.mxEvent.getRoomId()) && + SettingsStore.getValue("feature_custom_emotes") + ) { + this.decryptEmotes(); + } const content = this.contentRef.current!; - const showLineNumbers = SettingsStore.getValue("showCodeLineNumbers"); this.activateSpoilers([content]); @@ -563,7 +573,36 @@ export default class TextualBody extends React.Component { } return {`(${text})`}; } + private async decryptEmotes(): Promise { + const client = MatrixClientPeg.safeGet(); + const room = client.getRoom(this.props.mxEvent.getRoomId()); + const emotesEvent = room?.currentState.getStateEvents(EMOTES_STATE.name, ""); + const rawEmotes = emotesEvent ? emotesEvent.getContent() || {} : {}; + const decryptedemotes = new Map(); + let decryptedurl = ""; + const isEnc = client?.isRoomEncrypted(this.props.mxEvent.getRoomId()!); + for (const shortcode in rawEmotes) { + if (isEnc) { + const blob = await decryptFile(rawEmotes[shortcode]); + decryptedurl = URL.createObjectURL(blob); + } else { + decryptedurl = mediaFromMxc(rawEmotes[shortcode]).srcHttp!; + } + decryptedemotes.set( + ":" + shortcode + ":", + "", + ); + } + this.setState({ + finalEmotes: decryptedemotes, + }); + this.forceUpdate(); + } public render(): React.ReactNode { if (this.props.editState) { const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer"); @@ -577,7 +616,6 @@ export default class TextualBody extends React.Component { const content = mxEvent.getContent(); let isNotice = false; let isEmote = false; - // only strip reply if this is the original replying event, edits thereafter do not have the fallback const stripReply = !mxEvent.replacingEvent() && !!getParentEventId(mxEvent); isEmote = content.msgtype === MsgType.Emote; @@ -588,8 +626,8 @@ export default class TextualBody extends React.Component { stripReplyFallback: stripReply, ref: this.contentRef, returnString: false, + emotes: this.state.finalEmotes, }); - if (this.props.replacingEventId) { body = ( <> diff --git a/src/components/views/room_settings/RoomEmoteSettings.tsx b/src/components/views/room_settings/RoomEmoteSettings.tsx new file mode 100644 index 00000000000..debab5789ec --- /dev/null +++ b/src/components/views/room_settings/RoomEmoteSettings.tsx @@ -0,0 +1,519 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +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, { createRef } from "react"; +import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue"; + +import { _t } from "../../../languageHandler"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; +import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds"; +import { uploadFile } from "../../../ContentMessages"; +import { decryptFile } from "../../../utils/DecryptFile"; +import { mediaFromMxc } from "../../../customisations/Media"; +import SettingsFieldset from "../settings/SettingsFieldset"; +import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; +import { EncryptedFile } from "../../../customisations/models/IMediaEventContent"; +const EMOTES_STATE = new UnstableValue("m.room.emotes", "org.matrix.msc3892.emotes"); +const COMPAT_STATE = new UnstableValue( + "m.room.clientemote_compatibility", + "org.matrix.msc3892.clientemote_compatibility", +); +const EMOTES_COMP = new UnstableValue("m.room.room_emotes", "im.ponies.room_emotes"); +const SHORTCODE_REGEX = /[^a-zA-Z0-9_]/g; +interface IProps { + roomId: string; +} + +interface IState { + emotes: Map; + decryptedemotes: Map; + EmoteFieldsTouched: Record; + newEmoteFileAdded: boolean; + newEmoteCodeAdded: boolean; + newEmoteCode: Array; + newEmoteFile: Array; + canAddEmote: boolean; + deleted: boolean; + deletedItems: Map; + value: Map; + compatibility: boolean; +} + +interface compatibilityImagePack { + images: { + [key: string]: { + url?: string; + }; + }; + pack?: { + display_name: string; + }; +} + +export default class RoomEmoteSettings extends React.Component { + private emoteUpload = createRef(); + private emoteCodeUpload = createRef(); + private emoteUploadImage = createRef(); + private imagePack: compatibilityImagePack; + public constructor(props: IProps) { + super(props); + + const client = MatrixClientPeg.safeGet(); + const room = client.getRoom(props.roomId); + if (!room) throw new Error(`Expected a room for ID: ${props.roomId}`); + + const emotesEvent = room.currentState.getStateEvents(EMOTES_STATE.name, ""); + const emotes = emotesEvent ? emotesEvent.getContent() || {} : {}; + const value = new Map(); + const emotesMap = new Map(); + for (const shortcode in emotes) { + value.set(shortcode, shortcode); + emotesMap.set(shortcode, emotes[shortcode]); + } + + const compatEvent = room.currentState.getStateEvents(COMPAT_STATE.name, ""); + const compat = compatEvent ? compatEvent.getContent().isCompat || false : false; + + const imagePackEvent = room.currentState.getStateEvents(EMOTES_COMP.name, "Element Compatible Emotes"); + this.imagePack = imagePackEvent + ? imagePackEvent.getContent() || { images: {}, pack: { display_name: "Element Compatible Emotes" } } + : { images: {}, pack: { display_name: "Element Compatible Emotes" } }; + if (!this.imagePack["images"]) { + this.imagePack["images"] = {}; + } + + if (!this.imagePack["pack"]) { + this.imagePack["pack"] = { display_name: "Element Compatible Emotes" }; + } + + this.state = { + emotes: emotesMap, + decryptedemotes: new Map(), + EmoteFieldsTouched: {}, + newEmoteFileAdded: false, + newEmoteCodeAdded: false, + newEmoteCode: [""], + newEmoteFile: [], + deleted: false, + deletedItems: new Map(), + canAddEmote: room.currentState.maySendStateEvent(EMOTES_STATE.name, client.getSafeUserId()), + value: value, + compatibility: compat, + }; + this.decryptEmotes(client.isRoomEncrypted(props.roomId)); + } + public componentDidMount(): void { + const client = MatrixClientPeg.safeGet(); + this.decryptEmotes(client.isRoomEncrypted(this.props.roomId)); + } + + private uploadEmoteClick = (): void => { + this.emoteUpload.current?.click(); + }; + + private isSaveEnabled = (): boolean => { + return ( + Boolean(Object.values(this.state.EmoteFieldsTouched).length) || + (this.state.newEmoteCodeAdded && this.state.newEmoteFileAdded) || + this.state.deleted + ); + }; + + private cancelEmoteChanges = async (e: ButtonEvent): Promise => { + e.stopPropagation(); + e.preventDefault(); + const value = new Map(); + if (this.state.deleted) { + for (const [key, val] of this.state.deletedItems) { + this.state.emotes.set(key, val); + value.set(key, key); + } + } + document.querySelectorAll(".mx_EmoteSettings_existingEmoteCode").forEach((field) => { + value.set((field as HTMLInputElement).id, (field as HTMLInputElement).id); + }); + if (!this.isSaveEnabled()) return; + this.setState({ + EmoteFieldsTouched: {}, + newEmoteFileAdded: false, + newEmoteCodeAdded: false, + newEmoteCode: [""], + newEmoteFile: [], + deleted: false, + deletedItems: new Map(), + value: value, + }); + }; + private deleteEmote = (e: ButtonEvent): void => { + e.stopPropagation(); + e.preventDefault(); + const cleanemotes = new Map(); + const deletedItems = this.state.deletedItems; + const value = new Map(); + const id = e.currentTarget.getAttribute("id"); + for (const [shortcode, val] of this.state.emotes) { + if (shortcode != id) { + cleanemotes.set(shortcode, val); + value.set(shortcode, shortcode); + } else { + deletedItems.set(shortcode, val); + } + } + + this.setState({ deleted: true, emotes: cleanemotes, deletedItems: deletedItems, value: value }); + }; + private saveEmote = async (e: React.FormEvent): Promise => { + e.stopPropagation(); + e.preventDefault(); + + if (!this.isSaveEnabled()) return; + const client = MatrixClientPeg.safeGet(); + const newState: Partial = {}; + const emotesMxcs: { [key: string]: EncryptedFile | string } = {}; + const value = new Map(); + const newPack: Map> = new Map(); + + if (this.state.emotes || (this.state.newEmoteFileAdded && this.state.newEmoteCodeAdded)) { + if (this.state.newEmoteFileAdded && this.state.newEmoteCodeAdded) { + for (let i = 0; i < this.state.newEmoteCode.length; i++) { + const newEmote = await uploadFile(client, this.props.roomId, this.state.newEmoteFile[i]); + if (client.isRoomEncrypted(this.props.roomId)) { + emotesMxcs[this.state.newEmoteCode[i]] = newEmote.file!; + } else { + emotesMxcs[this.state.newEmoteCode[i]] = newEmote.url!; + } + value.set(this.state.newEmoteCode[i], this.state.newEmoteCode[i]); + if (this.state.compatibility) { + if (client.isRoomEncrypted(this.props.roomId)) { + const compatNewEmote = await client.uploadContent(this.state.newEmoteFile[i]); + newPack.set(this.state.newEmoteCode[i], { url: compatNewEmote.content_uri }); + } else { + newPack.set(this.state.newEmoteCode[i], { url: newEmote.url! }); + } + } + } + } + if (this.state.emotes) { + for (const [shortcode, val] of this.state.emotes) { + if ( + this.state.newEmoteFileAdded && + this.state.newEmoteCodeAdded && + shortcode in this.state.newEmoteCode + ) { + continue; + } + if (this.state.EmoteFieldsTouched.hasOwnProperty(shortcode)) { + emotesMxcs[this.state.EmoteFieldsTouched[shortcode]] = val; + value.set(this.state.EmoteFieldsTouched[shortcode], this.state.EmoteFieldsTouched[shortcode]); + if (this.imagePack["images"][shortcode]) { + newPack.set(this.state.EmoteFieldsTouched[shortcode], { + url: this.imagePack["images"][shortcode]["url"]!, + }); + } + } else { + emotesMxcs[shortcode] = val; + value.set(shortcode, shortcode); + if (this.imagePack["images"][shortcode]) { + newPack.set(shortcode, { url: this.imagePack["images"][shortcode]["url"]! }); + } + } + } + } + newState.value = value; + await client.sendStateEvent(this.props.roomId, EMOTES_STATE.name, emotesMxcs, ""); + this.imagePack = { images: {}, pack: this.imagePack["pack"] }; + for (const [key, val] of newPack) { + this.imagePack["images"][key] = val; + } + + await client.sendStateEvent( + this.props.roomId, + EMOTES_COMP.name, + this.imagePack, + "Element Compatible Emotes", + ); + + newState.newEmoteFileAdded = false; + newState.newEmoteCodeAdded = false; + newState.EmoteFieldsTouched = {}; + newState.emotes = new Map(); + for (const shortcode in emotesMxcs) { + newState.emotes.set(shortcode, emotesMxcs[shortcode]); + } + newState.deleted = false; + newState.deletedItems = new Map(); + newState.newEmoteCode = [""]; + newState.newEmoteFile = []; + } + this.setState(newState as IState); + this.decryptEmotes(client.isRoomEncrypted(this.props.roomId)); + }; + + private onEmoteChange = (e: React.ChangeEvent): void => { + const id = e.target.getAttribute("id")!; + const b = this.state.value; + b.set(id, e.target?.value?.replace(SHORTCODE_REGEX, "")); + this.setState({ + value: b, + EmoteFieldsTouched: { ...this.state.EmoteFieldsTouched, [id]: e.target.value.replace(SHORTCODE_REGEX, "") }, + }); + }; + + private onEmoteFileAdd = (e: React.ChangeEvent): void => { + if (!e.target.files || !e.target.files.length) { + this.setState({ + newEmoteFileAdded: false, + EmoteFieldsTouched: { + ...this.state.EmoteFieldsTouched, + }, + }); + return; + } + + const uploadedFiles: Array = []; + const newCodes: string[] = []; + for (const file of e.target.files) { + const fileName = file.name.replace(/\.[^.]*$/, ""); + uploadedFiles.push(file); + newCodes.push(fileName); + } + this.setState({ + newEmoteCodeAdded: true, + newEmoteFileAdded: true, + newEmoteCode: newCodes, + newEmoteFile: uploadedFiles, + EmoteFieldsTouched: { + ...this.state.EmoteFieldsTouched, + }, + }); + }; + private onEmoteCodeAdd = (e: React.ChangeEvent): void => { + if (e.target.value.replace(SHORTCODE_REGEX, "").length > 0) { + const updatedCode = this.state.newEmoteCode; + updatedCode[parseInt(e.target.getAttribute("data-index")!)] = e.target.value.replace(SHORTCODE_REGEX, ""); + this.setState({ + newEmoteCodeAdded: true, + newEmoteCode: updatedCode, + EmoteFieldsTouched: { + ...this.state.EmoteFieldsTouched, + }, + }); + } else { + const updatedCode = this.state.newEmoteCode; + updatedCode[parseInt(e.target.getAttribute("data-index")!)] = e.target.value.replace(SHORTCODE_REGEX, ""); + this.setState({ + newEmoteCodeAdded: false, + newEmoteCode: updatedCode, + }); + } + }; + + private onCompatChange = async (allowed: boolean): Promise => { + const client = MatrixClientPeg.safeGet(); + await client.sendStateEvent(this.props.roomId, COMPAT_STATE.name, { isCompat: allowed }, ""); + + if (allowed) { + for (const [shortcode, val] of this.state.emotes) { + if (!this.imagePack["images"][shortcode]) { + if (client.isRoomEncrypted(this.props.roomId)) { + const blob = await decryptFile(val as EncryptedFile); + const uploadedEmote = await client.uploadContent(blob); + this.imagePack["images"][shortcode] = { url: uploadedEmote.content_uri }; + } else { + this.imagePack["images"][shortcode] = { url: val as string }; + } + } + } + + await client.sendStateEvent( + this.props.roomId, + EMOTES_COMP.name, + this.imagePack, + "Element Compatible Emotes", + ); + } + + this.setState({ + compatibility: allowed, + }); + }; + private async decryptEmotes(isEnc: boolean): Promise { + const decryptedemotes = new Map(); + for (const [shortcode, val] of this.state.emotes) { + if (isEnc) { + const blob = await decryptFile(val as EncryptedFile); + decryptedemotes.set(shortcode, URL.createObjectURL(blob)); + } else { + decryptedemotes.set(shortcode, mediaFromMxc(val as string).srcHttp); + } + } + if (this.state.compatibility) { + const client = MatrixClientPeg.safeGet(); + let newCompatUploaded = false; + for (const shortcode in this.imagePack["images"]) { + if (!decryptedemotes.has(shortcode)) { + newCompatUploaded = true; + this.state.value.set(shortcode, shortcode); + decryptedemotes.set( + shortcode, + mediaFromMxc(this.imagePack["images"][shortcode]["url"] as string).srcHttp, + ); + if (isEnc) { + const blob = await mediaFromMxc(this.imagePack["images"][shortcode]["url"]) + .downloadSource() + .then((r) => r.blob()); + const uploadedEmoteFile = await uploadFile(client, this.props.roomId, blob); + this.state.emotes.set(shortcode, uploadedEmoteFile.file!); + } else { + this.state.emotes.set(shortcode, this.imagePack["images"][shortcode]["url"]!); + } + } + } + if (newCompatUploaded) { + const emotesMxcs: { [key: string]: EncryptedFile | string } = {}; + for (const [shortcode, val] of this.state.emotes) { + emotesMxcs[shortcode] = val; + } + client.sendStateEvent(this.props.roomId, EMOTES_STATE.name, emotesMxcs, ""); + } + } + this.setState({ + decryptedemotes: decryptedemotes, + }); + } + public render(): JSX.Element { + let emoteSettingsButtons; + if (this.state.canAddEmote) { + emoteSettingsButtons = ( +
+ + {_t("Cancel")} + + + {_t("Save")} + +
+ ); + } + + const existingEmotes: Array = []; + if (this.state.emotes) { + for (const emotecode of Array.from(this.state.emotes.keys()).sort(function (a, b) { + return a.localeCompare(b); + })) { + existingEmotes.push( +
  • + + {":" +
    + + {_t("Delete")} + +
    +
  • , + ); + } + } + + let emoteUploadButton; + if (this.state.canAddEmote) { + emoteUploadButton = ( +
    + + {_t("Upload Emote")} + +
    + ); + } + + const uploadedEmotes: Array = []; + for (let i = 0; i < this.state.newEmoteCode.length; i++) { + const fileUrl = this.state.newEmoteFile[i] ? URL.createObjectURL(this.state.newEmoteFile[i]) : ""; + uploadedEmotes.push( +
  • + + {this.state.newEmoteFileAdded ? ( + {":" + ) : null} + + {i == 0 ? emoteUploadButton : null} +
  • , + ); + } + const isCompat = this.state.compatibility; + return ( +
    + + {emoteSettingsButtons} + + + + {uploadedEmotes} + {existingEmotes} +
    + ); + } +} diff --git a/src/components/views/rooms/EmojiButton.tsx b/src/components/views/rooms/EmojiButton.tsx index 6139043a16c..1d588941b91 100644 --- a/src/components/views/rooms/EmojiButton.tsx +++ b/src/components/views/rooms/EmojiButton.tsx @@ -16,6 +16,7 @@ limitations under the License. import classNames from "classnames"; import React, { useContext } from "react"; +import { Room } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../languageHandler"; import ContextMenu, { aboveLeftOf, MenuProps, useContextMenu } from "../../structures/ContextMenu"; @@ -27,9 +28,10 @@ interface IEmojiButtonProps { addEmoji: (unicode: string) => boolean; menuPosition?: MenuProps; className?: string; + room?: Room; } -export function EmojiButton({ addEmoji, menuPosition, className }: IEmojiButtonProps): JSX.Element { +export function EmojiButton({ addEmoji, menuPosition, className, room }: IEmojiButtonProps): JSX.Element { const overflowMenuCloser = useContext(OverflowMenuContext); const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); @@ -43,7 +45,7 @@ export function EmojiButton({ addEmoji, menuPosition, className }: IEmojiButtonP contextMenu = ( - + ); } diff --git a/src/components/views/rooms/MessageComposerButtons.tsx b/src/components/views/rooms/MessageComposerButtons.tsx index 41fd8291f9f..fc66f21340b 100644 --- a/src/components/views/rooms/MessageComposerButtons.tsx +++ b/src/components/views/rooms/MessageComposerButtons.tsx @@ -82,7 +82,7 @@ const MessageComposerButtons: React.FC = (props: IProps) => { onClick={props.onComposerModeClick} /> ) : ( - emojiButton(props) + emojiButton(props, room) ), ]; moreButtons = [ @@ -102,7 +102,7 @@ const MessageComposerButtons: React.FC = (props: IProps) => { onClick={props.onComposerModeClick} /> ) : ( - emojiButton(props) + emojiButton(props, room) ), uploadButton(), // props passed via UploadButtonContext ]; @@ -150,12 +150,13 @@ const MessageComposerButtons: React.FC = (props: IProps) => { ); }; -function emojiButton(props: IProps): ReactElement { +function emojiButton(props: IProps, room: Room): ReactElement { return ( ); diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 14d09fd83dc..210f189c8fb 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -30,6 +30,7 @@ import { import { DebouncedFunc, throttle } from "lodash"; import { logger } from "matrix-js-sdk/src/logger"; import { Composer as ComposerEvent } from "@matrix-org/analytics-events/types/typescript/Composer"; +import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue"; import dis from "../../../dispatcher/dispatcher"; import EditorModel from "../../../editor/model"; @@ -83,6 +84,14 @@ import { getBlobSafeMimeType } from "../../../utils/blobs"; * @param replyToEvent - The event being replied to or undefined if it is not a reply. * @param editedContent - The content of the parent event being edited. */ + +const COMPAT_STATE = new UnstableValue( + "m.room.clientemote_compatibility", + "org.matrix.msc3892.clientemote_compatibility", +); +const EMOTES_COMP = new UnstableValue("m.room.room_emotes", "im.ponies.room_emotes"); +const EMOTES_REGEX = /:[\w+-]+:/g; +const SHORTCODE_REGEX = /[^a-zA-Z0-9_]/g; export function attachMentions( sender: string, content: IContent, @@ -184,6 +193,8 @@ export function createMessageContent( relation: IEventRelation | undefined, permalinkCreator?: RoomPermalinkCreator, includeReplyLegacyFallback = true, + emotes?: Map, + compat?: boolean, ): IContent { const isEmote = containsEmote(model); if (isEmote) { @@ -196,17 +207,30 @@ export function createMessageContent( const body = textSerialize(model); + let emoteBody: string | undefined; + const customEmotesEnabled = SettingsStore.getValue("feature_custom_emotes"); + if (compat && emotes && customEmotesEnabled) { + emoteBody = body.replace(EMOTES_REGEX, (m) => (emotes.get(m) ? emotes.get(m)! : m)); + } const content: IContent = { msgtype: isEmote ? MsgType.Emote : MsgType.Text, body: body, }; - const formattedBody = htmlSerializeIfNeeded(model, { + let formattedBody = htmlSerializeIfNeeded(model, { forceHTML: !!replyToEvent, useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"), }); if (formattedBody) { + if (compat && emotes && customEmotesEnabled) { + formattedBody = formattedBody.replace(EMOTES_REGEX, (m) => (emotes.has(m) ? emotes.get(m)! : m)); + } content.format = "org.matrix.custom.html"; content.formatted_body = formattedBody; + } else if (compat && customEmotesEnabled) { + if (body != emoteBody) { + content.format = "org.matrix.custom.html"; + content.formatted_body = emoteBody; + } } // Build the mentions property and add it to the event content. @@ -252,6 +276,14 @@ interface ISendMessageComposerProps extends MatrixClientProps { toggleStickerPickerOpen: () => void; } +interface compatibilityImagePack { + images: { + [key: string]: { + url: string; + }; + }; +} + export class SendMessageComposer extends React.Component { public static contextType = RoomContext; public context!: React.ContextType; @@ -262,7 +294,9 @@ export class SendMessageComposer extends React.Component; + private compat: boolean; public static defaultProps = { includeReplyLegacyFallback: true, }; @@ -288,6 +322,30 @@ export class SendMessageComposer extends React.Component(); + if (!this.imagePack["images"]) { + this.imagePack["images"] = {}; + } + + for (const shortcode in this.imagePack["images"]) { + this.emotes.set( + `:${shortcode.replace(SHORTCODE_REGEX, "")}:`, + `:${shortcode.replace(SHORTCODE_REGEX, "")}:`, + ); + } } public componentDidUpdate(prevProps: ISendMessageComposerProps): void { @@ -540,6 +598,8 @@ export class SendMessageComposer extends React.Component { + public constructor(props: IProps, context: ContextType) { + super(props, context); + + this.state = { + isRoomPublished: false, // loaded async + }; + } + + public render(): JSX.Element { + return ( +
    +
    {_t("Emotes")}
    +
    + +
    +
    + ); + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index df6f023a735..bf870e919f8 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -874,6 +874,7 @@ "Notification Settings": "Notification Settings", "Introducing a simpler way to change your notification settings. Customize your %(brand)s, just the way you like.": "Introducing a simpler way to change your notification settings. Customize your %(brand)s, just the way you like.", "In rooms that support moderation, the “Report” button will let you report abuse to room moderators.": "In rooms that support moderation, the “Report” button will let you report abuse to room moderators.", + "Support custom emotes of MSC3892 and MSC2545": "Support custom emotes of MSC3892 and MSC2545", "Show current profile picture and name for users in message history": "Show current profile picture and name for users in message history", "Send read receipts": "Send read receipts", "Your server doesn't support disabling sending read receipts.": "Your server doesn't support disabling sending read receipts.", @@ -1483,6 +1484,7 @@ "This room is bridging messages to the following platforms. Learn more.": "This room is bridging messages to the following platforms. Learn more.", "This room isn't bridging messages to any platforms. Learn more.": "This room isn't bridging messages to any platforms. Learn more.", "Bridges": "Bridges", + "Emotes": "Emotes", "Room Addresses": "Room Addresses", "Uploaded sound": "Uploaded sound", "Get notifications as set up in your settings": "Get notifications as set up in your settings", @@ -2045,6 +2047,13 @@ "Set addresses for this space so users can find this space through your homeserver (%(localDomain)s)": "Set addresses for this space so users can find this space through your homeserver (%(localDomain)s)", "Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)": "Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)", "Show more": "Show more", + "Cancel": "Cancel", + "Save": "Save", + "Delete": "Delete", + "Upload Emote": "Upload Emote", + "Emote Compatibility on Other Clients": "Emote Compatibility on Other Clients", + "This will allow emotes to be sent via MSC2545 which enables compatibility with clients that use this spec. Emote images will be stored on the server unencrypted.": "This will allow emotes to be sent via MSC2545 which enables compatibility with clients that use this spec. Emote images will be stored on the server unencrypted.", + "Compatibility": "Compatibility", "Room Name": "Room Name", "Room Topic": "Room Topic", "Room avatar": "Room avatar", @@ -2334,6 +2343,7 @@ "Drop a Pin": "Drop a Pin", "What location type do you want to share?": "What location type do you want to share?", "Frequently Used": "Frequently Used", + "Custom": "Custom", "Smileys & People": "Smileys & People", "Animals & Nature": "Animals & Nature", "Food & Drink": "Food & Drink", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 55651f3e48c..e7413a84ff3 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -271,6 +271,13 @@ export const SETTINGS: { [setting: string]: ISetting } = { supportedLevels: LEVELS_FEATURE, default: false, }, + "feature_custom_emotes": { + isFeature: true, + labsGroup: LabGroup.Messaging, + displayName: _td("Support custom emotes of MSC3892 and MSC2545"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "feature_latex_maths": { isFeature: true, labsGroup: LabGroup.Messaging, diff --git a/test/autocomplete/EmojiProvider-test.ts b/test/autocomplete/EmojiProvider-test.ts index a18951ce7cc..af423d1890d 100644 --- a/test/autocomplete/EmojiProvider-test.ts +++ b/test/autocomplete/EmojiProvider-test.ts @@ -14,11 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { mocked } from "jest-mock"; +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue"; + import EmojiProvider from "../../src/autocomplete/EmojiProvider"; import { mkStubRoom } from "../test-utils/test-utils"; import { add } from "../../src/emojipicker/recent"; import { stubClient } from "../test-utils"; import { MatrixClientPeg } from "../../src/MatrixClientPeg"; +import SettingsStore from "../../src/settings/SettingsStore"; const EMOJI_SHORTCODES = [ ":+1", @@ -40,6 +45,7 @@ const EMOJI_SHORTCODES = [ // This means that we cannot compare their autocompletion before and after the ending `:` and have // to simply assert that the final completion with the colon is the exact emoji. const TOO_SHORT_EMOJI_SHORTCODE = [{ emojiShortcode: ":o", expectedEmoji: "⭕️" }]; +const EMOTES_STATE = new UnstableValue("m.room.emotes", "org.matrix.msc3892.emotes"); describe("EmojiProvider", function () { const testRoom = mkStubRoom(undefined, undefined, undefined); @@ -95,4 +101,34 @@ describe("EmojiProvider", function () { expect(completionsList[1]?.component?.props.title).toEqual(":heartpulse:"); expect(completionsList[2]?.component?.props.title).toEqual(":heart_eyes:"); }); + + it("loads and returns custom emotes", async function () { + const cli = stubClient(); + jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); + mocked(cli.getRoom).mockReturnValue(testRoom); + mocked(cli.isRoomEncrypted).mockReturnValue(false); + + // @ts-ignore - mocked doesn't support overloads properly + mocked(testRoom.currentState.getStateEvents).mockImplementation((type, key) => { + if (key === undefined) return [] as MatrixEvent[]; + if (type === EMOTES_STATE.name) { + return new MatrixEvent({ + sender: "@sender:server", + room_id: testRoom.roomId, + type: EMOTES_STATE.name, + state_key: "", + content: { + testEmote: "abcde/custom-emote-123.png", + }, + }); + } + return null; + }); + + const ep = new EmojiProvider(testRoom); + const completionsList = await ep.getCompletions(":testEmote", { beginning: true, start: 0, end: 6 }); + expect(completionsList[0]?.component?.props.titleComponent).toEqual( + "http://this.is.a.url/custom-emote-123.png", + ); + }); }); diff --git a/test/components/views/emojipicker/EmojiPicker-test.tsx b/test/components/views/emojipicker/EmojiPicker-test.tsx index de2469ced69..7bd8b56fbe8 100644 --- a/test/components/views/emojipicker/EmojiPicker-test.tsx +++ b/test/components/views/emojipicker/EmojiPicker-test.tsx @@ -17,10 +17,19 @@ limitations under the License. import React, { createRef } from "react"; import { render } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { mocked } from "jest-mock"; +import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue"; +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { mkStubRoom, stubClient } from "../../../test-utils"; import EmojiPicker from "../../../../src/components/views/emojipicker/EmojiPicker"; -import { stubClient } from "../../../test-utils"; - +import { Media, mediaFromMxc } from "../../../../src/customisations/Media"; +import SettingsStore from "../../../../src/settings/SettingsStore"; +import * as recent from "../../../../src/emojipicker/recent"; +jest.mock("../../../../src/customisations/Media", () => ({ + mediaFromMxc: jest.fn(), +})); +const EMOTES_STATE = new UnstableValue("m.room.emotes", "org.matrix.msc3892.emotes"); describe("EmojiPicker", function () { stubClient(); @@ -98,4 +107,42 @@ describe("EmojiPicker", function () { expect(onChoose).toHaveBeenCalledWith("📫️"); expect(onFinished).toHaveBeenCalled(); }); + it("should load custom emotes", async () => { + const cli = stubClient(); + const room = mkStubRoom("!roomId:server", "Room", cli); + jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); + mocked(cli.getRoom).mockReturnValue(room); + mocked(cli.isRoomEncrypted).mockReturnValue(false); + mocked(mediaFromMxc).mockReturnValue({ + srcHttp: "http://this.is.a.url/server/custom-emote-123.png", + } as Media); + jest.spyOn(recent, "get").mockReturnValue([":testEmote:", "😀"]); + const ref = createRef(); + + // @ts-ignore - mocked doesn't support overloads properly + mocked(room.currentState.getStateEvents).mockImplementation((type, key) => { + if (key === undefined) return [] as MatrixEvent[]; + if (type === EMOTES_STATE.name) { + return new MatrixEvent({ + sender: "@sender:server", + room_id: room.roomId, + type: EMOTES_STATE.name, + state_key: "", + content: { + testEmote: "http://this.is.a.url/server/custom-emote-123.png", + }, + }); + } + return null; + }); + const { container } = render( + false} onFinished={jest.fn()} room={room} />, + ); + await new Promise(process.nextTick); + + const customCategory = container.querySelector("#mx_EmojiPicker_category_custom"); + if (!customCategory) { + throw new Error("custom emote not in emojipicker"); + } + }); }); diff --git a/test/components/views/messages/TextualBody-test.tsx b/test/components/views/messages/TextualBody-test.tsx index a6b22a74123..1b486f79810 100644 --- a/test/components/views/messages/TextualBody-test.tsx +++ b/test/components/views/messages/TextualBody-test.tsx @@ -19,8 +19,9 @@ import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { mocked, MockedObject } from "jest-mock"; import { render } from "@testing-library/react"; import * as prettier from "prettier"; +import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue"; -import { getMockClientWithEventEmitter, mkEvent, mkMessage, mkStubRoom } from "../../../test-utils"; +import { getMockClientWithEventEmitter, mkEvent, mkMessage, mkStubRoom, stubClient } from "../../../test-utils"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import * as languageHandler from "../../../../src/languageHandler"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; @@ -28,10 +29,12 @@ import TextualBody from "../../../../src/components/views/messages/TextualBody"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks"; import { MediaEventHelper } from "../../../../src/utils/MediaEventHelper"; +import SettingsStore from "../../../../src/settings/SettingsStore"; const room1Id = "!room1:example.com"; const room2Id = "!room2:example.com"; const room2Name = "Room 2"; +const EMOTES_STATE = new UnstableValue("m.room.emotes", "org.matrix.msc3892.emotes"); interface MkRoomTextMessageOpts { roomId?: string; @@ -89,6 +92,7 @@ describe("", () => { getAccountData: (): MatrixEvent | undefined => undefined, isGuest: () => false, mxcUrlToHttp: (s: string) => s, + isRoomEncrypted: (roomId: string) => false, getUserId: () => "@user:example.com", fetchRoomEvent: () => { throw new Error("MockClient event not found"); @@ -108,6 +112,7 @@ describe("", () => { highlightLink: "", onMessageAllowed: jest.fn(), onHeightChanged: jest.fn(), + isRoomEncrypted: jest.fn().mockReturnValue(false), permalinkCreator: new RoomPermalinkCreator(defaultRoom), mediaEventHelper: {} as MediaEventHelper, }; @@ -262,6 +267,7 @@ describe("", () => { on: (): void => undefined, removeListener: (): void => undefined, isGuest: () => false, + isRoomEncrypted: (roomId: string) => false, mxcUrlToHttp: (s: string) => s, }); DMRoomMap.makeShared(defaultMatrixClient); @@ -396,6 +402,35 @@ describe("", () => { '' + "escaped *markdown*" + "", ); }); + it("renders custom emote", () => { + const cli = stubClient(); + const room = mkStubRoom("!roomId:server", "Room", cli); + jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); + mocked(cli.getRoom).mockReturnValue(room); + mocked(cli.isRoomEncrypted).mockReturnValue(false); + // @ts-ignore - mocked doesn't support overloads properly + mocked(room.currentState.getStateEvents).mockImplementation((type, key) => { + if (key === undefined) return [] as MatrixEvent[]; + if (type === EMOTES_STATE.name) { + return new MatrixEvent({ + sender: "@sender:server", + room_id: room.roomId, + type: EMOTES_STATE.name, + state_key: "", + content: { + testEmote: "http://this.is.a.url/server/custom-emote-123.png", + }, + }); + } + return null; + }); + const ev = mkRoomTextMessage("this is a plaintext message with a :testEmote:"); + const { container } = getComponent({ mxEvent: ev }); + const emote = container.querySelector("img.mx_Emote"); + if (!emote) { + throw new Error("custom emote not rendering"); + } + }); }); it("renders url previews correctly", () => { @@ -406,6 +441,7 @@ describe("", () => { getAccountData: (): MatrixClient | undefined => undefined, getUrlPreview: (url: string) => new Promise(() => {}), isGuest: () => false, + isRoomEncrypted: (roomId: string) => false, mxcUrlToHttp: (s: string) => s, }); DMRoomMap.makeShared(defaultMatrixClient); diff --git a/test/components/views/rooms/EditMessageComposer-test.tsx b/test/components/views/rooms/EditMessageComposer-test.tsx index 89cb09dd727..b3eef9f53d6 100644 --- a/test/components/views/rooms/EditMessageComposer-test.tsx +++ b/test/components/views/rooms/EditMessageComposer-test.tsx @@ -48,6 +48,7 @@ describe("", () => { ...mockClientMethodsUser(userId), getRoom: jest.fn(), sendMessage: jest.fn(), + isRoomEncrypted: jest.fn(), }); const room = new Room(roomId, mockClient, userId); diff --git a/test/components/views/rooms/PinnedEventTile-test.tsx b/test/components/views/rooms/PinnedEventTile-test.tsx index 7febe0b4bd3..ccaf341c2b3 100644 --- a/test/components/views/rooms/PinnedEventTile-test.tsx +++ b/test/components/views/rooms/PinnedEventTile-test.tsx @@ -27,6 +27,7 @@ describe("", () => { const roomId = "!room:server.org"; const mockClient = getMockClientWithEventEmitter({ getRoom: jest.fn(), + isRoomEncrypted: jest.fn(), }); const room = new Room(roomId, mockClient, userId); const permalinkCreator = new RoomPermalinkCreator(room); diff --git a/test/components/views/rooms/SendMessageComposer-test.tsx b/test/components/views/rooms/SendMessageComposer-test.tsx index 00c6d6714d2..07f584e2b3f 100644 --- a/test/components/views/rooms/SendMessageComposer-test.tsx +++ b/test/components/views/rooms/SendMessageComposer-test.tsx @@ -16,9 +16,10 @@ limitations under the License. import React from "react"; import { fireEvent, render, waitFor } from "@testing-library/react"; -import { IContent, MatrixClient, MsgType } from "matrix-js-sdk/src/matrix"; +import { IContent, MatrixClient, MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix"; import { mocked } from "jest-mock"; import userEvent from "@testing-library/user-event"; +import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue"; import SendMessageComposer, { attachMentions, @@ -44,7 +45,11 @@ import SettingsStore from "../../../../src/settings/SettingsStore"; jest.mock("../../../../src/utils/local-room", () => ({ doMaybeLocalRoomAction: jest.fn(), })); - +const EMOTES_COMP = new UnstableValue("m.room.room_emotes", "im.ponies.room_emotes"); +const COMPAT_STATE = new UnstableValue( + "m.room.clientemote_compatibility", + "org.matrix.msc3892.clientemote_compatibility", +); describe("", () => { const defaultRoomContext: IRoomState = { roomLoading: true, @@ -603,4 +608,99 @@ describe("", () => { await userEvent.type(composer, "Hello"); expect(cli.prepareToEncrypt).toHaveBeenCalled(); }); + + it("should load compatible emotes and replace them in messages when compatibility is on", async () => { + const cli = stubClient(); + const room = mkStubRoom("!roomId:server", "Room", cli); + jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => { + if (settingName === "feature_custom_emotes") { + return true; + } + }); + mocked(cli.getRoom).mockReturnValue(room); + mocked(cli.isRoomEncrypted).mockReturnValue(false); + // @ts-ignore - mocked doesn't support overloads properly + mocked(room.currentState.getStateEvents).mockImplementation((type, key) => { + if (key === undefined) return [] as MatrixEvent[]; + if (type === EMOTES_COMP.name) { + return new MatrixEvent({ + sender: "@sender:server", + room_id: room.roomId, + type: EMOTES_COMP.name, + state_key: "Element Compatible Emotes", + content: { + images: { + testEmote: { + url: "http://this.is.a.url/server/custom-emote-123.png", + }, + }, + }, + }); + } + if (type === COMPAT_STATE.name) { + return new MatrixEvent({ + sender: "@sender:server", + room_id: room.roomId, + type: EMOTES_COMP.name, + state_key: "", + content: { + isCompat: true, + }, + }); + } + + return null; + }); + + render( + + + , + ); + + const permalinkCreator = jest.fn() as any; + const model = new EditorModel([], createPartCreator()); + const documentOffset = new DocumentOffset(30, true); + model.update(":testEmote: and some text", "insertText", documentOffset); + + const emoteMap = new Map(); + emoteMap.set( + ":testEmote:", + ":testEmote:", + ); + + let content = createMessageContent( + "@alice:test", + model, + undefined, + undefined, + permalinkCreator, + undefined, + emoteMap, + true, + ); + + expect(content).toEqual({ + body: ":testEmote: and some text", + msgtype: "m.text", + format: "org.matrix.custom.html", + formatted_body: + ":testEmote: and some text", + }); + + content = createMessageContent( + "@alice:test", + model, + undefined, + undefined, + permalinkCreator, + undefined, + emoteMap, + false, + ); + expect(content).toEqual({ + body: ":testEmote: and some text", + msgtype: "m.text", + }); + }); }); diff --git a/test/components/views/settings/tabs/room/EmoteSettingsTab-test.tsx b/test/components/views/settings/tabs/room/EmoteSettingsTab-test.tsx new file mode 100644 index 00000000000..5c908051174 --- /dev/null +++ b/test/components/views/settings/tabs/room/EmoteSettingsTab-test.tsx @@ -0,0 +1,429 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +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 { fireEvent, render, RenderResult, screen, waitFor } from "@testing-library/react"; +import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { mocked } from "jest-mock"; +import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue"; + +import EmoteRoomSettingsTab from "../../../../../../src/components/views/settings/tabs/room/EmoteSettingsTab"; +import { mkStubRoom, withClientContextRenderOptions, stubClient } from "../../../../../test-utils"; +import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg"; +import { uploadFile } from "../../../../../../src/ContentMessages"; + +jest.mock("../../../../../../src/ContentMessages", () => ({ + uploadFile: jest.fn(), +})); + +describe("EmoteSettingsTab", () => { + const EMOTES_STATE = new UnstableValue("m.room.emotes", "org.matrix.msc3892.emotes"); + const EMOTES_COMP = new UnstableValue("m.room.room_emotes", "im.ponies.room_emotes"); + const COMPAT_STATE = new UnstableValue( + "m.room.clientemote_compatibility", + "org.matrix.msc3892.clientemote_compatibility", + ); + const roomId = "!room:example.com"; + let cli: MatrixClient; + let room: Room; + let newemotefile: File; + const renderTab = (propRoom: Room = room): RenderResult => { + return render(, withClientContextRenderOptions(cli)); + }; + + beforeEach(() => { + stubClient(); + cli = MatrixClientPeg.safeGet(); + room = mkStubRoom(roomId, "test room", cli); + newemotefile = new File(["(⌐□_□)"], "coolnewemote.png", { type: "image/png" }); + mocked(uploadFile).mockResolvedValue({ + url: "http://this.is.a.url/server/custom-emote-123.png", + }); + }); + + it("should allow an Admin to upload emotes", () => { + mocked(cli.getRoom).mockReturnValue(room); + mocked(room.currentState.maySendStateEvent).mockReturnValue(true); + const tab = renderTab(); + + const editEmotesButton = tab.container.querySelector("div.mx_EmoteSettings_uploadButton"); + if (!editEmotesButton) { + throw new Error("upload emote button does not exist."); + } + }); + + it("should not let non-admin upload emotes", () => { + mocked(cli.getRoom).mockReturnValue(room); + mocked(room.currentState.maySendStateEvent).mockReturnValue(false); + const tab = renderTab(); + const editEmotesButton = tab.container.querySelector("div.mx_EmoteSettings_uploadButton"); + if (editEmotesButton) { + throw new Error("upload emote button exists for non-permissioned user."); + } + }); + + it("should load emotes", () => { + mocked(cli.getRoom).mockReturnValue(room); + mocked(cli.isRoomEncrypted).mockReturnValue(false); + // @ts-ignore - mocked doesn't support overloads properly + mocked(room.currentState.getStateEvents).mockImplementation((type, key) => { + if (key === undefined) return [] as MatrixEvent[]; + if (type === EMOTES_STATE.name) { + return new MatrixEvent({ + sender: "@sender:server", + room_id: roomId, + type: EMOTES_STATE.name, + state_key: "", + content: { + testEmote: "http://this.is.a.url/server/custom-emote-123.png", + }, + }); + } + return null; + }); + const tab = renderTab(); + const emotefield = tab.container.querySelector("input.mx_EmoteSettings_existingEmoteCode"); + if (!emotefield) { + throw new Error("emote isn't loading"); + } + }); + + it("should delete when delete is clicked and restore emotes when cancel is clicked", () => { + mocked(cli.getRoom).mockReturnValue(room); + mocked(cli.isRoomEncrypted).mockReturnValue(false); + // @ts-ignore - mocked doesn't support overloads properly + mocked(room.currentState.getStateEvents).mockImplementation((type, key) => { + if (key === undefined) return [] as MatrixEvent[]; + if (type === EMOTES_STATE.name) { + return new MatrixEvent({ + sender: "@sender:server", + room_id: roomId, + type: EMOTES_STATE.name, + state_key: "", + content: { + testEmote: "http://this.is.a.url/server/custom-emote-123.png", + anotherEmote: "http://this.is.a.url/server/custom-emote-123.png", + }, + }); + } + return null; + }); + const tab = renderTab(); + + fireEvent.click(screen.getAllByText("Delete")[0]); + let emotefieldnum = tab.container.querySelectorAll("input.mx_EmoteSettings_existingEmoteCode").length; + if (emotefieldnum > 1) { + throw new Error("not deleting"); + } + fireEvent.click(screen.getByText("Cancel")); + emotefieldnum = tab.container.querySelectorAll("input.mx_EmoteSettings_existingEmoteCode").length; + if (emotefieldnum < 2) { + throw new Error("not restoring when cancel is clicked"); + } + }); + + it("should save edits to emotes", () => { + mocked(cli.getRoom).mockReturnValue(room); + mocked(cli.isRoomEncrypted).mockReturnValue(false); + // @ts-ignore - mocked doesn't support overloads properly + mocked(room.currentState.getStateEvents).mockImplementation((type, key) => { + if (key === undefined) return [] as MatrixEvent[]; + if (type === EMOTES_STATE.name) { + return new MatrixEvent({ + sender: "@sender:server", + room_id: roomId, + type: EMOTES_STATE.name, + state_key: "", + content: { + testEmote: "http://this.is.a.url/server/custom-emote-123.png", + }, + }); + } + return null; + }); + + const tab = renderTab(); + + fireEvent.change(tab.container.querySelector("input.mx_EmoteSettings_existingEmoteCode")!, { + target: { value: "changed" }, + }); + fireEvent.click(screen.getByText("Save")); + expect(cli.sendStateEvent).toHaveBeenCalledWith( + roomId, + EMOTES_STATE.name, + { changed: "http://this.is.a.url/server/custom-emote-123.png" }, + "", + ); + }); + + it("should save emote deletion", () => { + mocked(cli.getRoom).mockReturnValue(room); + mocked(cli.isRoomEncrypted).mockReturnValue(false); + // @ts-ignore - mocked doesn't support overloads properly + mocked(room.currentState.getStateEvents).mockImplementation((type, key) => { + if (key === undefined) return [] as MatrixEvent[]; + if (type === EMOTES_STATE.name) { + return new MatrixEvent({ + sender: "@sender:server", + room_id: roomId, + type: EMOTES_STATE.name, + state_key: "", + content: { + testEmote: "http://this.is.a.url/server/custom-emote-123.png", + }, + }); + } + return null; + }); + renderTab(); + fireEvent.click(screen.getByText("Delete")); + fireEvent.click(screen.getByText("Save")); + expect(cli.sendStateEvent).toHaveBeenCalledWith(roomId, EMOTES_STATE.name, {}, ""); + }); + + it("should save new emotes", async () => { + mocked(cli.getRoom).mockReturnValue(room); + mocked(cli.isRoomEncrypted).mockReturnValue(false); + // @ts-ignore - mocked doesn't support overloads properly + mocked(room.currentState.getStateEvents).mockImplementation((type, key) => { + if (key === undefined) return [] as MatrixEvent[]; + if (type === EMOTES_STATE.name) { + return new MatrixEvent({ + sender: "@sender:server", + room_id: roomId, + type: EMOTES_STATE.name, + state_key: "", + content: { + oldEmote: "http://this.is.a.url/server/custom-emote-123.png", + }, + }); + } + return null; + }); + + const tab = renderTab(); + + fireEvent.click(screen.getByText("Upload Emote")); + await waitFor(() => + fireEvent.change(tab.container.querySelector("input.mx_EmoteSettings_emoteUpload")!, { + target: { files: [newemotefile] }, + }), + ); + fireEvent.click(screen.getByText("Save")); + await new Promise(process.nextTick); + expect(cli.sendStateEvent).toHaveBeenCalledWith( + roomId, + EMOTES_STATE.name, + { + coolnewemote: "http://this.is.a.url/server/custom-emote-123.png", + oldEmote: "http://this.is.a.url/server/custom-emote-123.png", + }, + "", + ); + }); + + it("should enable compatibility", async () => { + mocked(cli.getRoom).mockReturnValue(room); + mocked(cli.isRoomEncrypted).mockReturnValue(false); + // @ts-ignore - mocked doesn't support overloads properly + mocked(room.currentState.getStateEvents).mockImplementation((type, key) => { + if (key === undefined) return [] as MatrixEvent[]; + if (type === EMOTES_STATE.name) { + return new MatrixEvent({ + sender: "@sender:server", + room_id: roomId, + type: EMOTES_STATE.name, + state_key: "", + content: { + testEmote: "http://this.is.a.url/server/custom-emote-123.png", + }, + }); + } + return null; + }); + + const tab = renderTab(); + fireEvent.click(tab.container.querySelector("div.mx_ToggleSwitch")!); + await new Promise(process.nextTick); + expect(cli.sendStateEvent).toHaveBeenCalledWith( + roomId, + EMOTES_COMP.name, + { + images: { + testEmote: { + url: "http://this.is.a.url/server/custom-emote-123.png", + }, + }, + pack: { + display_name: "Element Compatible Emotes", + }, + }, + "Element Compatible Emotes", + ); + }); + + it("should save edits to emotes in compatibility", async () => { + mocked(cli.getRoom).mockReturnValue(room); + mocked(cli.isRoomEncrypted).mockReturnValue(false); + // @ts-ignore - mocked doesn't support overloads properly + mocked(room.currentState.getStateEvents).mockImplementation((type, key) => { + if (key === undefined) return [] as MatrixEvent[]; + if (type === EMOTES_STATE.name) { + return new MatrixEvent({ + sender: "@sender:server", + room_id: roomId, + type: EMOTES_STATE.name, + state_key: "", + content: { + testEmote: "http://this.is.a.url/server/custom-emote-123.png", + }, + }); + } + if (type === EMOTES_COMP.name) { + return new MatrixEvent({ + sender: "@sender:server", + room_id: roomId, + type: EMOTES_COMP.name, + state_key: "Element Compatible Emotes", + content: { + images: { + testEmote: { + url: "http://this.is.a.url/server/custom-emote-123.png", + }, + }, + pack: { + display_name: "Element Compatible Emotes", + }, + }, + }); + } + return null; + }); + + const tab = renderTab(); + fireEvent.click(tab.container.querySelector("div.mx_ToggleSwitch")!); + fireEvent.change(tab.container.querySelector("input.mx_EmoteSettings_existingEmoteCode")!, { + target: { value: "changed" }, + }); + fireEvent.click(screen.getByText("Save")); + await new Promise(process.nextTick); + expect(cli.sendStateEvent).toHaveBeenLastCalledWith( + roomId, + EMOTES_COMP.name, + { + images: { + changed: { + url: "http://this.is.a.url/server/custom-emote-123.png", + }, + }, + pack: { + display_name: "Element Compatible Emotes", + }, + }, + "Element Compatible Emotes", + ); + }); + + it("should save new emotes in compatibility", async () => { + mocked(cli.getRoom).mockReturnValue(room); + mocked(cli.isRoomEncrypted).mockReturnValue(false); + // @ts-ignore - mocked doesn't support overloads properly + const tab = renderTab(); + fireEvent.click(tab.container.querySelector("div.mx_ToggleSwitch")!); + fireEvent.click(screen.getByText("Upload Emote")); + await waitFor(() => + fireEvent.change(tab.container.querySelector("input.mx_EmoteSettings_emoteUpload")!, { + target: { files: [newemotefile] }, + }), + ); + fireEvent.change(tab.container.querySelector("input.mx_EmoteSettings_emoteField")!, { + target: { value: "" }, + }); + fireEvent.change(tab.container.querySelector("input.mx_EmoteSettings_emoteField")!, { + target: { value: "coolnewemotecustomname" }, + }); + fireEvent.click(screen.getByText("Save")); + await new Promise(process.nextTick); + await new Promise(process.nextTick); + expect(cli.sendStateEvent).toHaveBeenLastCalledWith( + roomId, + EMOTES_COMP.name, + { + images: { coolnewemotecustomname: { url: "http://this.is.a.url/server/custom-emote-123.png" } }, + pack: { + display_name: "Element Compatible Emotes", + }, + }, + "Element Compatible Emotes", + ); + }); + + it("should load emotes uploaded from other clients in compatibility mode", async () => { + mocked(cli.getRoom).mockReturnValue(room); + mocked(cli.isRoomEncrypted).mockReturnValue(false); + // @ts-ignore - mocked doesn't support overloads properly + mocked(room.currentState.getStateEvents).mockImplementation((type, key) => { + if (key === undefined) return [] as MatrixEvent[]; + if (type === EMOTES_STATE.name) { + return new MatrixEvent({ + sender: "@sender:server", + room_id: roomId, + type: EMOTES_STATE.name, + state_key: "", + content: {}, + }); + } + if (type === EMOTES_COMP.name) { + return new MatrixEvent({ + sender: "@sender:server", + room_id: roomId, + type: EMOTES_COMP.name, + state_key: "Element Compatible Emotes", + content: { + images: { + testEmote: { + url: "http://this.is.a.url/server/custom-emote-123.png", + }, + }, + }, + }); + } + if (type === COMPAT_STATE.name) { + return new MatrixEvent({ + sender: "@sender:server", + room_id: roomId, + type: COMPAT_STATE.name, + state_key: "", + content: { + isCompat: true, + }, + }); + } + return null; + }); + + renderTab(); + + expect(cli.sendStateEvent).toHaveBeenCalledWith( + roomId, + EMOTES_STATE.name, + { + testEmote: "http://this.is.a.url/server/custom-emote-123.png", + }, + "", + ); + }); +});