diff --git a/apps/desktop/scripts/release-codeberg.mjs b/apps/desktop/scripts/release-forge.mjs similarity index 100% rename from apps/desktop/scripts/release-codeberg.mjs rename to apps/desktop/scripts/release-forge.mjs diff --git a/apps/web/src/ContentMessages.ts b/apps/web/src/ContentMessages.ts index 32be8b7fea..5a3b550332 100644 --- a/apps/web/src/ContentMessages.ts +++ b/apps/web/src/ContentMessages.ts @@ -565,7 +565,12 @@ export default class ContentMessages { promBefore?: Promise, spoiler?: boolean, ): Promise { - 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 & { info: Partial } = { body: fileName, info: { diff --git a/apps/web/src/components/views/rooms/EditMessageComposer.tsx b/apps/web/src/components/views/rooms/EditMessageComposer.tsx index 970d8cffcd..7c3d6ab388 100644 --- a/apps/web/src/components/views/rooms/EditMessageComposer.tsx +++ b/apps/web/src/components/views/rooms/EditMessageComposer.tsx @@ -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()); diff --git a/apps/web/src/components/views/rooms/SendMessageComposer.tsx b/apps/web/src/components/views/rooms/SendMessageComposer.tsx index 66288fbbaf..9d12b3f060 100644 --- a/apps/web/src/components/views/rooms/SendMessageComposer.tsx +++ b/apps/web/src/components/views/rooms/SendMessageComposer.tsx @@ -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); diff --git a/apps/web/src/hooks/useMediaVisible.ts b/apps/web/src/hooks/useMediaVisible.ts index 92f691e452..91228d32f4 100644 --- a/apps/web/src/hooks/useMediaVisible.ts +++ b/apps/web/src/hooks/useMediaVisible.ts @@ -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 diff --git a/apps/web/src/utils/BlapSpoilerSyntax.ts b/apps/web/src/utils/BlapSpoilerSyntax.ts new file mode 100644 index 0000000000..9fc85ab80d --- /dev/null +++ b/apps/web/src/utils/BlapSpoilerSyntax.ts @@ -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 + * `` 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, ">"); + content.format = "org.matrix.custom.html"; + content.formatted_body = escaped + .replace(SPOILER_PATTERN, (_match, inner: string) => `${inner}`) + .replace(/\n/g, "
"); + } +} + +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; +}