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:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-messaging-ui",
|
"name": "@insignia/iios-messaging-ui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { Composer } from './composer';
|
||||||
|
|
||||||
|
function mount(onSend = vi.fn().mockResolvedValue(undefined)) {
|
||||||
|
render(<Composer members={[]} canUpload={false} upload={vi.fn()} onSend={onSend} />);
|
||||||
|
return { onSend, box: screen.getByLabelText('Message') as HTMLTextAreaElement };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('<Composer /> formatting + native text services', () => {
|
||||||
|
it('is a textarea with the platform spellchecker enabled', () => {
|
||||||
|
const { box } = mount();
|
||||||
|
expect(box.tagName).toBe('TEXTAREA');
|
||||||
|
// The red squiggle + right-click suggestions come from the OS via these attributes.
|
||||||
|
expect(box.getAttribute('spellcheck')).toBe('true');
|
||||||
|
expect(box.getAttribute('autocorrect')).toBe('on');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Enter sends, Shift+Enter does not (it makes a newline)', async () => {
|
||||||
|
const { onSend, box } = mount();
|
||||||
|
fireEvent.change(box, { target: { value: 'hello' } });
|
||||||
|
|
||||||
|
fireEvent.keyDown(box, { key: 'Enter', shiftKey: true });
|
||||||
|
expect(onSend).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.keyDown(box, { key: 'Enter' });
|
||||||
|
await waitFor(() => expect(onSend).toHaveBeenCalledWith('hello', expect.anything()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send mid-IME composition', () => {
|
||||||
|
const { onSend, box } = mount();
|
||||||
|
fireEvent.change(box, { target: { value: 'にほん' } });
|
||||||
|
fireEvent.keyDown(box, { key: 'Enter', isComposing: true });
|
||||||
|
expect(onSend).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a toolbar button wraps the current selection in its marker', () => {
|
||||||
|
const { box } = mount();
|
||||||
|
fireEvent.change(box, { target: { value: 'make me bold' } });
|
||||||
|
box.setSelectionRange(8, 12); // "bold"
|
||||||
|
fireEvent.click(screen.getByLabelText('Bold (⌘B)'));
|
||||||
|
expect(box.value).toBe('make me *bold*');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('⌘B / ⌘I wrap the selection too', () => {
|
||||||
|
const { box } = mount();
|
||||||
|
fireEvent.change(box, { target: { value: 'hello world' } });
|
||||||
|
box.setSelectionRange(0, 5);
|
||||||
|
fireEvent.keyDown(box, { key: 'b', metaKey: true });
|
||||||
|
expect(box.value).toBe('*hello* world');
|
||||||
|
|
||||||
|
box.setSelectionRange(8, 13); // "world" shifted by the two markers
|
||||||
|
fireEvent.keyDown(box, { key: 'i', ctrlKey: true });
|
||||||
|
expect(box.value).toBe('*hello* _world_');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with nothing selected, a marker pair is inserted at the caret', () => {
|
||||||
|
const { box } = mount();
|
||||||
|
fireEvent.change(box, { target: { value: 'ab' } });
|
||||||
|
box.setSelectionRange(2, 2);
|
||||||
|
fireEvent.click(screen.getByLabelText('Italic (⌘I)'));
|
||||||
|
expect(box.value).toBe('ab__');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the raw markers as plain text — formatting is a render concern', async () => {
|
||||||
|
const { onSend, box } = mount();
|
||||||
|
fireEvent.change(box, { target: { value: 'ship *today*' } });
|
||||||
|
fireEvent.keyDown(box, { key: 'Enter' });
|
||||||
|
await waitFor(() => expect(onSend).toHaveBeenCalledWith('ship *today*', expect.anything()));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from 'react';
|
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from 'react';
|
||||||
import {
|
import {
|
||||||
SPECIAL_MENTIONS,
|
SPECIAL_MENTIONS,
|
||||||
insertMention,
|
insertMention,
|
||||||
@@ -13,6 +13,17 @@ interface Suggestion {
|
|||||||
insert: string;
|
insert: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Keyboard shortcut → the marker it wraps the selection in. */
|
||||||
|
const SHORTCUTS: Record<string, string> = { b: '*', i: '_', e: '`' };
|
||||||
|
|
||||||
|
/** Toolbar affordances for the same markers (strikethrough is button-only — no common shortcut). */
|
||||||
|
const FORMAT_BUTTONS: Array<{ marker: string; label: string; title: string }> = [
|
||||||
|
{ marker: '*', label: 'B', title: 'Bold (⌘B)' },
|
||||||
|
{ marker: '_', label: 'I', title: 'Italic (⌘I)' },
|
||||||
|
{ marker: '~', label: 'S', title: 'Strikethrough' },
|
||||||
|
{ marker: '`', label: '‹›', title: 'Code (⌘E)' },
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The message input: draft, @mention autocomplete, and attachment staging. Shared by the main
|
* The message input: draft, @mention autocomplete, and attachment staging. Shared by the main
|
||||||
* Thread and the ThreadPane (which passes a parentInteractionId so a reply lands in the thread).
|
* Thread and the ThreadPane (which passes a parentInteractionId so a reply lands in the thread).
|
||||||
@@ -24,7 +35,7 @@ export function Composer({
|
|||||||
onSend,
|
onSend,
|
||||||
onTyping,
|
onTyping,
|
||||||
parentInteractionId,
|
parentInteractionId,
|
||||||
placeholder = 'Type a message… @ to mention',
|
placeholder = 'Type a message… @ to mention, *bold*',
|
||||||
}: {
|
}: {
|
||||||
members: Person[];
|
members: Person[];
|
||||||
canUpload: boolean;
|
canUpload: boolean;
|
||||||
@@ -40,6 +51,36 @@ export function Composer({
|
|||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [uploadingName, setUploadingName] = useState<string | null>(null);
|
const [uploadingName, setUploadingName] = useState<string | null>(null);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// Grow with the content up to the CSS max-height, then scroll — a chat box, not a fixed field.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = inputRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = 'auto';
|
||||||
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
|
}, [draft]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap the current selection in a marker (or, with nothing selected, drop in an empty pair and
|
||||||
|
* park the caret inside). Text stays plain — the marker characters ARE the format, so this never
|
||||||
|
* needs a rich-text model and the native spellchecker keeps working on the raw string.
|
||||||
|
*/
|
||||||
|
function wrapSelection(marker: string): void {
|
||||||
|
const el = inputRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const start = el.selectionStart ?? draft.length;
|
||||||
|
const end = el.selectionEnd ?? start;
|
||||||
|
const selected = draft.slice(start, end);
|
||||||
|
const next = `${draft.slice(0, start)}${marker}${selected}${marker}${draft.slice(end)}`;
|
||||||
|
setDraft(next);
|
||||||
|
// Restore a sensible selection after React re-renders the value.
|
||||||
|
const caret = selected ? start + marker.length + selected.length + marker.length : start + marker.length;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.focus();
|
||||||
|
el.setSelectionRange(selected ? start + marker.length : caret, selected ? caret - marker.length : caret);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const query = trailingMentionQuery(draft);
|
const query = trailingMentionQuery(draft);
|
||||||
const suggestions = useMemo<Suggestion[]>(() => {
|
const suggestions = useMemo<Suggestion[]>(() => {
|
||||||
@@ -93,10 +134,26 @@ export function Composer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent<HTMLInputElement>): void {
|
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>): void {
|
||||||
if (showSuggest && e.key === 'Enter') {
|
// Formatting shortcuts, matching the toolbar. Cmd on macOS, Ctrl elsewhere.
|
||||||
|
if (e.metaKey || e.ctrlKey) {
|
||||||
|
const marker = SHORTCUTS[e.key.toLowerCase()];
|
||||||
|
if (marker) {
|
||||||
|
e.preventDefault();
|
||||||
|
wrapSelection(marker);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key !== 'Enter') return;
|
||||||
|
if (showSuggest) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
pick(suggestions[0]!.insert);
|
pick(suggestions[0]!.insert);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Enter sends; Shift+Enter (and IME composition) inserts a newline.
|
||||||
|
if (!e.shiftKey && !e.nativeEvent.isComposing) {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +182,13 @@ export function Composer({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div className="miu-format-bar" role="group" aria-label="Formatting">
|
||||||
|
{FORMAT_BUTTONS.map((b) => (
|
||||||
|
<button key={b.marker} type="button" className="miu-format-btn" title={b.title} aria-label={b.title} onClick={() => wrapSelection(b.marker)}>
|
||||||
|
{b.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<div className="miu-composer-row">
|
<div className="miu-composer-row">
|
||||||
{canUpload ? (
|
{canUpload ? (
|
||||||
<>
|
<>
|
||||||
@@ -134,11 +198,18 @@ export function Composer({
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<input
|
<textarea
|
||||||
className="miu-input"
|
ref={inputRef}
|
||||||
|
className="miu-input miu-textarea"
|
||||||
value={draft}
|
value={draft}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
aria-label="Message"
|
aria-label="Message"
|
||||||
|
rows={1}
|
||||||
|
// Native platform text services: the browser/OS supplies the red squiggle, the right-click
|
||||||
|
// suggestions and "Add to dictionary", and mobile keyboards add autocorrect. Nothing to ship.
|
||||||
|
spellCheck
|
||||||
|
autoCorrect="on"
|
||||||
|
autoCapitalize="sentences"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setDraft(e.target.value);
|
setDraft(e.target.value);
|
||||||
onTyping?.();
|
onTyping?.();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { highlightMentions } from '../mentions';
|
import { renderRichText } from '../rich-text';
|
||||||
import { PopoverPortal } from './popover-portal';
|
import { PopoverPortal } from './popover-portal';
|
||||||
import type { Attachment } from '../types';
|
import type { Attachment } from '../types';
|
||||||
import type { UiMessage } from '../hooks/use-messages';
|
import type { UiMessage } from '../hooks/use-messages';
|
||||||
@@ -65,7 +65,7 @@ export function MessageItem({
|
|||||||
<div className={`miu-msg${message.mine ? ' is-mine' : ''}${message.pending ? ' is-pending' : ''}`}>
|
<div className={`miu-msg${message.mine ? ' is-mine' : ''}${message.pending ? ' is-pending' : ''}`}>
|
||||||
<div className="miu-bubble-row">
|
<div className="miu-bubble-row">
|
||||||
<div className="miu-bubble">
|
<div className="miu-bubble">
|
||||||
{m.text ? highlightMentions(m.text, memberNames) : null}
|
{m.text ? renderRichText(m.text, memberNames) : null}
|
||||||
{m.attachment ? <AttachmentView att={m.attachment} /> : null}
|
{m.attachment ? <AttachmentView att={m.attachment} /> : null}
|
||||||
</div>
|
</div>
|
||||||
<time className="miu-msg-time" dateTime={m.at} title={Number.isNaN(+new Date(m.at)) ? '' : new Date(m.at).toLocaleString()}>
|
<time className="miu-msg-time" dateTime={m.at} title={Number.isNaN(+new Date(m.at)) ? '' : new Date(m.at).toLocaleString()}>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export { useMessages } from './hooks/use-messages';
|
|||||||
export { useChannels } from './hooks/use-channels';
|
export { useChannels } from './hooks/use-channels';
|
||||||
export { useMembers } from './hooks/use-members';
|
export { useMembers } from './hooks/use-members';
|
||||||
export { isOwnMessage } from './types';
|
export { isOwnMessage } from './types';
|
||||||
|
// Message-body markup (bold/italic/strike/code/links + mentions) as a safe ReactNode tree.
|
||||||
|
export { renderRichText, safeHref } from './rich-text';
|
||||||
|
|
||||||
// Rendered UI. Pair with the './styles.css' export (or override the --miu-* tokens).
|
// Rendered UI. Pair with the './styles.css' export (or override the --miu-* tokens).
|
||||||
export { Messenger } from './components/messenger';
|
export { Messenger } from './components/messenger';
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { renderRichText, safeHref } from './rich-text';
|
||||||
|
|
||||||
|
function show(text: string, names: string[] = []) {
|
||||||
|
render(<div data-testid="out">{renderRichText(text, names)}</div>);
|
||||||
|
return screen.getByTestId('out');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('renderRichText', () => {
|
||||||
|
it('renders bold, italic and strikethrough', () => {
|
||||||
|
const el = show('*bold* _italic_ ~gone~');
|
||||||
|
expect(el.querySelector('strong')?.textContent).toBe('bold');
|
||||||
|
expect(el.querySelector('em')?.textContent).toBe('italic');
|
||||||
|
expect(el.querySelector('del')?.textContent).toBe('gone');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nests styles', () => {
|
||||||
|
const el = show('*bold with _italic_ inside*');
|
||||||
|
const strong = el.querySelector('strong');
|
||||||
|
expect(strong?.textContent).toBe('bold with italic inside');
|
||||||
|
expect(strong?.querySelector('em')?.textContent).toBe('italic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves markup inside code completely literal', () => {
|
||||||
|
const el = show('use `*not bold*` here');
|
||||||
|
expect(el.querySelector('code')?.textContent).toBe('*not bold*');
|
||||||
|
expect(el.querySelector('strong')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a fenced code block', () => {
|
||||||
|
const el = show('```\nconst a = 1;\n```');
|
||||||
|
expect(el.querySelector('pre.miu-code-block')?.textContent).toBe('const a = 1;\n');
|
||||||
|
expect(el.querySelector('code')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat arithmetic or a lone marker as formatting', () => {
|
||||||
|
const el = show('5 * 3 = 15 and a lone * plus snake_case_name');
|
||||||
|
expect(el.querySelector('strong')).toBeNull();
|
||||||
|
expect(el.querySelector('em')).toBeNull();
|
||||||
|
expect(el.textContent).toBe('5 * 3 = 15 and a lone * plus snake_case_name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('linkifies http(s) and www URLs without swallowing trailing punctuation', () => {
|
||||||
|
const el = show('see https://example.com/a?b=1, ok');
|
||||||
|
const a = el.querySelector('a');
|
||||||
|
expect(a?.getAttribute('href')).toBe('https://example.com/a?b=1');
|
||||||
|
expect(a?.textContent).toBe('https://example.com/a?b=1');
|
||||||
|
expect(a?.getAttribute('rel')).toContain('noopener');
|
||||||
|
expect(el.textContent).toContain(', ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never renders a javascript: URL as a link (XSS guard)', () => {
|
||||||
|
expect(safeHref('javascript:alert(1)')).toBeNull();
|
||||||
|
expect(safeHref('data:text/html,<script>')).toBeNull();
|
||||||
|
expect(safeHref('https://ok.example')).toBe('https://ok.example/');
|
||||||
|
// and it is not linkified in message text either
|
||||||
|
const el = show('javascript:alert(1)');
|
||||||
|
expect(el.querySelector('a')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes nothing as HTML — angle brackets stay text', () => {
|
||||||
|
const el = show('<img src=x onerror=alert(1)>');
|
||||||
|
expect(el.querySelector('img')).toBeNull();
|
||||||
|
expect(el.textContent).toBe('<img src=x onerror=alert(1)>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still highlights @mentions alongside formatting', () => {
|
||||||
|
const el = show('*hi* @Sofia Ramirez', ['Sofia Ramirez']);
|
||||||
|
expect(el.querySelector('strong')?.textContent).toBe('hi');
|
||||||
|
expect(el.querySelector('.miu-mention')?.textContent).toBe('@Sofia Ramirez');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns plain text unchanged when there is no markup', () => {
|
||||||
|
expect(show('just a normal message').textContent).toBe('just a normal message');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -515,6 +515,63 @@ button.miu-attach-dl:hover {
|
|||||||
.miu-input:focus {
|
.miu-input:focus {
|
||||||
border-color: var(--miu-accent);
|
border-color: var(--miu-accent);
|
||||||
}
|
}
|
||||||
|
/* Chat box: one row by default, grows with content (JS sets height), then scrolls. */
|
||||||
|
.miu-textarea {
|
||||||
|
resize: none;
|
||||||
|
min-height: 38px;
|
||||||
|
max-height: 160px;
|
||||||
|
overflow-y: auto;
|
||||||
|
line-height: 1.45;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.miu-format-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0 2px 4px;
|
||||||
|
}
|
||||||
|
.miu-format-btn {
|
||||||
|
min-width: 26px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--miu-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.miu-format-btn:hover {
|
||||||
|
background: var(--miu-panel-2);
|
||||||
|
color: var(--miu-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Rendered message formatting ── */
|
||||||
|
.miu-code {
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--miu-panel-2);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.92em;
|
||||||
|
}
|
||||||
|
.miu-code-block {
|
||||||
|
margin: 6px 0 2px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--miu-panel-2);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.92em;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.miu-link {
|
||||||
|
color: var(--miu-accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.miu-msg.is-mine .miu-bubble .miu-link,
|
||||||
|
.miu-msg.is-mine .miu-bubble .miu-code {
|
||||||
|
color: var(--miu-accent-text);
|
||||||
|
}
|
||||||
.miu-send {
|
.miu-send {
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
|||||||
Reference in New Issue
Block a user