Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
11 changes: 6 additions & 5 deletions desktop/src/features/messages/lib/hasMention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,18 @@ function escapeRegExp(str: string): string {
/**
* Check whether `text` contains an @mention of `name`.
*
* Matches `@Name` preceded by start-of-string, whitespace, or markdown
* bold/italic markers (`*`, `**`, `***`, `_`, `__`, `___`). This handles
* the case where a mention is pasted from the chat area and TipTap's Bold
* extension wraps it in bold marks (font-weight >= 500 → bold).
* Matches `@Name` preceded by start-of-string, whitespace, markdown
* bold/italic markers (`*`, `**`, `***`, `_`, `__`, `___`), or spoiler
* delimiters (`||`). This handles the case where a mention is pasted from the
* chat area and TipTap's Bold extension wraps it in bold marks (font-weight >=
* 500 -> bold), plus messages whose visible mention text is spoilered.
*
* Exported separately so it can be unit-tested without importing React.
*/
export function hasMention(text: string, name: string): boolean {
const escaped = escapeRegExp(name);
const pattern = new RegExp(
`(?:^|\\s|[*_]{1,3})@${escaped}(?=[\\s,;.!?:)\\]}*_]|$)`,
`(?:^|\\s|[*_]{1,3}|\\|\\|)@${escaped}(?=\\|\\||[\\s,;.!?:)\\]}*_]|$)`,
"i",
);
return pattern.test(text);
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
69 changes: 69 additions & 0 deletions desktop/src/features/messages/lib/spoilerFormatting.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import test from "node:test";

import { getSchema } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";

import { getSpoilerRangeState } from "./spoilerFormatting.ts";
import { SpoilerMark, SPOILER_MARK_NAME } from "./spoilerMark.ts";

const schema = getSchema([
StarterKit.configure({
hardBreak: { keepMarks: true },
heading: false,
trailingNode: false,
link: false,
}),
SpoilerMark,
]);

const spoilerMark = schema.marks[SPOILER_MARK_NAME];
const codeMark = schema.marks.code;
const para = (...content) => schema.nodes.paragraph.create(null, content);
const codeBlock = (content) => schema.nodes.codeBlock.create(null, content);
const t = (text, marks = []) => schema.text(text, marks);

function doc(...content) {
return schema.nodes.doc.create(null, content);
}

function wholeDocState(d) {
return getSpoilerRangeState(d, spoilerMark, 1, d.content.size - 1);
}

test("getSpoilerRangeState: ignores inline code when surrounding text is spoilered", () => {
const d = doc(
para(
t("hidden ", [spoilerMark.create()]),
t("literal", [codeMark.create()]),
t(" text", [spoilerMark.create()]),
),
);

assert.equal(wholeDocState(d), "fully-spoiled");
});

test("getSpoilerRangeState: ignores code blocks when surrounding text is spoilered", () => {
const d = doc(
para(t("hidden", [spoilerMark.create()])),
codeBlock(t("const secret = true;")),
);

assert.equal(wholeDocState(d), "fully-spoiled");
});

test("getSpoilerRangeState: reports unspoilered markable text as partial", () => {
const d = doc(
para(t("hidden", [spoilerMark.create()])),
codeBlock(t("const secret = true;")),
para(t("visible")),
);

assert.equal(wholeDocState(d), "partially-spoiled");
});

test("getSpoilerRangeState: returns no markable content for code-only ranges", () => {
const d = doc(codeBlock(t("const secret = true;")));

assert.equal(wholeDocState(d), "no-markable-content");
});
55 changes: 55 additions & 0 deletions desktop/src/features/messages/lib/spoilerFormatting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { Editor } from "@tiptap/react";
import type { MarkType, Node as ProseMirrorNode } from "@tiptap/pm/model";

import { SPOILER_MARK_NAME } from "./spoilerMark";

export type SpoilerRangeState =
| "fully-spoiled"
| "partially-spoiled"
| "no-markable-content";

function canTextNodeHoldMark(
node: ProseMirrorNode,
parent: ProseMirrorNode | null,
markType: MarkType,
): boolean {
if (!node.isText || node.textContent.length === 0) return false;
if (parent && !parent.type.allowsMarkType(markType)) return false;
if (markType.isInSet(node.marks)) return true;

return node.marks.every((mark) => !mark.type.excludes(markType));
}

export function getSpoilerRangeState(
doc: ProseMirrorNode,
spoilerMark: MarkType,
from: number,
to: number,
): SpoilerRangeState {
let hasMarkableContent = false;
let isFullySpoiled = true;

doc.nodesBetween(from, to, (node, _pos, parent) => {
if (!canTextNodeHoldMark(node, parent, spoilerMark)) return;

hasMarkableContent = true;
if (!spoilerMark.isInSet(node.marks)) {
isFullySpoiled = false;
return false;
}
});

if (!hasMarkableContent) return "no-markable-content";
return isFullySpoiled ? "fully-spoiled" : "partially-spoiled";
}

export function getEditorSpoilerRangeState(
editor: Editor,
from: number,
to: number,
): SpoilerRangeState {
const spoilerMark = editor.schema.marks[SPOILER_MARK_NAME];
if (!spoilerMark) return "no-markable-content";

return getSpoilerRangeState(editor.state.doc, spoilerMark, from, to);
}
16 changes: 16 additions & 0 deletions desktop/src/features/messages/lib/spoilerMark.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import test from "node:test";

import { findClosingDelimiter } from "./spoilerMark.ts";

test("findClosingDelimiter: closes on the first inner delimiter", () => {
const source = "||a || b||";

assert.equal(findClosingDelimiter(source, 2, source.length), 4);
});

test("findClosingDelimiter: returns -1 when no closing delimiter exists", () => {
const source = "||open spoiler";

assert.equal(findClosingDelimiter(source, 2, source.length), -1);
});
Loading