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(
+
+
+
+
+ ,
+ );
+ }
+ }
+
+ 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 (
+
+ );
+ }
+}
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, "")}:`,
+ ` `,
+ );
+ }
}
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 (
+
+ );
+ }
+}
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:",
+ " ",
+ );
+
+ 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:
+ " 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",
+ },
+ "",
+ );
+ });
+});