From 84c5f578b00ba9ac41df7f427cbeae8cb4f9ebb7 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 1 Aug 2026 17:14:57 +0200 Subject: [PATCH] refactor(composer): extract message building out of RoomInput --- src/app/features/room/RoomInput.tsx | 357 +++--------------- src/app/features/room/composerMessage.test.ts | 200 ++++++++++ src/app/features/room/composerMessage.ts | 319 ++++++++++++++++ 3 files changed, 563 insertions(+), 313 deletions(-) create mode 100644 src/app/features/room/composerMessage.test.ts create mode 100644 src/app/features/room/composerMessage.ts diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 9aec61484..db002ed86 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -22,7 +22,7 @@ import type { StickerEventContent, } from '$types/matrix-sdk'; import { MatrixError } from '$types/matrix-sdk'; -import { EventType, MsgType, RelationType } from '$types/matrix-sdk'; +import { EventType, RelationType } from '$types/matrix-sdk'; import { ReactEditor } from 'slate-react'; import { Editor, Point, Range, Transforms } from 'slate'; import type { RectCords } from 'folds'; @@ -63,17 +63,12 @@ import { moveCursor, resetEditorHistory, isEmptyEditor, - getBeginCommand, - trimCommand, - getMentions, ANYWHERE_AUTOCOMPLETE_PREFIXES, BEGINNING_AUTOCOMPLETE_PREFIXES, - getLinks, MarkdownFormattingToolbarBottom, MarkdownFormattingToolbarToggle, focusEditor, replaceWithElement, - BlockType, } from '$components/editor'; import { stripMarkdownEscapesForHiddenPreviews } from './message/hiddenLinkPreviews'; import { plainToEditorInput } from '$components/editor/input'; @@ -111,10 +106,9 @@ import { useSetting } from '$state/hooks/settings'; import type { EditorButtonId } from '$state/settings'; import { settingsAtom } from '$state/settings'; import { matchesShortcut } from '../../keyboard/shortcuts'; -import { getEditedEvent, getMentionContent, getThreadReplyEvents } from '$utils/room/relations'; -import { buildReplacementContent } from './buildReplacementContent'; +import { getEditedEvent, getThreadReplyEvents } from '$utils/room/relations'; import { htmlToMarkdown } from '$plugins/markdown'; -import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '$hooks/useCommands'; +import { Command, useCommands } from '$hooks/useCommands'; import { isMobileOrTablet } from '$utils/platform'; import { Reply, ThreadIndicator } from '$components/message'; import { roomToParentsAtom } from '$state/room/roomToParents'; @@ -179,7 +173,6 @@ import { } from '$components/icons/phosphor'; import { getSupportedAudioExtension } from '$plugins/voice-recorder-kit/supportedCodec'; import { ErrorCode } from '../../cs-errorcode'; -import { sanitizeText } from '$utils/sanitize'; import { PKitCommandMessageHandler } from '$plugins/pluralkit-handler/PKitCommandMessageHandler'; import { PKitProxyMessageHandler } from '$plugins/pluralkit-handler/PKitProxyMessageHandler'; import type { IGenericMSC4459, MSC4459ImagePackReference } from '$types/matrix/common'; @@ -205,7 +198,7 @@ import { buildGalleryContent, getGalleryItemContent, } from './msgContent'; -import { outgoingMessageTransforms } from './outgoingMessageTransforms'; +import { buildEditReplacement, buildOutgoingMessage } from './composerMessage'; import { getSendableKlipyMxcUrl } from '$utils/klipy'; import { CommandAutocomplete } from './CommandAutocomplete'; import type { @@ -1069,56 +1062,18 @@ export const RoomInput = forwardRef( const submit = useCallback(async () => { if (editingEvent && isMobileOrTablet()) { - let plainText = toPlainText(editor.children).trim(); - if (!plainText) { + const content = buildEditReplacement(editor.children, { + mx, + room, + roomId, + editingEvent, + currentContent: getEditingContent(editingEvent), + }); + if (!content) { onCancelEdit?.(); return; } - let customHtml = trimCustomHtml( - toMatrixCustomHTML(editor.children, { - forEmote: editingEvent.getContent().msgtype === MsgType.Emote, - room, - }) - ); - const oldContent = editingEvent.getContent(); - const currentContent = getEditingContent(editingEvent); - const eventId = editingEvent.getId(); - if (!eventId) return; - - const rawPmp = - currentContent['com.beeper.per_message_profile'] ?? - oldContent['com.beeper.per_message_profile']; - - const mentionData = getMentions(mx, roomId, editor); - const previousMentions = currentContent['m.mentions']; - if ( - previousMentions && - typeof previousMentions === 'object' && - 'user_ids' in previousMentions && - Array.isArray(previousMentions.user_ids) - ) { - previousMentions.user_ids.forEach((userId) => { - if (typeof userId === 'string') mentionData.users.add(userId); - }); - } - const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room); - - const linkPreviews = - getLinks(editor.children)?.map((matchedUrl) => ({ - matched_url: matchedUrl, - })) ?? []; - - const content = buildReplacementContent( - oldContent, - plainText, - customHtml, - eventId, - mMentions, - linkPreviews, - rawPmp - ); - await mx.sendMessage(roomId, content as RoomMessageEventContent); onCancelEdit?.(); sendTypingStatus(false); @@ -1137,276 +1092,52 @@ export const RoomInput = forwardRef( return; } - const commandName = getBeginCommand(editor); - /** - * a map of regex patterns to replace nicknames with, - * used when stripNickname is true in toMatrixCustomHTML - * during HTML generation for the message content. - * This is necessary because the HTML generation needs to know - * which nicknames to strip in order to generate the correct formatted_body, - * and the plain text generation needs to replace those same nicknames with - * the original user IDs so that the message content remains consistent and - * mentions are correctly processed by the server and clients. - */ - const nicknameReplacement = new Map(); - if (replyEvent) { - /** - * the id of the user being replied to, - * whose nickname (if any) should be stripped - * from the message content and replaced with their - * user ID for correct mention processing - */ - const senderId = replyEvent.getSender(); - if (senderId) { - const nick = nicknames[senderId]; - if (typeof nick === 'string' && nick.length > 0) { - nicknameReplacement.set( - new RegExp(`@?${nick}`, 'g'), - room.getMember(senderId)?.rawDisplayName ?? senderId - ); - } - } - } - /** - * any other users mentioned in the message being replied to, - * whose nicknames should also be stripped and replaced with user IDs - */ - const mentions = getMentions(mx, roomId, editor); - if (mentions?.users) { - mentions.users.forEach((id) => { - const nick = nicknames[id]; - if (typeof nick === 'string' && nick.length > 0) { - nicknameReplacement.set( - new RegExp(`@?${nick}`, 'g'), - room.getMember(id)?.rawDisplayName ?? id - ); - } - }); - } - /** - * the plain text we will send - */ - let serializedChildren = editor.children; - if (commandName) { - // Strip the empty text node and command node from the beginning of the first paragraph - const firstPara = serializedChildren[0]; - if ( - firstPara && - 'type' in firstPara && - firstPara.type === BlockType.Paragraph && - firstPara.children.length >= 2 - ) { - serializedChildren = [ - { - ...firstPara, - children: firstPara.children.slice(2), - }, - ...serializedChildren.slice(1), - ]; - } - } - const outgoingTransformContext = { - isMarkdown: true, + const outgoing = await buildOutgoingMessage(editor.children, { + mx, + room, + roomId, + nicknames, + replyEvent, + replyDraft, + silentReply, settingsLinkBaseUrl, - }; - - outgoingMessageTransforms.forEach((transform) => { - if (!transform.shouldApply(serializedChildren, outgoingTransformContext)) return; - serializedChildren = transform.apply(serializedChildren, outgoingTransformContext); + canSendReaction, + pkCompatEnable, + pmpProxyingEnable, + pmpLatchingEnable, + latchedPersona, + isPKCommand: (text) => PKitCommandMessageHandler.isPKCommand(text), + pluralkitProxyMessageHandler, + imagePacksUsed: imagePacksUsedRef.current, }); - let plainText = toPlainText(serializedChildren, true, true, nicknameReplacement).trim(); - - /** - * the html we will send - */ - let customHtml = trimCustomHtml( - toMatrixCustomHTML(serializedChildren, { - stripNickname: true, - nickNameReplacement: nicknameReplacement, - forEmote: commandName === Command.Me || commandName === Command.RainbowMe, - room, - }) - ); - - let msgType = MsgType.Text; - - // quick text react - if (canSendReaction && plainText.startsWith('+#')) { - handleQuickReact(plainText.substring(2)); + if (outgoing.kind === 'empty') return; + if (outgoing.kind === 'quickReact') { + handleQuickReact(outgoing.key); return; } - - // check if its a pk command - if (pkCompatEnable && PKitCommandMessageHandler.isPKCommand(plainText)) { - await pluralkitCmdMessageHandler.handleMessage(plainText); - resetEditor(editor); // clear the editor - return; // don't do anything besides handling the command - } - - if (commandName) { - plainText = trimCommand(commandName, plainText); - customHtml = trimCommand(commandName, customHtml); + if (outgoing.kind === 'pkCommand') { + await pluralkitCmdMessageHandler.handleMessage(outgoing.plainText); + resetEditor(editor); + return; } - if (commandName === Command.Me) { - msgType = MsgType.Emote; - } else if (commandName === Command.Notice) { - msgType = MsgType.Notice; - } else if (commandName === Command.Shrug) { - plainText = `${SHRUG} ${plainText}`; - customHtml = `${SHRUG} ${customHtml}`; - } else if (commandName === Command.TableFlip) { - plainText = `${TABLEFLIP} ${plainText}`; - customHtml = `${TABLEFLIP} ${customHtml}`; - } else if (commandName === Command.UnFlip) { - plainText = `${UNFLIP} ${plainText}`; - customHtml = `${UNFLIP} ${customHtml}`; - } else if (commandName) { - if ((commandName as Command) === Command.Poll) setShowPollPicker(true); - else if ((commandName as Command) === Command.Location && plainText.trim().length === 0) + if (outgoing.kind === 'command') { + const { command, plainText, customHtml } = outgoing; + if (command === Command.Poll) setShowPollPicker(true); + else if (command === Command.Location && plainText.trim().length === 0) setShowLocationPicker(true); - else { - const commandContent = commands[commandName as Command]; - if (commandContent) { - commandContent.exe(plainText, customHtml); - } - } + else commands[command as Command]?.exe(plainText, customHtml); resetEditor(editor); resetEditorHistory(editor); sendTypingStatus(false); - return; } - if (plainText === '') return; - - // PluralKit-style proxy wrappers (per-message profile proxies) must be stripped - // *before* building `content`, otherwise we end up sending the wrapper verbatim. - let proxiedPerMessageProfile: - | Awaited> - | undefined; - if (pmpProxyingEnable) { - proxiedPerMessageProfile = - await pluralkitProxyMessageHandler.getPmpBasedOnMessage(plainText); - if (proxiedPerMessageProfile) { - // normal plainText has spoilers stripped, but this breaks spoilers with a proxy tag. - // here we get a new 'unsanitized' plainText without spoiler stripping - let unsanitizedPlainText = toPlainText( - serializedChildren, - true, - false, - nicknameReplacement - ).trim(); - - const stripped = pluralkitProxyMessageHandler.stripProxyFromMessage(unsanitizedPlainText); - if (stripped !== undefined) { - // Re-run the normal outgoing pipeline on the stripped content so the message - // goes through the same transforms/parsers as any other message. - serializedChildren = plainToEditorInput(stripped); - - outgoingMessageTransforms.forEach((transform) => { - if (!transform.shouldApply(serializedChildren, outgoingTransformContext)) return; - serializedChildren = transform.apply(serializedChildren, outgoingTransformContext); - }); - - plainText = toPlainText(serializedChildren, true, true, nicknameReplacement).trim(); - customHtml = trimCustomHtml( - toMatrixCustomHTML(serializedChildren, { - stripNickname: true, - nickNameReplacement: nicknameReplacement, - forEmote: commandName === Command.Me || commandName === Command.RainbowMe, - room, - }) - ); - - if (pmpLatchingEnable) { - await setCurrentlyUsedPerMessageProfileIdForRoom( - mx, - roomId, - proxiedPerMessageProfile.id - ); - setLatchedPersona(proxiedPerMessageProfile); - } - } - } - } - - const body = plainText; - const formattedBody = customHtml; - const mentionData = getMentions(mx, roomId, editor); - - const content: IContent & Pick = { - msgtype: msgType, - body, - }; - - if (replyDraft && !silentReply) { - mentionData.users.add(replyDraft.userId); - } - - content['m.mentions'] = getMentionContent(Array.from(mentionData.users), mentionData.room); - content[prefix.MATRIX_UNSTABLE_IMAGE_SOURCE_PACK_PROPERTY_NAME] = - imagePacksUsedRef.current.toJSON(); - - const links = getLinks(serializedChildren); - content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME] = []; - links?.forEach((link) => - content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME].push({ - matched_url: link, - }) - ); - - if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) { - content.format = 'org.matrix.custom.html'; - content.formatted_body = formattedBody; - } - - /** - * the currently with the room associated per-message profile, if any, so that it can be included in the message content when sending. - * This allows the server to apply the correct profile-based transformations (e.g. font size adjustments) when processing the message, - * and also allows clients to display an accurate preview of how the message will look with the profile applied while it's being composed. - */ - const globalPerMessageProfile = await getCurrentlyUsedPerMessageProfileForAccount(mx); - const roomPerMessageProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); - let perMessageProfile = latchedPersona ?? roomPerMessageProfile ?? globalPerMessageProfile; - - if (pmpProxyingEnable) { - if (proxiedPerMessageProfile) perMessageProfile = proxiedPerMessageProfile; - } - if (perMessageProfile) { - content[prefix.MATRIX_UNSTABLE_PER_MESSAGE_PROFILE_PROPERTY_NAME] = - convertPerMessageProfileToBeeperFormat( - perMessageProfile, - perMessageProfile.name.trim() !== '' - ); - - if (perMessageProfile.name.trim() !== '') { - // if a per-message profile is used, it must per spec include a fallback - const pmpPrefix = `${perMessageProfile.name}: `; - - if (!content.body.startsWith(pmpPrefix)) { - // to prevent double-prefixing when the fallback is already present - content.body = pmpPrefix + content.body; - } - - /** - * html escaped version of the display name - */ - const escapedName = sanitizeText(perMessageProfile.name); - - const htmlPrefix = `${escapedName}: `; - - if (content.formatted_body && !content.formatted_body.startsWith(htmlPrefix)) { - content.formatted_body = htmlPrefix + content.formatted_body; - } else { - // we don't have a formatted body, but we need one - content.format = 'org.matrix.custom.html'; - const escapedBody = sanitizeText(plainText).replaceAll('\n', '
'); - content.formatted_body = `${htmlPrefix}${escapedBody}`; - } - } + const { content } = outgoing; + if (outgoing.latchPersona) { + await setCurrentlyUsedPerMessageProfileIdForRoom(mx, roomId, outgoing.latchPersona.id); + setLatchedPersona(outgoing.latchPersona); } - if (replyDraft) { content['m.relates_to'] = getReplyContent(replyDraft, room); } diff --git a/src/app/features/room/composerMessage.test.ts b/src/app/features/room/composerMessage.test.ts new file mode 100644 index 000000000..4e1a32407 --- /dev/null +++ b/src/app/features/room/composerMessage.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { BlockType, plainToEditorInput } from '$components/editor'; +import { Command, SHRUG } from '$hooks/useCommands'; +import type { MatrixClient, Room } from '$types/matrix-sdk'; +import { SerializableMap } from '$types/wrapper/SerializableMap'; +import type { MSC4459ImagePackReference } from '$types/matrix/common'; +import type { PKitProxyMessageHandler } from '$plugins/pluralkit-handler/PKitProxyMessageHandler'; +import type * as PerMessageProfileModule from '$hooks/usePerMessageProfile'; +import type { PerMessageProfile } from '$hooks/usePerMessageProfile'; + +const { profiles } = vi.hoisted(() => ({ + profiles: { + account: undefined as PerMessageProfile | undefined, + room: undefined as PerMessageProfile | undefined, + }, +})); + +vi.mock('$hooks/usePerMessageProfile', async (importOriginal) => ({ + ...(await importOriginal()), + getCurrentlyUsedPerMessageProfileForAccount: () => Promise.resolve(profiles.account), + getCurrentlyUsedPerMessageProfileForRoom: () => Promise.resolve(profiles.room), +})); + +const { buildOutgoingMessage } = await import('./composerMessage'); + +const ROOM_ID = '!room:example.org'; + +const room = { + roomId: ROOM_ID, + getMember: (userId: string) => ({ rawDisplayName: `Display ${userId}` }), +} as unknown as Room; + +const mx = { + getUserId: () => '@me:example.org', + getSafeUserId: () => '@me:example.org', + getRoom: () => room, +} as unknown as MatrixClient; + +const noProxyHandler = { + getPmpBasedOnMessage: () => Promise.resolve(undefined), + stripProxyFromMessage: () => undefined, +} as unknown as PKitProxyMessageHandler; + +/** Mirrors how the editor represents a typed command: empty text node, then a command node. */ +const commandInput = (command: Command, rest = '') => [ + { + type: BlockType.Paragraph as const, + children: [ + { text: '' }, + { type: BlockType.Command as const, command, children: [{ text: '' }] }, + { text: rest }, + ], + }, +]; + +const build = ( + input: string | ReturnType, + overrides: Partial[1]> = {} +) => + buildOutgoingMessage(typeof input === 'string' ? plainToEditorInput(input) : input, { + mx, + room, + roomId: ROOM_ID, + nicknames: {}, + replyEvent: undefined, + replyDraft: undefined, + silentReply: false, + settingsLinkBaseUrl: 'https://app.example', + canSendReaction: true, + pkCompatEnable: false, + pmpProxyingEnable: false, + pmpLatchingEnable: false, + latchedPersona: undefined, + isPKCommand: () => false, + pluralkitProxyMessageHandler: noProxyHandler, + imagePacksUsed: new SerializableMap(), + ...overrides, + }); + +beforeEach(() => { + profiles.account = undefined; + profiles.room = undefined; +}); + +describe('buildOutgoingMessage', () => { + it('builds a plain text message', async () => { + const result = await build('hello world'); + expect(result).toMatchObject({ kind: 'message' }); + if (result.kind !== 'message') throw new Error('expected a message'); + expect(result.content.body).toBe('hello world'); + expect(result.content.msgtype).toBe('m.text'); + }); + + it('reports empty input instead of sending a blank message', async () => { + await expect(build(' ')).resolves.toEqual({ kind: 'empty' }); + }); + + it('returns a quick-react descriptor rather than reacting itself', async () => { + await expect(build('+#tada')).resolves.toEqual({ kind: 'quickReact', key: 'tada' }); + }); + + it('ignores quick-react syntax without permission to react', async () => { + const result = await build('+#tada', { canSendReaction: false }); + expect(result.kind).toBe('message'); + }); + + it('returns a pk-command descriptor only when pk compat is on', async () => { + await expect( + build('pk;switch', { pkCompatEnable: false, isPKCommand: () => true }) + ).resolves.toMatchObject({ kind: 'message' }); + await expect( + build('pk;switch', { pkCompatEnable: true, isPKCommand: () => true }) + ).resolves.toEqual({ kind: 'pkCommand', plainText: 'pk;switch' }); + }); + + it('prefixes shrug and emits an emote for /me', async () => { + const shrug = await build(commandInput(Command.Shrug, ' take it')); + if (shrug.kind !== 'message') throw new Error('expected a message'); + expect(shrug.content.body.startsWith(SHRUG)).toBe(true); + + const emote = await build(commandInput(Command.Me, ' waves')); + if (emote.kind !== 'message') throw new Error('expected a message'); + expect(emote.content.msgtype).toBe('m.emote'); + expect(emote.content.body).toBe('waves'); + }); + + it('hands unhandled commands back to the caller to execute', async () => { + const result = await build(commandInput(Command.Poll)); + expect(result).toMatchObject({ kind: 'command', command: Command.Poll }); + }); + + it('mentions the replied-to user unless the reply is silent', async () => { + const replyDraft = { userId: '@other:example.org', eventId: '$reply', body: 'hi' }; + + const loud = await build('answer', { replyDraft }); + if (loud.kind !== 'message') throw new Error('expected a message'); + expect(loud.content['m.mentions']?.user_ids).toContain('@other:example.org'); + + const silent = await build('answer', { replyDraft, silentReply: true }); + if (silent.kind !== 'message') throw new Error('expected a message'); + expect(silent.content['m.mentions']?.user_ids ?? []).not.toContain('@other:example.org'); + }); + + it('adds the spec-required fallback prefix for a named per-message profile', async () => { + profiles.room = { id: 'p1', name: 'Alter' }; + const result = await build('hello'); + if (result.kind !== 'message') throw new Error('expected a message'); + expect(result.content.body).toBe('Alter: hello'); + expect(result.content.formatted_body).toContain('data-mx-profile-fallback'); + expect(result.content.formatted_body).toContain('Alter: '); + }); + + it('does not double-prefix a body that already carries the fallback', async () => { + profiles.room = { id: 'p1', name: 'Alter' }; + const result = await build('Alter: hello'); + if (result.kind !== 'message') throw new Error('expected a message'); + expect(result.content.body).toBe('Alter: hello'); + }); + + it('prefers the room profile over the account profile', async () => { + profiles.account = { id: 'global', name: 'Global' }; + profiles.room = { id: 'scoped', name: 'Scoped' }; + const result = await build('hello'); + if (result.kind !== 'message') throw new Error('expected a message'); + expect(result.content.body).toBe('Scoped: hello'); + }); + + it('omits an unnamed profile fallback but still tags the profile', async () => { + profiles.account = { id: 'p1', name: '' }; + const result = await build('hello'); + if (result.kind !== 'message') throw new Error('expected a message'); + expect(result.content.body).toBe('hello'); + }); + + it('strips a pluralkit proxy wrapper and lets its profile win', async () => { + const proxied: PerMessageProfile = { id: 'proxy', name: 'Proxied' }; + const handler = { + getPmpBasedOnMessage: () => Promise.resolve(proxied), + stripProxyFromMessage: (text: string) => text.replace(/^A:\s*/, ''), + } as unknown as PKitProxyMessageHandler; + profiles.account = { id: 'global', name: 'Global' }; + + const result = await build('A: hello there', { + pmpProxyingEnable: true, + pluralkitProxyMessageHandler: handler, + }); + if (result.kind !== 'message') throw new Error('expected a message'); + // The wrapper must never reach the wire, and the proxy's profile wins. + expect(result.content.body).toBe('Proxied: hello there'); + }); + + it('records embedded link previews for urls in the body', async () => { + const result = await build('see https://example.com/page'); + if (result.kind !== 'message') throw new Error('expected a message'); + const previews = result.content['com.beeper.linkpreviews'] as + | { matched_url: string }[] + | undefined; + expect(previews?.map((preview) => preview.matched_url)).toContain('https://example.com/page'); + }); +}); diff --git a/src/app/features/room/composerMessage.ts b/src/app/features/room/composerMessage.ts new file mode 100644 index 000000000..718b5f335 --- /dev/null +++ b/src/app/features/room/composerMessage.ts @@ -0,0 +1,319 @@ +import type { Editor } from 'slate'; +import type { IContent, MatrixEvent, Room } from '$types/matrix-sdk'; +import { MsgType } from '$types/matrix-sdk'; +import type { MatrixClient, RoomMessageEventContent } from '$types/matrix-sdk'; +import { + BlockType, + customHtmlEqualsPlainText, + getBeginCommand, + getLinks, + getMentions, + plainToEditorInput, + toMatrixCustomHTML, + toPlainText, + trimCommand, + trimCustomHtml, +} from '$components/editor'; +import { sanitizeText } from '$utils/sanitize'; +import { getMentionContent } from '$utils/room/relations'; +import type { IReplyDraft } from '$state/room/roomInputDrafts'; +import { + convertPerMessageProfileToBeeperFormat, + getCurrentlyUsedPerMessageProfileForAccount, + getCurrentlyUsedPerMessageProfileForRoom, + type PerMessageProfile, +} from '$hooks/usePerMessageProfile'; +import * as prefix from '$unstable/prefixes'; +import { outgoingMessageTransforms } from './outgoingMessageTransforms'; +import { buildReplacementContent } from './buildReplacementContent'; +import { Command, SHRUG, TABLEFLIP, UNFLIP } from '$hooks/useCommands'; +import type { PKitProxyMessageHandler } from '$plugins/pluralkit-handler/PKitProxyMessageHandler'; +import type { MSC4459ImagePackReference } from '$types/matrix/common'; +import type { SerializableMap } from '$types/wrapper/SerializableMap'; + +export type MessageContent = IContent & Pick; + +/** + * Branches needing React state (opening a picker, running a command, reacting) are + * returned as descriptors rather than executed, keeping this module pure. + */ +export type OutgoingMessage = + | { kind: 'message'; content: MessageContent; latchPersona?: PerMessageProfile } + | { kind: 'quickReact'; key: string } + | { kind: 'pkCommand'; plainText: string } + | { kind: 'command'; command: Command; plainText: string; customHtml: string } + | { kind: 'empty' }; + +export interface BuildOutgoingMessageDeps { + mx: MatrixClient; + room: Room; + roomId: string; + /** userId -> nickname, stripped from the body and replaced with the real display name. */ + nicknames: Record; + replyEvent: MatrixEvent | undefined; + replyDraft: IReplyDraft | undefined; + silentReply: boolean; + settingsLinkBaseUrl: string; + canSendReaction: boolean; + pkCompatEnable: boolean; + pmpProxyingEnable: boolean; + pmpLatchingEnable: boolean; + /** Persona latched by an earlier proxied message in this room. */ + latchedPersona: PerMessageProfile | undefined; + isPKCommand: (plainText: string) => boolean; + pluralkitProxyMessageHandler: PKitProxyMessageHandler; + imagePacksUsed: SerializableMap; +} + +const resolveNickname = ( + room: Room, + nicknames: Record, + userId: string +): string | undefined => { + const nick = nicknames[userId]; + if (typeof nick !== 'string' || nick.length === 0) return undefined; + return room.getMember(userId)?.rawDisplayName ?? userId; +}; + +// Nicknames are local-only, so they are swapped back to real display names before the +// body goes out, keeping server-side mention processing correct. +const buildNicknameReplacement = ( + children: Editor['children'], + { mx, room, roomId, nicknames, replyEvent }: BuildOutgoingMessageDeps +): Map => { + const replacement = new Map(); + const add = (userId: string) => { + const displayName = resolveNickname(room, nicknames, userId); + if (displayName) + replacement.set(new RegExp(`@?${nicknames[userId] as string}`, 'g'), displayName); + }; + + const senderId = replyEvent?.getSender(); + if (senderId) add(senderId); + getMentions(mx, roomId, { children } as Editor)?.users?.forEach(add); + + return replacement; +}; + +const stripCommandNode = (children: Editor['children']): Editor['children'] => { + const firstPara = children[0]; + if ( + firstPara && + 'type' in firstPara && + firstPara.type === BlockType.Paragraph && + firstPara.children.length >= 2 + ) { + return [{ ...firstPara, children: firstPara.children.slice(2) }, ...children.slice(1)]; + } + return children; +}; + +const applyPerMessageProfileFallback = (content: MessageContent, profile: PerMessageProfile) => { + content[prefix.MATRIX_UNSTABLE_PER_MESSAGE_PROFILE_PROPERTY_NAME] = + convertPerMessageProfileToBeeperFormat(profile, profile.name.trim() !== ''); + if (profile.name.trim() === '') return; + + // Per spec a per-message profile must ship a fallback prefix for clients that ignore it. + const pmpPrefix = `${profile.name}: `; + // guard against double-prefixing when the fallback is already present + if (!content.body.startsWith(pmpPrefix)) content.body = pmpPrefix + content.body; + + const htmlPrefix = `${sanitizeText(profile.name)}: `; + if (content.formatted_body && !content.formatted_body.startsWith(htmlPrefix)) { + content.formatted_body = htmlPrefix + content.formatted_body; + } else { + // we don't have a formatted body, but the fallback needs one + content.format = 'org.matrix.custom.html'; + const escapedBody = sanitizeText(content.body).replaceAll('\n', '
'); + content.formatted_body = `${htmlPrefix}${escapedBody}`; + } +}; + +export async function buildOutgoingMessage( + children: Editor['children'], + deps: BuildOutgoingMessageDeps +): Promise { + const { + mx, + room, + roomId, + replyDraft, + silentReply, + settingsLinkBaseUrl, + canSendReaction, + pkCompatEnable, + pmpProxyingEnable, + pmpLatchingEnable, + latchedPersona, + isPKCommand, + pluralkitProxyMessageHandler, + imagePacksUsed, + } = deps; + + const commandName = getBeginCommand({ children } as Editor); + const nicknameReplacement = buildNicknameReplacement(children, deps); + const transformContext = { isMarkdown: true, settingsLinkBaseUrl }; + + const runTransforms = (input: Editor['children']): Editor['children'] => { + let output = input; + outgoingMessageTransforms.forEach((transform) => { + if (!transform.shouldApply(output, transformContext)) return; + output = transform.apply(output, transformContext); + }); + return output; + }; + const forEmote = commandName === Command.Me || commandName === Command.RainbowMe; + const serializeHtml = (input: Editor['children']) => + trimCustomHtml( + toMatrixCustomHTML(input, { + stripNickname: true, + nickNameReplacement: nicknameReplacement, + forEmote, + room, + }) + ); + + let serializedChildren = runTransforms(commandName ? stripCommandNode(children) : children); + let plainText = toPlainText(serializedChildren, true, true, nicknameReplacement).trim(); + let customHtml = serializeHtml(serializedChildren); + let msgType = MsgType.Text; + + if (canSendReaction && plainText.startsWith('+#')) { + return { kind: 'quickReact', key: plainText.substring(2) }; + } + if (pkCompatEnable && isPKCommand(plainText)) { + return { kind: 'pkCommand', plainText }; + } + + if (commandName) { + plainText = trimCommand(commandName, plainText); + customHtml = trimCommand(commandName, customHtml); + } + if (commandName === Command.Me) { + msgType = MsgType.Emote; + } else if (commandName === Command.Notice) { + msgType = MsgType.Notice; + } else if (commandName === Command.Shrug) { + plainText = `${SHRUG} ${plainText}`; + customHtml = `${SHRUG} ${customHtml}`; + } else if (commandName === Command.TableFlip) { + plainText = `${TABLEFLIP} ${plainText}`; + customHtml = `${TABLEFLIP} ${customHtml}`; + } else if (commandName === Command.UnFlip) { + plainText = `${UNFLIP} ${plainText}`; + customHtml = `${UNFLIP} ${customHtml}`; + } else if (commandName) { + return { kind: 'command', command: commandName as Command, plainText, customHtml }; + } + + if (plainText === '') return { kind: 'empty' }; + + // PluralKit-style proxy wrappers must be stripped before building `content`, otherwise + // the wrapper itself gets sent verbatim. + let proxiedPerMessageProfile: PerMessageProfile | undefined; + let proxyStripped = false; + if (pmpProxyingEnable) { + proxiedPerMessageProfile = await pluralkitProxyMessageHandler.getPmpBasedOnMessage(plainText); + if (proxiedPerMessageProfile) { + // plainText has spoilers stripped, which breaks spoilers carrying a proxy tag, so + // match the proxy against an unsanitized copy instead. + const unsanitizedPlainText = toPlainText( + serializedChildren, + true, + false, + nicknameReplacement + ).trim(); + const stripped = pluralkitProxyMessageHandler.stripProxyFromMessage(unsanitizedPlainText); + if (stripped !== undefined) { + proxyStripped = true; + // Re-run the normal pipeline so a proxied message is parsed like any other. + serializedChildren = runTransforms(plainToEditorInput(stripped)); + plainText = toPlainText(serializedChildren, true, true, nicknameReplacement).trim(); + customHtml = serializeHtml(serializedChildren); + } + } + } + + const mentionData = getMentions(mx, roomId, { children } as Editor); + if (replyDraft && !silentReply) mentionData.users.add(replyDraft.userId); + + const content: MessageContent = { msgtype: msgType, body: plainText }; + content['m.mentions'] = getMentionContent(Array.from(mentionData.users), mentionData.room); + content[prefix.MATRIX_UNSTABLE_IMAGE_SOURCE_PACK_PROPERTY_NAME] = imagePacksUsed.toJSON(); + content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME] = ( + getLinks(serializedChildren) ?? [] + ).map((matched_url) => ({ matched_url })); + + if (replyDraft || !customHtmlEqualsPlainText(customHtml, plainText)) { + content.format = 'org.matrix.custom.html'; + content.formatted_body = customHtml; + } + + const [globalProfile, roomProfile] = await Promise.all([ + getCurrentlyUsedPerMessageProfileForAccount(mx), + getCurrentlyUsedPerMessageProfileForRoom(mx, roomId), + ]); + const perMessageProfile = + proxiedPerMessageProfile ?? latchedPersona ?? roomProfile ?? globalProfile; + if (perMessageProfile) applyPerMessageProfileFallback(content, perMessageProfile); + + return { + kind: 'message', + content, + // Only a proxy we actually stripped counts as used, so a wrapper we failed to + // strip does not latch the persona for the rest of the room. + latchPersona: pmpLatchingEnable && proxyStripped ? proxiedPerMessageProfile : undefined, + }; +} + +export interface BuildEditReplacementDeps { + mx: MatrixClient; + room: Room; + roomId: string; + editingEvent: MatrixEvent; + /** Content of the latest edit, so a re-edit builds on the current body. */ + currentContent: IContent; +} + +/** Returns undefined when there is nothing to send, which cancels the edit. */ +export function buildEditReplacement( + children: Editor['children'], + { mx, room, roomId, editingEvent, currentContent }: BuildEditReplacementDeps +): IContent | undefined { + const plainText = toPlainText(children).trim(); + if (!plainText) return undefined; + + const eventId = editingEvent.getId(); + if (!eventId) return undefined; + + const oldContent = editingEvent.getContent(); + const customHtml = trimCustomHtml( + toMatrixCustomHTML(children, { + forEmote: oldContent.msgtype === MsgType.Emote, + room, + }) + ); + + const mentionData = getMentions(mx, roomId, { children } as Editor); + const previousMentions = currentContent['m.mentions']; + if ( + previousMentions && + typeof previousMentions === 'object' && + 'user_ids' in previousMentions && + Array.isArray(previousMentions.user_ids) + ) { + previousMentions.user_ids.forEach((userId) => { + if (typeof userId === 'string') mentionData.users.add(userId); + }); + } + + return buildReplacementContent( + oldContent, + plainText, + customHtml, + eventId, + getMentionContent(Array.from(mentionData.users), mentionData.room), + (getLinks(children) ?? []).map((matched_url) => ({ matched_url })), + currentContent['com.beeper.per_message_profile'] ?? oldContent['com.beeper.per_message_profile'] + ); +}