diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 08fdb75715c..ac22a9a30ac 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -194,7 +194,7 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { const requestedWidth = Number(attribs.width); let requestedHeight = Number(attribs.height); if ("data-mx-emoticon" in attribs) { - requestedHeight = Math.floor(18*window.devicePixelRatio); // 18 is the display height of a normal small emoji + requestedHeight = Math.floor(18 * window.devicePixelRatio); // 18 is the display height of a normal small emoji } const width = Math.min(requestedWidth || 800, 800); const height = Math.min(requestedHeight || 600, 600); @@ -311,7 +311,7 @@ const sanitizeHtmlParams: IExtendedSanitizeOptions = { div: ["data-mx-maths"], a: ["href", "name", "target", "rel"], // remote target: custom to matrix // img tags also accept width/height, we just map those to max-width & max-height during transformation - img: ["src", "alt", "title", "style", "data-mx-emoticon"], + img: ["src", "alt", "title", "style", "data-mx-emoticon", "data-mx-pack-url"], ol: ["start"], code: ["class"], // We don't actually allow all classes, we filter them in transformTags }, @@ -596,14 +596,24 @@ export function bodyToHtml(content: IContent, highlights: Optional, op }); safeBodyNeedsSerialisation = true; } - if (isAllHtmlEmoji && !opts.disableBigEmoji) { // Big emoji? Big image URLs. + if (isAllHtmlEmoji && !opts.disableBigEmoji) { + // Big emoji? Big image URLs. (phtml.root()[0] as cheerio.TagElement).children.forEach((elm) => { - if (elm.name === "img" && "data-mx-emoticon" in elm.attribs && typeof elm.attribs.src === "string") { - elm.attribs.src = elm.attribs.src.replace(/height=[0-9]*/, `height=${Math.floor(48*window.devicePixelRatio)}`) // 48 is the display height of a big emoji + const tagElm = elm as cheerio.TagElement; + if ( + tagElm.name === "img" && + "data-mx-emoticon" in tagElm.attribs && + typeof tagElm.attribs.src === "string" + ) { + tagElm.attribs.src = tagElm.attribs.src.replace( + /height=[0-9]*/, + `height=${Math.floor(48 * window.devicePixelRatio)}`, + ); // 48 is the display height of a big emoji } - }) + }); } - if (safeBodyNeedsSerialisation) { // SchildiChat: all done editing emojis, can finally serialise the body + if (safeBodyNeedsSerialisation) { + // SchildiChat: all done editing emojis, can finally serialise the body safeBody = phtml.html(); } if (bodyHasEmoji) { @@ -633,11 +643,7 @@ export function bodyToHtml(content: IContent, highlights: Optional, op const match = BIGEMOJI_REGEX.exec(contentBodyTrimmed); const matched = match?.[0]?.length === contentBodyTrimmed.length; - emojiBody = - (matched || isAllHtmlEmoji) && - (strippedBody === safeBody || // replies have the html fallbacks, account for that here - content.formatted_body === undefined || - (!content.formatted_body.includes("http:") && !content.formatted_body.includes("https:"))); + emojiBody = matched || isAllHtmlEmoji; } const className = classNames({ diff --git a/src/autocomplete/Autocompleter.ts b/src/autocomplete/Autocompleter.ts index edd8782a5ff..9fa27fae9cc 100644 --- a/src/autocomplete/Autocompleter.ts +++ b/src/autocomplete/Autocompleter.ts @@ -28,6 +28,7 @@ import AutocompleteProvider, { ICommand } from "./AutocompleteProvider"; import SpaceProvider from "./SpaceProvider"; import { TimelineRenderingType } from "../contexts/RoomContext"; import { filterBoolean } from "../utils/arrays"; +import { ICustomEmoji } from "../emojipicker/customemoji"; export interface ISelectionRange { beginning?: boolean; // whether the selection is in the first block of the editor or not @@ -46,6 +47,7 @@ export interface ICompletion { // If provided, apply a LINK entity to the completion with the // data = { url: href }. href?: string; + customEmoji?: ICustomEmoji; } const PROVIDERS = [UserProvider, RoomProvider, EmojiProvider, NotifProvider, CommandProvider, SpaceProvider]; diff --git a/src/autocomplete/EmojiProvider.tsx b/src/autocomplete/EmojiProvider.tsx index 0a98567ebe4..371d031f77a 100644 --- a/src/autocomplete/EmojiProvider.tsx +++ b/src/autocomplete/EmojiProvider.tsx @@ -103,7 +103,7 @@ export default class EmojiProvider extends AutocompleteProvider { // Load this room's image sets. const imageSetEvents = room?.currentState?.getStateEvents("im.ponies.room_emotes"); let loadedImages: ICustomEmoji[] = - imageSetEvents?.flatMap((imageSetEvent) => loadImageSet(imageSetEvent)) || []; + imageSetEvents?.flatMap((imageSetEvent) => loadImageSet(imageSetEvent, room)) || []; // Global emotes from rooms const cli = MatrixClientPeg.get(); @@ -115,7 +115,7 @@ export default class EmojiProvider extends AutocompleteProvider { "im.ponies.room_emotes", packRoomStateKey, ); - const moreLoadedImages: ICustomEmoji[] = loadImageSet(packRoomImageSetEvents); + const moreLoadedImages: ICustomEmoji[] = loadImageSet(packRoomImageSetEvents, packRoom!); loadedImages = [...loadedImages, ...(moreLoadedImages || [])]; } } @@ -244,6 +244,7 @@ export default class EmojiProvider extends AutocompleteProvider { {c.emoji.shortcodes[0]} ), + customEmoji: c.emoji, range: range!, } as const; } diff --git a/src/components/views/emojipicker/EmojiPicker.tsx b/src/components/views/emojipicker/EmojiPicker.tsx index e166b7048a9..4d2cecbff29 100644 --- a/src/components/views/emojipicker/EmojiPicker.tsx +++ b/src/components/views/emojipicker/EmojiPicker.tsx @@ -76,7 +76,7 @@ class EmojiPicker extends React.Component { let loadedImages: ICustomEmoji[]; if (props.room) { const imageSetEvents = props.room.currentState.getStateEvents("im.ponies.room_emotes"); - loadedImages = imageSetEvents.flatMap((imageSetEvent) => loadImageSet(imageSetEvent)); + loadedImages = imageSetEvents.flatMap((imageSetEvent) => loadImageSet(imageSetEvent, props.room)); } else { loadedImages = []; } diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index bcd50d5554b..7609abad0be 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -439,6 +439,13 @@ export default class TextualBody extends React.Component { let target: HTMLLinkElement | null = e.target as HTMLLinkElement; // links processed by linkifyjs have their own handler so don't handle those here if (target.classList.contains(linkifyOpts.className as string)) return; + // handle clicking packs + const packUrl = target.getAttribute("data-mx-pack-url"); + if (packUrl) { + // it could be converted to a localHref -> therefore handle locally + e.preventDefault(); + window.location.hash = tryTransformPermalinkToLocalHref(packUrl); + } if (target.nodeName !== "A") { // Jump to parent as the `` may contain children, e.g. an anchor wrapping an inline code section target = target.closest("a"); diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 31c1c61165c..1bc72c18823 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -900,7 +900,7 @@ export default class BasicMessageEditor extends React.Component if ("unicode" in emoji) { emojiPart = partCreator.emoji(emoji.unicode); } else { - emojiPart = partCreator.customEmoji(emoji.shortcodes[0], emoji.url); + emojiPart = partCreator.customEmoji(emoji.shortcodes[0], emoji.url, emoji.roomId, emoji.eventId); } model.transform(() => { const addedLen = model.insert([emojiPart], position); diff --git a/src/editor/autocomplete.ts b/src/editor/autocomplete.ts index dac8eaaebc2..567db69d28f 100644 --- a/src/editor/autocomplete.ts +++ b/src/editor/autocomplete.ts @@ -112,7 +112,14 @@ export default class AutocompleteWrapperModel { // command needs special handling for auto complete, but also renders as plain texts return [(this.partCreator as CommandPartCreator).command(text)]; case "customEmoji": - return [this.partCreator.customEmoji(text, completionId)]; + return [ + this.partCreator.customEmoji( + text, + completionId!, + completion.customEmoji?.roomId, + completion.customEmoji?.eventId, + ), + ]; default: // used for emoji and other plain text completion replacement return this.partCreator.plainWithEmoji(text); diff --git a/src/editor/deserialize.ts b/src/editor/deserialize.ts index 77b225ff5d7..8eb2756e5c8 100644 --- a/src/editor/deserialize.ts +++ b/src/editor/deserialize.ts @@ -19,11 +19,12 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MsgType } from "matrix-js-sdk/src/@types/event"; import { checkBlockNode } from "../HtmlUtils"; -import { getPrimaryPermalinkEntity } from "../utils/permalinks/Permalinks"; +import { getPrimaryPermalinkEntity, parsePermalink } from "../utils/permalinks/Permalinks"; import { Part, PartCreator, Type } from "./parts"; import SdkConfig from "../SdkConfig"; import { textToHtmlRainbow } from "../utils/colour"; import { stripPlainReply } from "../utils/Reply"; +import { PermalinkParts } from "../utils/permalinks/PermalinkConstructor"; const LIST_TYPES = ["UL", "OL", "LI"]; @@ -97,7 +98,13 @@ function parseImage(n: Node, pc: PartCreator, opts: IParseOptions): Part[] { const isCustomEmoji = elm.hasAttribute("data-mx-emoticon"); if (isCustomEmoji) { const shortcode = elm.title || elm.alt || ":SHORTCODE_MISSING:"; - return [pc.customEmoji(shortcode, src)]; + // parse the link + const packUrl = elm.getAttribute("data-mx-pack-url"); + let permalinkParts: PermalinkParts | null = null; + if (packUrl) { + permalinkParts = parsePermalink(packUrl); + } + return [pc.customEmoji(shortcode, src, permalinkParts?.roomIdOrAlias, permalinkParts?.eventId)]; } return pc.plainWithEmoji(`![${escape(alt)}](${src})`); } diff --git a/src/editor/parts.ts b/src/editor/parts.ts index 522c2679ae2..b033b722047 100644 --- a/src/editor/parts.ts +++ b/src/editor/parts.ts @@ -41,6 +41,8 @@ interface ISerializedPillPart { type: Type.AtRoomPill | Type.RoomPill | Type.UserPill | Type.CustomEmoji; text: string; resourceId?: string; + roomId?: string; + eventId?: string; } export type SerializedPart = ISerializedPart | ISerializedPillPart; @@ -87,7 +89,12 @@ interface IPillPart extends Omit { resourceId: string; } -export type Part = IBasePart | IPillCandidatePart | IPillPart; +export interface ICustomEmojiPart extends IPillPart { + roomId?: string; + eventId?: string; +} + +export type Part = IBasePart | IPillCandidatePart | IPillPart | ICustomEmojiPart; abstract class BasePart { protected _text: string; @@ -418,7 +425,9 @@ export class EmojiPart extends BasePart implements IBasePart { } } -class CustomEmojiPart extends PillPart implements IPillPart { +class CustomEmojiPart extends PillPart implements ICustomEmojiPart { + public roomId?: string; + public eventId?: string; protected get className(): string { return "mx_CustomEmojiPill mx_Pill"; } @@ -434,8 +443,10 @@ class CustomEmojiPart extends PillPart implements IPillPart { this.setAvatarVars(node, url, this.text[0]); } - public constructor(shortCode: string, url: string) { + public constructor(shortCode: string, url: string, roomId?: string, eventId?: string) { super(url, shortCode); + this.roomId = roomId; + this.eventId = eventId; } protected acceptsInsertion(chr: string): boolean { return false; @@ -452,6 +463,14 @@ class CustomEmojiPart extends PillPart implements IPillPart { public get canEdit(): boolean { return false; } + + public serialize(): ISerializedPillPart { + return { + ...super.serialize(), + roomId: this.roomId, + eventId: this.eventId, + }; + } } class RoomPillPart extends PillPart { @@ -622,7 +641,7 @@ export class PartCreator { case Type.Emoji: return this.emoji(part.text); case Type.CustomEmoji: - return this.customEmoji(part.text, part.resourceId); + return this.customEmoji(part.text, part.resourceId!, part.roomId!, part.eventId!); case Type.AtRoomPill: return this.atRoomPill(part.text); case Type.PillCandidate: @@ -701,8 +720,13 @@ export class PartCreator { return parts; } - public customEmoji(shortcode: string, url: string): CustomEmojiPart { - return new CustomEmojiPart(shortcode, url); + public customEmoji( + shortcode: string, + url: string, + roomId?: string | null, + eventId?: string | null, + ): CustomEmojiPart { + return new CustomEmojiPart(shortcode, url, roomId!, eventId!); } public createMentionParts( diff --git a/src/editor/serialize.ts b/src/editor/serialize.ts index 30df421e042..427f34aacd8 100644 --- a/src/editor/serialize.ts +++ b/src/editor/serialize.ts @@ -21,11 +21,11 @@ import escapeHtml from "escape-html"; import _ from "lodash"; import Markdown from "../Markdown"; -import { makeGenericPermalink } from "../utils/permalinks/Permalinks"; +import { makeGenericPermalink, makeRoomPermalink } from "../utils/permalinks/Permalinks"; import EditorModel from "./model"; import SettingsStore from "../settings/SettingsStore"; import SdkConfig from "../SdkConfig"; -import { Type } from "./parts"; +import { ICustomEmojiPart, Type } from "./parts"; export function mdSerialize(model: EditorModel): string { return model.parts.reduce((html, part) => { @@ -53,6 +53,18 @@ export function mdSerialize(model: EditorModel): string { `[${part.text.replace(/[[\\\]]/g, (c) => "\\" + c)}](${makeGenericPermalink(part.resourceId)})` ); case Type.CustomEmoji: + if ((part as ICustomEmojiPart).roomId) { + const permalink = makeRoomPermalink( + (part as ICustomEmojiPart).roomId!, + (part as ICustomEmojiPart).eventId, + ); + return ( + html + + `:${_.escape(part.text)}:` + ); + } return ( html + `