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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user