feat(messaging-ui): message formatting + native spellcheck in the composer
Composer becomes a <textarea> so the PLATFORM supplies text services: red spellcheck squiggles, right-click suggestions / add-to-dictionary, and mobile autocorrect (spellCheck + autoCorrect + autoCapitalize). Nothing shipped for it. Previously a single-line <input>, which Firefox does not spellcheck by default (layout.spellcheckDefault=1 checks multi-line only) and which could not hold a multi-line message at all. Enter sends, Shift+Enter (and IME composition) makes a newline, and the box grows with content. WhatsApp-style markup: *bold*, _italic_, ~strike~, `code`, ```fenced blocks```, plus bare URLs. Cmd/Ctrl+B/I/E and a small toolbar wrap the selection in markers. Messages stay PLAIN TEXT on the wire, so stored history and older clients are unaffected — formatting is purely a render concern. renderRichText() returns a ReactNode tree and never uses dangerouslySetInnerHTML, so message text cannot inject markup; links are restricted to http/https/mailto (safeHref) to close the javascript: vector. Code is tokenized first and its contents stay literal; markers require word boundaries so snake_case_name and "5 * 3" are not mangled. Bumped to 0.1.9. SDK suite: 17 files / 95 tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { highlightMentions } from './mentions';
|
||||
|
||||
/**
|
||||
* WhatsApp-style lightweight markup for message bodies.
|
||||
*
|
||||
* *bold* _italic_ ~strike~ `code` ```code block``` plus bare URLs
|
||||
*
|
||||
* Messages stay PLAIN TEXT on the wire — this only affects rendering, so nothing already stored
|
||||
* breaks and a client without this build just shows the marker characters.
|
||||
*
|
||||
* Security: this returns a ReactNode tree and never touches dangerouslySetInnerHTML, so message
|
||||
* text can never inject markup. The one genuinely dangerous surface is links, where an attacker
|
||||
* could otherwise smuggle `javascript:` — {@link safeHref} allows only http/https/mailto.
|
||||
*
|
||||
* Precedence (deliberate): code is tokenized first and its contents are left completely alone, so
|
||||
* `*not bold*` inside backticks stays literal. Everything else may nest (*bold with _italic_*).
|
||||
*/
|
||||
|
||||
/** Only protocols that cannot execute script. Anything else renders as plain text, not a link. */
|
||||
export function safeHref(raw: string): string | null {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? url.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// A bare URL: stops before trailing punctuation so "see https://x.com." doesn't swallow the period.
|
||||
const URL_RE = /\bhttps?:\/\/[^\s<>()]+[^\s<>().,;:!?'"]|\bwww\.[^\s<>()]+[^\s<>().,;:!?'"]/g;
|
||||
|
||||
interface Rule {
|
||||
/** The wrapping marker, e.g. '*' for bold. */
|
||||
marker: string;
|
||||
tag: 'strong' | 'em' | 'del';
|
||||
}
|
||||
|
||||
const RULES: Rule[] = [
|
||||
{ marker: '*', tag: 'strong' },
|
||||
{ marker: '_', tag: 'em' },
|
||||
{ marker: '~', tag: 'del' },
|
||||
];
|
||||
|
||||
/** Escape a marker so it is literal inside a RegExp. */
|
||||
function esc(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first inline span (`*bold*`, `_italic_`, `~strike~`) in `text`.
|
||||
*
|
||||
* Two guards keep everyday prose from being mangled:
|
||||
* - the content must not start or end with whitespace, so "5 * 3 = 15" and a lone `*` are inert;
|
||||
* - the markers must sit on word boundaries, so `snake_case_name` is NOT italicised (the bug
|
||||
* every naive markdown renderer ships with).
|
||||
*/
|
||||
function firstSpan(text: string): { start: number; end: number; rule: Rule; inner: string } | null {
|
||||
let best: { start: number; end: number; rule: Rule; inner: string } | null = null;
|
||||
for (const rule of RULES) {
|
||||
const m = esc(rule.marker);
|
||||
// (lead)(marker)(inner)(marker) — `lead` keeps the boundary char out of the match itself.
|
||||
const re = new RegExp(`(^|[^\\w${m}])${m}(?=\\S)([^${m}]*[^\\s${m}])${m}(?![\\w${m}])`);
|
||||
const found = re.exec(text);
|
||||
if (!found) continue;
|
||||
const start = found.index + (found[1]?.length ?? 0);
|
||||
if (best === null || start < best.start) {
|
||||
best = { start, end: found.index + found[0].length, rule, inner: found[2] ?? '' };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Linkify + mention-highlight a run of text that carries no other markup. */
|
||||
function plain(text: string, memberNames: string[], keyed: () => string): ReactNode[] {
|
||||
const out: ReactNode[] = [];
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
URL_RE.lastIndex = 0;
|
||||
while ((m = URL_RE.exec(text)) !== null) {
|
||||
if (m.index > last) out.push(...highlightMentions(text.slice(last, m.index), memberNames));
|
||||
const raw = m[0];
|
||||
const href = safeHref(raw.startsWith('www.') ? `https://${raw}` : raw);
|
||||
out.push(
|
||||
href ? (
|
||||
<a key={keyed()} className="miu-link" href={href} target="_blank" rel="noopener noreferrer nofollow">
|
||||
{raw}
|
||||
</a>
|
||||
) : (
|
||||
raw
|
||||
),
|
||||
);
|
||||
last = m.index + raw.length;
|
||||
}
|
||||
if (last < text.length) out.push(...highlightMentions(text.slice(last), memberNames));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Tokenize one segment that is known to contain no code, recursing so styles can nest. */
|
||||
function inline(text: string, memberNames: string[], keyed: () => string): ReactNode[] {
|
||||
const span = firstSpan(text);
|
||||
if (!span) return plain(text, memberNames, keyed);
|
||||
const { start, end, rule, inner } = span;
|
||||
const Tag = rule.tag;
|
||||
return [
|
||||
...(start > 0 ? inline(text.slice(0, start), memberNames, keyed) : []),
|
||||
<Tag key={keyed()}>{inline(inner, memberNames, keyed)}</Tag>,
|
||||
...(end < text.length ? inline(text.slice(end), memberNames, keyed) : []),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render message text as a safe ReactNode tree: code first (its contents stay literal), then
|
||||
* nested bold/italic/strike, then links and @mentions.
|
||||
*/
|
||||
export function renderRichText(text: string, memberNames: string[] = []): ReactNode[] {
|
||||
let n = 0;
|
||||
const keyed = (): string => `rt${n++}`;
|
||||
const out: ReactNode[] = [];
|
||||
// ```block``` or `inline` — matched together so the longer fence wins.
|
||||
const CODE_RE = /```([\s\S]+?)```|`([^`\n]+)`/g;
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = CODE_RE.exec(text)) !== null) {
|
||||
if (m.index > last) out.push(...inline(text.slice(last, m.index), memberNames, keyed));
|
||||
if (m[1] !== undefined) {
|
||||
out.push(
|
||||
<pre key={keyed()} className="miu-code-block">
|
||||
<code>{m[1].replace(/^\n/, '')}</code>
|
||||
</pre>,
|
||||
);
|
||||
} else {
|
||||
out.push(
|
||||
<code key={keyed()} className="miu-code">
|
||||
{m[2]}
|
||||
</code>,
|
||||
);
|
||||
}
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < text.length) out.push(...inline(text.slice(last), memberNames, keyed));
|
||||
return out.length > 0 ? out : [text];
|
||||
}
|
||||
Reference in New Issue
Block a user