Skip to content
166 changes: 164 additions & 2 deletions src/pages/inbox/conciergeDraftState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,170 @@ type BuildConciergeDraftReportActionParams = {
reportID: string;
};

type TextRange = {
start: number;
end: number;
};

const CODE_BLOCK_DELIMITER = '```';
const INLINE_CODE_DELIMITER = '`';
const BOLD_DELIMITER = '**';
const STRIKETHROUGH_DELIMITER = '~~';

function isInAnyRange(position: number, ranges: TextRange[]): boolean {
return ranges.some((range) => position >= range.start && position < range.end);
}

function isEscaped(text: string, index: number): boolean {
let slashCount = 0;
let pos = index - 1;

while (pos >= 0 && text[pos] === '\\') {
slashCount++;
pos--;
}

return slashCount % 2 !== 0;
}

function getCodeRanges(text: string): {ranges: TextRange[]; unclosedCodeBlockStart: number | null} {
const ranges: TextRange[] = [];
let unclosedCodeBlockStart: number | null = null;

for (let pos = 0; pos <= text.length - CODE_BLOCK_DELIMITER.length; pos++) {
if (!text.startsWith(CODE_BLOCK_DELIMITER, pos) || isEscaped(text, pos)) {
continue;
}

if (unclosedCodeBlockStart === null) {
unclosedCodeBlockStart = pos;
} else {
ranges.push({start: unclosedCodeBlockStart, end: pos + CODE_BLOCK_DELIMITER.length});
unclosedCodeBlockStart = null;
}
pos += CODE_BLOCK_DELIMITER.length - 1;
}

let lineStart = 0;

while (lineStart <= text.length) {
const nextNewline = text.indexOf('\n', lineStart);
const lineEnd = nextNewline === -1 ? text.length : nextNewline;
let openingDelimiterIndex: number | null = null;

for (let pos = lineStart; pos < lineEnd; pos++) {
if (text[pos] !== INLINE_CODE_DELIMITER || isEscaped(text, pos) || isInAnyRange(pos, ranges)) {
continue;
}

if (openingDelimiterIndex === null) {
openingDelimiterIndex = pos;
} else {
ranges.push({start: openingDelimiterIndex, end: pos + INLINE_CODE_DELIMITER.length});
openingDelimiterIndex = null;
}
}

if (nextNewline === -1) {
break;
}
lineStart = nextNewline + 1;
}

return {ranges, unclosedCodeBlockStart};
}

function stripUnpairedLastLineDelimiter(text: string, delimiter: string, ignoredRanges: TextRange[] = []): string {
const lastNewline = text.lastIndexOf('\n');
const lastLineStart = lastNewline + 1;
const delimiterIndexes: number[] = [];

for (let pos = lastLineStart; pos <= text.length - delimiter.length; pos++) {
if (!text.startsWith(delimiter, pos) || isEscaped(text, pos) || isInAnyRange(pos, ignoredRanges)) {
continue;
}

delimiterIndexes.push(pos);
pos += delimiter.length - 1;
}

if (delimiterIndexes.length > 0 && delimiterIndexes.length % 2 !== 0) {
return text.substring(0, delimiterIndexes.at(-1));
}

return text;
}

function normalizeDelimiterForExpensiMark(text: string, delimiter: string, replacement: string, ignoredRanges: TextRange[] = []): string {
let result = '';

for (let pos = 0; pos < text.length; pos++) {
if (text.startsWith(delimiter, pos) && !isEscaped(text, pos) && !isInAnyRange(pos, ignoredRanges)) {
result += replacement;
pos += delimiter.length - 1;
continue;
}

result += text[pos];
}

return result;
}

