Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export default defineConfig({
"**/profile-active-turn-screenshots.spec.ts",
"**/file-attachment.spec.ts",
"**/video-attachment.spec.ts",
"**/spoiler.spec.ts",
"**/mentions.spec.ts",
"**/relay-reconnect.spec.ts",
"**/workflows.spec.ts",
Expand Down
79 changes: 79 additions & 0 deletions desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import test from "node:test";
import {
buildImetaTags,
buildOutgoingMessage,
findSpoileredImetaMediaUrls,
formatImetaMediaLine,
imetaMediaFromTags,
mergeOutgoingTags,
Expand All @@ -24,6 +25,14 @@ test("strip: removes trailing image line whose URL is in imetaMedia", () => {
assert.equal(stripped, "Look at this");
});

test("strip: removes trailing spoilered image line whose URL is in imetaMedia", () => {
const body = "Look at this\n||![image](https://blossom/abc.png)||";
const stripped = stripImetaMediaLines(body, [
{ url: "https://blossom/abc.png", type: "image/png" },
]);
assert.equal(stripped, "Look at this");
});

test("strip: removes trailing video line", () => {
const body = "Demo:\n![video](https://blossom/clip.mp4)";
const stripped = stripImetaMediaLines(body, [
Expand Down Expand Up @@ -81,6 +90,16 @@ test("formatImetaMediaLine: image mime → ![image] line", () => {
);
});

test("formatImetaMediaLine: spoilered image mime → wrapped ![image] line", () => {
assert.equal(
formatImetaMediaLine(
{ url: "https://b/a.png", type: "image/png" },
{ spoiler: true },
),
"\n||![image](https://b/a.png)||",
);
});

test("buildImetaTags keeps media filenames in imeta", () => {
// Filenames are included for every MIME type — the video review dialog
// and file cards use them as display titles.
Expand Down Expand Up @@ -126,6 +145,20 @@ test("formatImetaMediaLine: generic mime → [filename](url) link", () => {
);
});

test("formatImetaMediaLine: spoiler option does not wrap generic files", () => {
assert.equal(
formatImetaMediaLine(
{
url: "https://b/blob",
type: "application/pdf",
filename: "report.pdf",
},
{ spoiler: true },
),
"\n[report.pdf](https://b/blob)",
);
});

test("formatImetaMediaLine: escapes markdown brackets/backslash in filename", () => {
// `a].pdf` would otherwise close the link label early and break the FileCard.
assert.equal(
Expand All @@ -149,6 +182,25 @@ test("strip: removes an escaped-bracket generic file line on edit", () => {
assert.equal(stripped, "note");
});

test("findSpoileredImetaMediaUrls: extracts only spoilered matching media urls", () => {
const body = [
"note",
"||![image](https://b/a.png)||",
"![image](https://b/b.png)",
"||![video](https://b/c.mp4)||",
].join("\n");
const spoilered = findSpoileredImetaMediaUrls(body, [
{ url: "https://b/a.png", type: "image/png" },
{ url: "https://b/b.png", type: "image/png" },
{ url: "https://b/c.mp4", type: "video/mp4" },
{ url: "https://b/other.png", type: "image/png" },
]);
assert.deepEqual([...spoilered].sort(), [
"https://b/a.png",
"https://b/c.mp4",
]);
});

// ── imetaMediaFromTags (full BlobDescriptor projection) ───────────────

test("imetaMediaFromTags: empty / undefined", () => {
Expand Down Expand Up @@ -327,6 +379,33 @@ test("buildOutgoingMessage: appends media markdown line per attachment, in order
);
});

test("buildOutgoingMessage: wraps spoilered image and video attachments", () => {
const out = buildOutgoingMessage(
"hi",
[
{
url: "https://b/a.png",
type: "image/png",
sha256: "x",
size: 1,
uploaded: 0,
},
{
url: "https://b/v.mp4",
type: "video/mp4",
sha256: "y",
size: 2,
uploaded: 0,
},
],
new Set(["https://b/a.png", "https://b/v.mp4"]),
);
assert.equal(
out.content,
"hi\n||![image](https://b/a.png)||\n||![video](https://b/v.mp4)||",
);
});

test("buildOutgoingMessage: mediaTags mirror buildImetaTags output for non-empty pending", () => {
const pending = [
{
Expand Down
48 changes: 39 additions & 9 deletions desktop/src/features/messages/lib/imetaMediaMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,10 @@ export function buildImetaTags(
]);
}

const MEDIA_LINE_RE = /^!\[(?:image|video)\]\(([^)\s]+)\)\s*$/;
const MEDIA_LINE_RE =
/^(?:\|\|)?!\[(?:image|video)\]\(([^)\s]+)\)(?:\|\|)?\s*$/;
const SPOILERED_MEDIA_LINE_RE =
/^\|\|!\[(?:image|video)\]\(([^)\s]+)\)\|\|\s*$/;
Comment thread
klopez4212 marked this conversation as resolved.
/**
* Matches a generic file-attachment line `[label](url)` (no leading `!`, so it's
* a link not an image). The label can contain spaces and backslash-escaped
Expand Down Expand Up @@ -143,6 +146,23 @@ export function stripImetaMediaLines(
return lines.slice(0, end).join("\n").replace(/\s+$/, "");
}

export function findSpoileredImetaMediaUrls(
body: string,
imetaMedia: ReadonlyArray<ImetaMedia>,
): Set<string> {
if (imetaMedia.length === 0) return new Set();

const urls = new Set(imetaMedia.map((m) => m.url));
const spoileredUrls = new Set<string>();
for (const line of body.split("\n")) {
const match = line.match(SPOILERED_MEDIA_LINE_RE);
if (match && urls.has(match[1])) {
spoileredUrls.add(match[1]);
}
}
return spoileredUrls;
}

/**
* Format a single imeta entry as a leading-newline markdown line.
*
Expand All @@ -151,13 +171,18 @@ export function stripImetaMediaLines(
* href as a local media blob with a non-media MIME and upgrades it to a file
* card. Mime-driven so the form is correct regardless of URL suffix.
*/
export function formatImetaMediaLine({
url,
type,
filename,
}: ImetaMedia): string {
if (type.startsWith("video/")) return `\n![video](${url})`;
if (type.startsWith("image/")) return `\n![image](${url})`;
export function formatImetaMediaLine(
{ url, type, filename }: ImetaMedia,
options: { spoiler?: boolean } = {},
): string {
if (type.startsWith("video/")) {
const line = `![video](${url})`;
return options.spoiler ? `\n||${line}||` : `\n${line}`;
}
if (type.startsWith("image/")) {
const line = `![image](${url})`;
return options.spoiler ? `\n||${line}||` : `\n${line}`;
}
// Generic file: plain link, label is the original filename (fallback to url tail).
const label = filename || url.split("/").pop() || "file";
// Escape markdown link-label metacharacters so filenames containing `[`, `]`,
Expand All @@ -181,9 +206,14 @@ export function formatImetaMediaLine({
export function buildOutgoingMessage(
body: string,
pendingImeta: ReadonlyArray<ImetaMedia>,
spoileredMediaUrls: ReadonlySet<string> = new Set(),
): { content: string; mediaTags: string[][] | undefined } {
let content = body;
for (const d of pendingImeta) content += formatImetaMediaLine(d);
for (const d of pendingImeta) {
content += formatImetaMediaLine(d, {
spoiler: spoileredMediaUrls.has(d.url),
});
}
const mediaTags =
pendingImeta.length > 0 ? buildImetaTags(pendingImeta) : undefined;
return { content, mediaTags };
Expand Down
104 changes: 104 additions & 0 deletions desktop/src/features/messages/lib/spoilerMark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { Mark, mergeAttributes } from "@tiptap/core";

export const SPOILER_MARK_NAME = "spoiler";
const SPOILER_MARKDOWN_RULE = "buzz_spoiler";
const SPOILER_OPEN_TOKEN = "buzz_spoiler_open";
const SPOILER_CLOSE_TOKEN = "buzz_spoiler_close";
const PIPE_CHAR = 0x7c;

export function registerSpoilerMarkdownIt(
// biome-ignore lint/suspicious/noExplicitAny: markdown-it is untyped here
md: any,
): void {
if (md.renderer.rules[SPOILER_OPEN_TOKEN]) return;

// biome-ignore lint/suspicious/noExplicitAny: markdown-it state/silent
const rule = (state: any, silent: boolean): boolean => {
const start = state.pos;
if (
state.src.charCodeAt(start) !== PIPE_CHAR ||
state.src.charCodeAt(start + 1) !== PIPE_CHAR
) {
return false;
}

const contentStart = start + 2;
const contentEnd = findClosingDelimiter(
state.src,
contentStart,
state.posMax,
);
if (contentEnd <= contentStart) return false;

if (!silent) {
const previousPosMax = state.posMax;
state.push(SPOILER_OPEN_TOKEN, "span", 1);
state.pos = contentStart;
state.posMax = contentEnd;
state.md.inline.tokenize(state);
state.push(SPOILER_CLOSE_TOKEN, "span", -1);
state.posMax = previousPosMax;
}

state.pos = contentEnd + 2;
return true;
};

md.inline.ruler.before("emphasis", SPOILER_MARKDOWN_RULE, rule);
md.renderer.rules[SPOILER_OPEN_TOKEN] = () =>
'<span data-spoiler="" class="buzz-spoiler">';
md.renderer.rules[SPOILER_CLOSE_TOKEN] = () => "</span>";
}

function findClosingDelimiter(
source: string,
start: number,
end: number,
): number {
for (let index = start; index < end - 1; index++) {
if (
source.charCodeAt(index) === PIPE_CHAR &&
source.charCodeAt(index + 1) === PIPE_CHAR
) {
return index;
}
}
return -1;
}

export const SpoilerMark = Mark.create({
name: SPOILER_MARK_NAME,

parseHTML() {
return [{ tag: "span[data-spoiler]" }];
},

renderHTML({ HTMLAttributes }) {
return [
"span",
mergeAttributes(HTMLAttributes, {
"data-spoiler": "",
class: "buzz-spoiler",
}),
0,
];
},

addStorage() {
return {
markdown: {
serialize: {
open: "||",
close: "||",
Comment thread
klopez4212 marked this conversation as resolved.
expelEnclosingWhitespace: true,
},
parse: {
// biome-ignore lint/suspicious/noExplicitAny: markdown-it is untyped here
setup(_md: any) {
registerSpoilerMarkdownIt(_md);
},
},
},
};
},
});
Loading