diff --git a/apps/web/src/Notifier.ts b/apps/web/src/Notifier.ts index dbe890909e..b0a2ce5b42 100644 --- a/apps/web/src/Notifier.ts +++ b/apps/web/src/Notifier.ts @@ -81,6 +81,59 @@ const msgTypeHandlers: Record string | null> = { }, }; +/** + * Extracts plain text from a message body, replacing any spoilered content + * with '[Spoiler]' to prevent spoilers in desktop notifications. + */ +function getNotificationBodyWithoutSpoilers(ev: MatrixEvent): string { + const content = ev.getContent(); + const plainBody = content.body ?? ""; + const formattedBody = content.formatted_body; + + if (typeof formattedBody !== "string" || !formattedBody.length) { + return plainBody; + } + + /** Recursively walks HTML tree to hide spoilers. */ + function replaceSpoilers(node: Node): Node { + if (node.nodeType !== Node.ELEMENT_NODE || !(node instanceof Element)) { + return node; + } + + if (node.hasAttribute("data-mx-spoiler")) { + const e = document.createElement("span"); + e.appendChild(document.createTextNode("[Spoiler]")); + return e; + } + + for (const childNode of node.childNodes) { + node.replaceChild(replaceSpoilers(childNode), childNode); + } + + return node; + } + + try { + // Dev note: ideally we would reuse more of the existing rendering stack + // rather than re-parsing and updating the generated HTML here. However, + // that rendering stack is currently quite consolidated and cannot + // easily be refactored to allow the call-site to control how spoilers + // are rendered. The problem is that we now need two different output + // formats: + // - The existing format where spoilers are wrapped in html tags + // - The new format where the spoilered text is replaced with [Spoiler] + + const parser = new DOMParser(); + const doc = parser.parseFromString(formattedBody, "text/html"); + + // Use textContent rather than innerHTML/outerHTML since textContent is + // XSS-safe and the input is untrusted. + return replaceSpoilers(doc.body).textContent ?? plainBody; + } catch { + return plainBody; + } +} + export const enum NotifierEvent { NotificationHiddenChange = "notification_hidden_change", } @@ -134,7 +187,7 @@ class NotifierClass extends TypedEventEmitter { reply, ); }); + + it.each([ + ["This was a triumph", "This was a triumph", "This was a triumph"], + ["This was a triumph", "This was a triumph", "[Spoiler]"], + ["This was a triumph", 'This was a triumph', "[Spoiler]"], + ["foo bar baz", "foo bar baz", "foo [Spoiler] baz"], + ["foo foo foo", "foo foo foo", "foo [Spoiler] foo"], + [ + "a b c d e", + "a b c d e", + "a [Spoiler] c [Spoiler] e", + ], + ["foo foo", "foo foo", "foo [Spoiler] foo"], + ["foo bar baz", "foo bar baz", "foo [Spoiler] baz"], + ["foobar", "foobar", "[Spoiler][Spoiler]"], + ["foo bar baz", "foo bar baz", "foo [Spoiler] baz"], + ["foo baz", "foo <bar> baz", "foo [Spoiler] baz"], + ["foo\nbar\nbaz", "foo
bar
baz", "foo[Spoiler]baz"], + ])("should hide spoilers in notification", (body, formattedBody, expected) => { + const spoilerEvent = mkEvent({ + event: true, + type: EventType.RoomMessage, + user: mockClient.getSafeUserId(), + room: testRoom.roomId, + content: { + msgtype: MsgType.Text, + body: body, + format: "org.matrix.custom.html", + formatted_body: formattedBody, + }, + }); + Notifier.displayPopupNotification(spoilerEvent, testRoom); + expect(MockPlatform.displayNotification).toHaveBeenCalledWith( + "@bob:example.org (!room1:server)", + expected, + expect.any(String), + testRoom, + spoilerEvent, + ); + }); }); describe("getSoundForRoom", () => {