Discord-style ||spoiler|| syntax + SPOILER_ media filenames (Android interop)

- ||text|| in outgoing messages/edits becomes <span data-mx-spoiler> (MSC2010),
  matching what Blap Android sends; code/pre spans are left alone
- spoiler media uploads get the Discord SPOILER_ filename prefix (the only
  marker the Rust SDK on Android can produce) and isSpoilerMedia honors it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 12:50:02 -07:00
parent badbd8f0f7
commit a264351fea
6 changed files with 93 additions and 1 deletions
+6 -1
View File
@@ -565,7 +565,12 @@ export default class ContentMessages {
promBefore?: Promise<any>,
spoiler?: boolean,
): Promise<void> {
const fileName = file.name || _t("common|attachment");
let fileName = file.name || _t("common|attachment");
// Blap: spoiler media also gets the Discord-style SPOILER_ filename prefix — the only
// marker Android (Rust SDK strips custom content keys) and bridges can read.
if (spoiler && !fileName.toUpperCase().startsWith("SPOILER_")) {
fileName = `SPOILER_${fileName}`;
}
const content: Omit<MediaEventContent, "info"> & { info: Partial<MediaEventInfo> } = {
body: fileName,
info: {
@@ -44,6 +44,7 @@ import { PosthogAnalytics } from "../../../PosthogAnalytics";
import { editorRoomKey, editorStateKey } from "../../../Editing";
import type DocumentOffset from "../../../editor/offset";
import { attachMentions, attachRelation } from "../../../utils/messages";
import { applyBlapSpoilerSyntax } from "../../../utils/BlapSpoilerSyntax";
import { filterBoolean } from "../../../utils/arrays";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
@@ -78,6 +79,14 @@ export function createEditContent(
contentBody.format = newContent.format;
contentBody.formatted_body = `* ${formattedBody}`;
}
// Blap: Discord-style ||spoiler|| syntax, on both the replacement and the fallback
applyBlapSpoilerSyntax(newContent);
if (newContent.format && !contentBody.format) {
contentBody.format = newContent.format;
contentBody.formatted_body = `* ${newContent.formatted_body}`;
} else if (contentBody.formatted_body) {
applyBlapSpoilerSyntax(contentBody as { body: string; format?: string; formatted_body?: string });
}
// Build the mentions properties for both the content and new_content.
attachMentions(editedEvent.sender!.userId, contentBody, model, replyToEvent, editedEvent.getContent());
@@ -65,6 +65,7 @@ import { type IDiff } from "../../../editor/diff";
import { getBlobSafeMimeType } from "../../../utils/blobs";
import { EMOJI_REGEX } from "../../../HtmlUtils";
import { attachMentions, attachRelation } from "../../../utils/messages";
import { applyBlapSpoilerSyntax } from "../../../utils/BlapSpoilerSyntax";
import { type RoomUploadViewModel, useRoomUploadViewModel } from "../../../viewmodels/room/RoomUploadViewModel";
// The prefix used when persisting editor drafts to localstorage.
@@ -99,6 +100,8 @@ export function createMessageContent(
content.format = "org.matrix.custom.html";
content.formatted_body = formattedBody;
}
// Blap: Discord-style ||spoiler|| syntax
applyBlapSpoilerSyntax(content);
// Build the mentions property and add it to the event content.
attachMentions(sender, content, model, replyToEvent);
+5
View File
@@ -65,8 +65,13 @@ export function useMediaVisible(mxEvent?: MatrixEvent): [boolean, (visible: bool
/** Whether a media event is flagged as a spoiler / content warning. */
export function isSpoilerMedia(mxEvent: MatrixEvent): boolean {
const content = mxEvent.getContent();
// Discord-style filename convention — what Blap Android sends (the Rust SDK strips
// custom content keys), and what Discord bridges produce.
const fileName = content["filename"] ?? content["body"];
const hasSpoilerFileName = typeof fileName === "string" && fileName.toUpperCase().startsWith("SPOILER_");
return (
content["m.yap.spoiler"] === true ||
hasSpoilerFileName ||
// common content-warning conventions used by some bridges/clients
content["town.robin.msc3725.content_warning"] !== undefined ||
content["m.content_warning"] !== undefined
+70
View File
@@ -0,0 +1,70 @@
/*
Copyright 2026 Blap
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
/**
* Blap: Discord-style ||spoiler|| syntax (MSC2010) for outgoing messages, mirrored on Android.
* Neither commonmark nor the composer knows the syntax, so matches are rewritten into
* `<span data-mx-spoiler>` in the formatted body at send time. The plain-text body keeps the
* `||…||` markers as a readable fallback.
*/
const SPOILER_PATTERN = /\|\|([\s\S]+?)\|\|/g;
interface SpoilerableContent {
body: string;
format?: string;
formatted_body?: string;
}
export function applyBlapSpoilerSyntax(content: SpoilerableContent): void {
if (!content.body.match(SPOILER_PATTERN)) return;
if (content.formatted_body) {
const doc = new DOMParser().parseFromString(content.formatted_body, "text/html");
let changed = false;
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
const textNodes: Text[] = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
textNodes.push(node as Text);
}
for (const node of textNodes) {
if (!node.data.match(SPOILER_PATTERN) || isInsideCode(node)) continue;
changed = true;
const fragment = doc.createDocumentFragment();
let consumedUpTo = 0;
for (const match of node.data.matchAll(SPOILER_PATTERN)) {
if (match.index! > consumedUpTo) {
fragment.appendChild(doc.createTextNode(node.data.slice(consumedUpTo, match.index)));
}
const span = doc.createElement("span");
span.setAttribute("data-mx-spoiler", "");
span.textContent = match[1];
fragment.appendChild(span);
consumedUpTo = match.index! + match[0].length;
}
if (consumedUpTo < node.data.length) {
fragment.appendChild(doc.createTextNode(node.data.slice(consumedUpTo)));
}
node.replaceWith(fragment);
}
if (changed) content.formatted_body = doc.body.innerHTML;
} else {
// No formatted body (plain message): create one carrying the spoiler spans.
const escaped = content.body.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
content.format = "org.matrix.custom.html";
content.formatted_body = escaped
.replace(SPOILER_PATTERN, (_match, inner: string) => `<span data-mx-spoiler>${inner}</span>`)
.replace(/\n/g, "<br/>");
}
}
function isInsideCode(node: Node): boolean {
for (let parent = node.parentElement; parent; parent = parent.parentElement) {
const tag = parent.tagName;
if (tag === "CODE" || tag === "PRE") return true;
}
return false;
}