/**
* Strips incomplete markdown constructs from the tail of a streaming markdown
* string so that ExpensiMark doesn't render raw syntax for half-finished
* links, bold, strikethrough, or code blocks. Completed double-delimiter
* emphasis is normalized to ExpensiMark's single-delimiter syntax so the text
* stays styled without leaking raw delimiters while the server-rendered HTML is
* still pending.
*/
function stripIncompleteMarkdown(markdown: string): string {
if (!markdown) {
return markdown;
}

const initialCodeState = getCodeRanges(markdown);
let codeRanges = initialCodeState.ranges;
let result = initialCodeState.unclosedCodeBlockStart === null ? markdown : markdown.substring(0, initialCodeState.unclosedCodeBlockStart);

// Strip incomplete inline code before looking for other markdown so code
// contents don't look like unfinished links or emphasis.
codeRanges = codeRanges.filter((range) => range.end <= result.length);
result = stripUnpairedLastLineDelimiter(result, INLINE_CODE_DELIMITER, codeRanges);

codeRanges = getCodeRanges(result).ranges;
for (let openBracketIndex = result.length - 1; openBracketIndex >= 0; openBracketIndex--) {
if (result[openBracketIndex] !== '[' || isEscaped(result, openBracketIndex) || isInAnyRange(openBracketIndex, codeRanges)) {
continue;
}

const closeBracketIndex = result.indexOf(']', openBracketIndex + 1);
const stripFrom = openBracketIndex > 0 && result[openBracketIndex - 1] === '!' && !isEscaped(result, openBracketIndex - 1) ? openBracketIndex - 1 : openBracketIndex;

if (closeBracketIndex === -1) {
result = result.substring(0, stripFrom);
} else if (result[closeBracketIndex + 1] === '(' && result.indexOf(')', closeBracketIndex + 2) === -1) {
result = result.substring(0, stripFrom);
}
break;
}

codeRanges = getCodeRanges(result).ranges;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ CONSISTENCY-3 (docs)

The strip-then-normalize sequence for bold (lines 172-175) and strikethrough (lines 177-180) is identical apart from the delimiter and its single-character replacement. This 4-line block is duplicated verbatim.

Extract a helper that performs both steps for a given delimiter:

function stripAndNormalizeDelimiter(text: string, delimiter: string, replacement: string, codeRanges: TextRange[]): {result: string; codeRanges: TextRange[]} {
    let updatedRanges = getCodeRanges(text).ranges;
    const stripped = stripUnpairedLastLineDelimiter(text, delimiter, updatedRanges);
    updatedRanges = getCodeRanges(stripped).ranges;
    const normalized = normalizeDelimiterForExpensiMark(stripped, delimiter, replacement, updatedRanges);
    return {result: normalized, codeRanges: getCodeRanges(normalized).ranges};
}

Then the two blocks collapse to:

({result, codeRanges} = stripAndNormalizeDelimiter(result, BOLD_DELIMITER, '*', codeRanges));
({result, codeRanges} = stripAndNormalizeDelimiter(result, STRIKETHROUGH_DELIMITER, '~', codeRanges));

Reviewed at: 583714b | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

result = stripUnpairedLastLineDelimiter(result, BOLD_DELIMITER, codeRanges);
codeRanges = getCodeRanges(result).ranges;
result = normalizeDelimiterForExpensiMark(result, BOLD_DELIMITER, '*', codeRanges);

codeRanges = getCodeRanges(result).ranges;
result = stripUnpairedLastLineDelimiter(result, STRIKETHROUGH_DELIMITER, codeRanges);
codeRanges = getCodeRanges(result).ranges;
result = normalizeDelimiterForExpensiMark(result, STRIKETHROUGH_DELIMITER, '~', codeRanges);

return result;
}

