Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
104 changes: 104 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,31 @@ 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 block-spoilered image line", () => {
const body = "Look at this\n||\n![image](https://blossom/abc.png)\n||";
const stripped = stripImetaMediaLines(body, [
{ url: "https://blossom/abc.png", type: "image/png" },
]);
assert.equal(stripped, "Look at this");
});

test("strip: keeps block spoiler text while stripping imeta media", () => {
const body =
"Look at this\n||\nsecret\n![image](https://blossom/abc.png)\n||";
const stripped = stripImetaMediaLines(body, [
{ url: "https://blossom/abc.png", type: "image/png" },
]);
assert.equal(stripped, body);
});

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 +107,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 +162,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 +199,33 @@ 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)||",
"||",
"![image](https://b/d.png)",
"![video](https://b/e.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/d.png", type: "image/png" },
{ url: "https://b/e.mp4", type: "video/mp4" },
{ url: "https://b/other.png", type: "image/png" },
]);
assert.deepEqual([...spoilered].sort(), [
"https://b/a.png",
"https://b/c.mp4",
"https://b/d.png",
"https://b/e.mp4",
]);
});

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

test("imetaMediaFromTags: empty / undefined", () => {
Expand Down Expand Up @@ -327,6 +404,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
114 changes: 105 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,11 @@ 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.
const BLOCK_SPOILER_DELIMITER_RE = /^\s*\|\|\s*$/;
/**
* 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 All @@ -110,6 +114,35 @@ const MEDIA_LINE_RE = /^!\[(?:image|video)\]\(([^)\s]+)\)\s*$/;
*/
const FILE_LINE_RE = /^\[(?:\\.|[^\]\\])*\]\(([^)\s]+)\)\s*$/;

function findTrailingBlockSpoilerMediaStart(
lines: string[],
closingDelimiterIndex: number,
urls: ReadonlySet<string>,
): number | null {
let index = closingDelimiterIndex - 1;
let hasMatchingMedia = false;

while (index >= 0) {
const line = lines[index];
if (line.trim() === "") {
index -= 1;
continue;
}

if (BLOCK_SPOILER_DELIMITER_RE.test(line)) {
return hasMatchingMedia ? index : null;
}

const match = line.match(MEDIA_LINE_RE);
if (!match || !urls.has(match[1])) return null;

hasMatchingMedia = true;
index -= 1;
}

return null;
}

/**
* Remove trailing `![image|video](url)` lines whose URL matches an entry in
* `imetaMedia`. Stops at the first non-matching/non-blank line so attachments
Expand All @@ -132,6 +165,13 @@ export function stripImetaMediaLines(
end -= 1;
continue;
}
if (BLOCK_SPOILER_DELIMITER_RE.test(line)) {
const start = findTrailingBlockSpoilerMediaStart(lines, end - 1, urls);
if (start != null) {
end = start;
continue;
}
}
const match = line.match(MEDIA_LINE_RE) ?? line.match(FILE_LINE_RE);
if (match && urls.has(match[1])) {
end -= 1;
Expand All @@ -143,6 +183,52 @@ 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>();
const lines = body.split("\n");
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const match = line.match(SPOILERED_MEDIA_LINE_RE);
if (match && urls.has(match[1])) {
spoileredUrls.add(match[1]);
continue;
}

if (!BLOCK_SPOILER_DELIMITER_RE.test(line)) continue;

const blockSpoileredUrls = new Set<string>();
let closingDelimiterIndex = -1;
for (
let blockIndex = index + 1;
blockIndex < lines.length;
blockIndex += 1
) {
const blockLine = lines[blockIndex];
if (BLOCK_SPOILER_DELIMITER_RE.test(blockLine)) {
closingDelimiterIndex = blockIndex;
break;
}

const blockMatch = blockLine.match(MEDIA_LINE_RE);
if (blockMatch && urls.has(blockMatch[1])) {
blockSpoileredUrls.add(blockMatch[1]);
}
}

if (closingDelimiterIndex !== -1) {
for (const url of blockSpoileredUrls) spoileredUrls.add(url);
index = closingDelimiterIndex;
}
}
return spoileredUrls;
}

/**
* Format a single imeta entry as a leading-newline markdown line.
*
Expand All @@ -151,13 +237,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 +272,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
Loading