diff --git a/.changeset/fix-pmp-proxy.md b/.changeset/fix-pmp-proxy.md new file mode 100644 index 000000000..3f6ce975c --- /dev/null +++ b/.changeset/fix-pmp-proxy.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fixed per-message profile proxies not unwrapping and generally just not working. diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 373948925..36469c3c6 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -63,6 +63,7 @@ import { replaceWithElement, BlockType, } from '$components/editor'; +import { plainToEditorInput } from '$components/editor/input'; import { EmojiBoard, EmojiBoardTab } from '$components/emoji-board'; import { UseStateProvider } from '$components/UseStateProvider'; import type { TUploadContent } from '$utils/matrix'; @@ -777,7 +778,7 @@ export const RoomInput = forwardRef( // check if its a pk command if (pkCompatEnable && PKitCommandMessageHandler.isPKCommand(plainText)) { - pluralkitCmdMessageHandler.handleMessage(plainText); + await pluralkitCmdMessageHandler.handleMessage(plainText); resetEditor(editor); // clear the editor return; // don't do anything besides handling the command } @@ -813,6 +814,37 @@ export const RoomInput = forwardRef( 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) { + const stripped = pluralkitProxyMessageHandler.stripProxyFromMessage(plainText); + 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, nicknameReplacement).trim(); + customHtml = trimCustomHtml( + toMatrixCustomHTML(serializedChildren, { + stripNickname: true, + nickNameReplacement: nicknameReplacement, + }) + ); + } + } + } + const body = plainText; const formattedBody = customHtml; const mentionData = getMentions(mx, roomId, editor); @@ -848,13 +880,10 @@ export const RoomInput = forwardRef( * 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 perMessageProfile = - pmpProxyingEnable && pluralkitProxyMessageHandler.isAProxiedMessage(plainText) - ? await pluralkitProxyMessageHandler.getPmpBasedOnMessage(plainText) - : await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); - - if (pmpProxyingEnable && pluralkitProxyMessageHandler.isAProxiedMessage(plainText)) - plainText = pluralkitProxyMessageHandler.stripProxyFromMessage(plainText) ?? plainText; + let perMessageProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); + if (pmpProxyingEnable) { + if (proxiedPerMessageProfile) perMessageProfile = proxiedPerMessageProfile; + } if (perMessageProfile) { content[prefix.MATRIX_UNSTABLE_PER_MESSAGE_PROFILE_PROPERTY_NAME] = convertPerMessageProfileToBeeperFormat( diff --git a/src/app/features/room/pmpProxyOutgoingPipeline.test.ts b/src/app/features/room/pmpProxyOutgoingPipeline.test.ts new file mode 100644 index 000000000..f5815891c --- /dev/null +++ b/src/app/features/room/pmpProxyOutgoingPipeline.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; + +import { + plainToEditorInput, + toMatrixCustomHTML, + toPlainText, + trimCustomHtml, +} from '$components/editor'; +import { outgoingMessageTransforms } from './outgoingMessageTransforms'; +import { buildSettingsLink, getSettingsLinkLabel } from '$features/settings/settingsLink'; + +function runOutgoingPipeline(input: string, settingsLinkBaseUrl = 'https://app.example') { + let children = plainToEditorInput(input); + const context = { settingsLinkBaseUrl }; + + outgoingMessageTransforms.forEach((t) => { + if (!t.shouldApply(children, context)) return; + children = t.apply(children, context); + }); + + const plain = toPlainText(children, true).trim(); + const html = trimCustomHtml( + toMatrixCustomHTML(children, { + stripNickname: true, + nickNameReplacement: new Map(), + }) + ); + + return { children, plain, html }; +} + +describe('PMP proxy outgoing pipeline parity', () => { + it('renders markdown like normal messages (bold)', () => { + const { plain, html } = runOutgoingPipeline('**bold**'); + expect(plain).toBe('**bold**'); + // Our markdown pipeline injects `data-md` markers for round-tripping. + expect(html).toMatch(/]*>bold<\/strong>/); + }); + + it('preserves markdown links in plaintext and renders anchors in html', () => { + const { plain, html } = runOutgoingPipeline('[Sable](https://example.com)'); + expect(plain).toBe('[Sable](https://example.com)'); + expect(html).toContain('Sable'); + }); + + it('escapes raw html so it is not treated as markup', () => { + const { plain, html } = runOutgoingPipeline('nope'); + expect(plain).toBe('nope'); + // markdownToHtml sanitizes/strips raw tags; ensure it does not render as actual . + expect(html).toContain('nope'); + expect(html).not.toContain('nope'); + }); + + it('applies outgoing transforms (settings link rewrite) like normal messages', () => { + const base = 'https://app.example'; + const url = buildSettingsLink(base, 'appearance', 'message-link-preview'); + const label = getSettingsLinkLabel('appearance', 'message-link-preview'); + + const { plain, html } = runOutgoingPipeline(`see ${url}`, base); + + expect(plain).toContain(`[${label}](${url})`); + // HTML encodes & as & in attributes + const encodedUrl = url.replaceAll('&', '&'); + expect(html).toContain(`', '>'); + expect(html).toContain(`>${encodedLabel}`); + }); + + it('supports multi-paragraph messages (keeps line breaks)', () => { + const { plain, html } = runOutgoingPipeline('first line\n\nsecond line'); + // Plaintext keeps the blank line separation + expect(plain).toBe('first line\n\nsecond line'); + // HTML should contain two paragraphs + expect(html).toMatch(/

first line<\/p>[\s\S]*

second line<\/p>/); + }); + + it('supports fenced code blocks and renders them as code', () => { + const md = ['```ts', 'const x = 1', '```'].join('\n'); + const { plain, html } = runOutgoingPipeline(md); + expect(plain).toBe(md); + // Allow flexibility in the exact HTML produced by the markdown pipeline + expect(html).toMatch(/[\s\S]*const x = 1[\s\S]*<\/code><\/pre>/); + }); +}); diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index 07b987a51..94a12f8a6 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -632,7 +632,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { exe: async (payload) => { const pid: string = splitWithSpace(payload)[0] ?? ''; const proxy: string = splitWithSpace(payload)[1] ?? ''; - pkitcmdHandler.handleMessage(`pk;member "${pid}" proxy ${proxy}`, true); + await pkitcmdHandler.handleMessage(`pk;member "${pid}" proxy ${proxy}`, true); }, }, [Command.MyRoomAvatar]: { diff --git a/src/app/hooks/usePerMessageProfile.proxy.test.ts b/src/app/hooks/usePerMessageProfile.proxy.test.ts new file mode 100644 index 000000000..ab3ba58c6 --- /dev/null +++ b/src/app/hooks/usePerMessageProfile.proxy.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { parsePerMessageProfileProxyAssociation } from './usePerMessageProfile'; + +describe('parsePerMessageProfileProxyAssociation', () => { + it('parses a regex string with flags (RegExp#toString form)', () => { + const assoc = { + profileId: 'p1', + regexString: '/^\\[text\\] (.+)$/i', + setAt: 123, + }; + + const parsed = parsePerMessageProfileProxyAssociation(assoc); + expect(parsed.profileId).toBe('p1'); + expect(parsed.setAt).toBe(123); + expect(parsed.regex.test('[text] Hello')).toBe(true); + expect(parsed.regex.test('[TEXT] hello')).toBe(true); // i flag + }); + + it('parses a regex string without flags', () => { + const assoc = { + profileId: 'p1', + regexString: '/^\\[(.+)\\]$/', + }; + + const parsed = parsePerMessageProfileProxyAssociation(assoc); + expect(parsed.regex.test('[ok]')).toBe(true); + expect(parsed.regex.test('[no] trailing')).toBe(false); + }); +}); diff --git a/src/app/hooks/usePerMessageProfile.ts b/src/app/hooks/usePerMessageProfile.ts index 0f9c7c284..82f0cfc96 100644 --- a/src/app/hooks/usePerMessageProfile.ts +++ b/src/app/hooks/usePerMessageProfile.ts @@ -158,10 +158,12 @@ export type InternalPerMessageProfileProxyAssociation = { export function parsePerMessageProfileProxyAssociation( assoc: PerMessageProfileProxyAssociation ): InternalPerMessageProfileProxyAssociation { + const m = assoc.regexString.match(/^\/([\s\S]*)\/([gimsuy]*)$/); + const source = m?.[1] ?? assoc.regexString; + const flags = m?.[2] ?? ''; return { profileId: assoc.profileId, - // we need to remove artifacts from the toString conversion - regex: new RegExp(assoc.regexString.slice(1, -1)), + regex: new RegExp(source, flags), setAt: assoc.setAt, } satisfies InternalPerMessageProfileProxyAssociation; } @@ -431,7 +433,7 @@ export async function getProfileAssociatedWithProxy( mx: MatrixClient, proxy: string ): Promise { - const profileId = getAssociationsMap( + const profileId = getProxyAssociationMap( mx .getAccountData( `${ACCOUNT_DATA_PREFIX}.proxyassociation` as Parameters[0] diff --git a/src/app/plugins/pluralkit-handler/PKitCommandMessageHandler.ts b/src/app/plugins/pluralkit-handler/PKitCommandMessageHandler.ts index 7ceb1313f..83a61453b 100644 --- a/src/app/plugins/pluralkit-handler/PKitCommandMessageHandler.ts +++ b/src/app/plugins/pluralkit-handler/PKitCommandMessageHandler.ts @@ -102,7 +102,10 @@ export class PKitCommandMessageHandler { this.room, this.mx.getSafeUserId() ); - addOrUpdatePerMessageProfile(this.mx, { id: generatedID, name: memberName }); + await addOrUpdatePerMessageProfile(this.mx, { + id: generatedID, + name: memberName, + }); sendFeedback( `added new member has been created with id: ${generatedID} and name ${memberName}`, this.room, @@ -169,7 +172,7 @@ export class PKitCommandMessageHandler { this.room, this.mx.getSafeUserId() ); - addOrUpdatePerMessageProfile(this.mx, pmp); + await addOrUpdatePerMessageProfile(this.mx, pmp); } else if (pkMemberRemoveProxy.test(this.message)) { const cmdParts = pkMemberRemoveProxy.exec(this.message); if (!cmdParts) return; @@ -195,7 +198,7 @@ export class PKitCommandMessageHandler { ); return; } - dropProxyAssociationForPMP(this.mx, matchAgainst); + await dropProxyAssociationForPMP(this.mx, matchAgainst); sendFeedback( `Persona with ${this.useIdInsteadOfNameWherePossible ? 'id' : 'name'} "${name}" (${pmpId}) is now no longer associated with ${matchAgainst}`, @@ -227,7 +230,7 @@ export class PKitCommandMessageHandler { return; } const matchAgainstRegExp = buildRegex(matchAgainst); - associateProxyWithProfile(this.mx, pmpId, matchAgainst, matchAgainstRegExp, false); + await associateProxyWithProfile(this.mx, pmpId, matchAgainst, matchAgainstRegExp, false); sendFeedback( `Persona with ${this.useIdInsteadOfNameWherePossible ? 'id' : 'name'} "${name}" (${pmpId}) is now associated with ${matchAgainst}`, this.room, diff --git a/src/app/plugins/pluralkit-handler/PKitProxyMessageHandler.test.ts b/src/app/plugins/pluralkit-handler/PKitProxyMessageHandler.test.ts new file mode 100644 index 000000000..42b916cf8 --- /dev/null +++ b/src/app/plugins/pluralkit-handler/PKitProxyMessageHandler.test.ts @@ -0,0 +1,50 @@ +import type { Mock } from 'vitest'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +import { PKitProxyMessageHandler } from './PKitProxyMessageHandler'; +import type { MatrixClient } from '$types/matrix-sdk'; + +// Mock the hook module that provides proxy associations + profile lookup +vi.mock('$hooks/usePerMessageProfile', () => ({ + getAllPerMessageProfileProxies: vi.fn<() => Promise>(), + getPerMessageProfileById: vi.fn<() => Promise>(), + parsePerMessageProfileProxyAssociation: vi.fn<() => unknown>(), +})); + +const mocked = await import('$hooks/usePerMessageProfile'); + +describe('PKitProxyMessageHandler', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('returns false for isAProxiedMessage before init', () => { + const handler = new PKitProxyMessageHandler({} as unknown as MatrixClient); + expect(handler.isAProxiedMessage('[test] hi')).toBe(false); + }); + + it('matches a proxied message, returns pmp, and strips content', async () => { + const proxyRegex = /^\[(.+)\]$/; + + (mocked.getAllPerMessageProfileProxies as unknown as Mock).mockResolvedValueOnce([ + { profileId: 'p1', regexString: proxyRegex.toString() }, + ]); + (mocked.parsePerMessageProfileProxyAssociation as unknown as Mock).mockReturnValueOnce({ + profileId: 'p1', + regex: proxyRegex, + }); + (mocked.getPerMessageProfileById as unknown as Mock).mockResolvedValueOnce({ + id: 'p1', + name: 'Test', + }); + + const handler = new PKitProxyMessageHandler({} as unknown as MatrixClient); + + const pmp = await handler.getPmpBasedOnMessage('[hello]'); + expect(pmp).toEqual({ id: 'p1', name: 'Test' }); + + // getPmpBasedOnMessage refreshes/init() so we should be inited now + expect(handler.isAProxiedMessage('[hello]')).toBe(true); + expect(handler.stripProxyFromMessage('[hello]')).toBe('hello'); + }); +}); diff --git a/src/app/plugins/pluralkit-handler/PKitProxyMessageHandler.ts b/src/app/plugins/pluralkit-handler/PKitProxyMessageHandler.ts index 4e310da29..b23efdd4c 100644 --- a/src/app/plugins/pluralkit-handler/PKitProxyMessageHandler.ts +++ b/src/app/plugins/pluralkit-handler/PKitProxyMessageHandler.ts @@ -54,7 +54,9 @@ export class PKitProxyMessageHandler { this.succInit = true; } catch (err) { this.succInit = false; - throw new Error(`failed to init pmp proxy handler: ${String(err)}`, { cause: err }); + throw new Error(`failed to init pmp proxy handler: ${String(err)}`, { + cause: err, + }); } } @@ -63,7 +65,7 @@ export class PKitProxyMessageHandler { * @param message the message to check */ public isAProxiedMessage(message: string): boolean { - if (!this.succInit) throw new Error('PK proxy message handler is not initialized'); + if (!this.succInit) return false; return this.proxiesAssocs.some((assoc) => assoc.regex.test(message)); } @@ -73,7 +75,8 @@ export class PKitProxyMessageHandler { * @returns the matching Per-Message-Profile, if any */ public async getPmpBasedOnMessage(message: string): Promise { - if (!this.succInit) await this.init(); + // Always refresh so newly-added proxies apply immediately. + await this.init(); // check if the message matches our formats // maybe a bit unsafe, as we are evaluating regex that aren't necessarily by us, could be _maybe_ manipulated const profileId = this.proxiesAssocs.find((assoc) => assoc.regex.test(message))?.profileId; @@ -89,7 +92,7 @@ export class PKitProxyMessageHandler { * @memberof PKitProxyMessageHandler */ public stripProxyFromMessage(message: string): string | undefined { - if (!this.succInit) throw new Error('PK proxy message handler is not initialized'); + if (!this.succInit) return undefined; let m; this.proxiesAssocs.forEach((assoc) => { const match = assoc.regex.exec(message);