function buildConciergeDraftReportAction({bodyMarkdown, created, finalRenderedHTML, reportActionID, reportID}: BuildConciergeDraftReportActionParams): ReportAction | null {
const html = finalRenderedHTML ?? (bodyMarkdown ? getParsedComment(bodyMarkdown, {reportID}) : '');
const html = finalRenderedHTML ?? (bodyMarkdown ? getParsedComment(stripIncompleteMarkdown(bodyMarkdown), {reportID}) : '');

if (!html) {
return null;
Expand Down Expand Up @@ -101,5 +263,5 @@ function applyConciergeDraftEvent(currentDraft: ConciergeDraft | null, event: Co
};
}

export {applyConciergeDraftEvent, getCachedDraft, setCachedDraft};
export {applyConciergeDraftEvent, getCachedDraft, setCachedDraft, stripIncompleteMarkdown};
export type {ConciergeDraft};
113 changes: 110 additions & 3 deletions tests/unit/pages/inbox/conciergeDraftState.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {applyConciergeDraftEvent, getCachedDraft, setCachedDraft} from '@pages/inbox/conciergeDraftState';
import {applyConciergeDraftEvent, getCachedDraft, setCachedDraft, stripIncompleteMarkdown} from '@pages/inbox/conciergeDraftState';
import CONST from '@src/CONST';

const REPORT_ID = '123';
Expand Down Expand Up @@ -47,7 +47,7 @@ describe('conciergeDraftState', () => {
expect(draft?.reportAction.actorAccountID).toBe(CONST.ACCOUNT_ID.CONCIERGE);
expect(draft?.reportAction.created).toBe(CREATED);
expect(getFirstMessageHTML(draft)).toContain('<strong>world</strong>');
expect(getFirstMessageText(draft)).toBe('Hello, *world*!');
expect(getFirstMessageText(draft)).toBe('Hello, world!');
});

it('should update the same draft session when a newer sequence arrives', () => {
Expand Down Expand Up @@ -79,7 +79,7 @@ describe('conciergeDraftState', () => {
);

expect(staleDraft).toBe(initialDraft);
expect(getFirstMessageText(staleDraft)).toBe('Hello, *world*!');
expect(getFirstMessageText(staleDraft)).toBe('Hello, world!');
});

it('should keep the draft visible through completion and prefer finalRenderedHTML when provided', () => {
Expand Down Expand Up @@ -132,6 +132,113 @@ describe('conciergeDraftState', () => {
expect(otherReportDraft).toBe(initialDraft);
});

describe('stripIncompleteMarkdown', () => {
it('returns empty/falsy values unchanged', () => {
expect(stripIncompleteMarkdown('')).toBe('');
});

it('does not alter complete markdown', () => {
const complete = 'Hello *bold* and [link](https://example.com) and `code`';
expect(stripIncompleteMarkdown(complete)).toBe(complete);
});

it('normalizes complete double-delimiter emphasis for ExpensiMark', () => {
expect(stripIncompleteMarkdown('Hello **bold** and ~~strike~~')).toBe('Hello *bold* and ~strike~');
});

// --- Links / Images ---
it('strips an incomplete link with only opening bracket', () => {
expect(stripIncompleteMarkdown('Check out [')).toBe('Check out ');
});

it('strips an incomplete link with text but no closing bracket', () => {
expect(stripIncompleteMarkdown('Check out [this page')).toBe('Check out ');
});

it('strips an incomplete link with bracket closed but no URL', () => {
expect(stripIncompleteMarkdown('Check out [link](')).toBe('Check out ');
});

it('strips an incomplete link with partial URL', () => {
expect(stripIncompleteMarkdown('Check out [link](https://example')).toBe('Check out ');
});

it('preserves a complete link followed by an incomplete one', () => {
expect(stripIncompleteMarkdown('[done](https://a.com) and [broken')).toBe('[done](https://a.com) and ');
});

it('preserves bracketed text that is not a link', () => {
const complete = 'The accepted values are [yes/no] for this setting';
expect(stripIncompleteMarkdown(complete)).toBe(complete);
});

it('strips an incomplete image syntax', () => {
expect(stripIncompleteMarkdown('Here is ![alt')).toBe('Here is ');
});

// --- Bold (**) ---
it('strips trailing unclosed bold', () => {
expect(stripIncompleteMarkdown('Hello **world')).toBe('Hello ');
});

it('strips bare trailing ** delimiter', () => {
expect(stripIncompleteMarkdown('Hello **')).toBe('Hello ');
});

it('preserves complete bold and strips only the unclosed one', () => {
expect(stripIncompleteMarkdown('**done** and **broken')).toBe('*done* and ');
});

// --- Strikethrough (~~) ---
it('strips trailing unclosed strikethrough', () => {
expect(stripIncompleteMarkdown('Hello ~~strike')).toBe('Hello ');
});

// --- Code blocks (```) ---
it('strips an unclosed code block', () => {
expect(stripIncompleteMarkdown('Here:\n```\ncode')).toBe('Here:\n');
});

it('preserves a complete code block', () => {
const complete = 'Before\n```\ncode\n```\nAfter';
expect(stripIncompleteMarkdown(complete)).toBe(complete);
});

it('preserves a complete code block ending at the closing fence', () => {
const complete = 'Before\n```\ncode\n```';
expect(stripIncompleteMarkdown(complete)).toBe(complete);
});

// --- Inline code (`) ---
it('strips trailing unclosed inline code', () => {
expect(stripIncompleteMarkdown('Run `command')).toBe('Run ');
});

it('preserves complete inline code', () => {
const complete = 'Run `command` now';
expect(stripIncompleteMarkdown(complete)).toBe(complete);
});

it('preserves markdown-looking text inside complete inline code', () => {
const complete = 'Use `[accountID]` and `**not bold` in the payload';
expect(stripIncompleteMarkdown(complete)).toBe(complete);
});

// --- Streaming integration ---
it('keeps complete double-delimiter bold styled without showing raw delimiters during streaming', () => {
const draft = applyConciergeDraftEvent(null, createDraftEvent({bodyMarkdown: 'Hello **bold**!'}), REPORT_ID);

expect(getFirstMessageHTML(draft)).toContain('<strong>bold</strong>');
expect(getFirstMessageHTML(draft)).not.toContain('*');
});

it('strips incomplete markdown during a streaming draft event', () => {
const draft = applyConciergeDraftEvent(null, createDraftEvent({bodyMarkdown: 'Check [this link'}), REPORT_ID);
// The raw '[this link' syntax should NOT appear in the rendered HTML
expect(getFirstMessageHTML(draft)).not.toContain('[this link');
});
});

describe('draftCache', () => {
// Always start clean so tests don't leak state into each other.
beforeEach(() => {
Expand Down
Loading