diff --git a/packages/iios-messaging-ui/package.json b/packages/iios-messaging-ui/package.json index 754f846..78d31d1 100644 --- a/packages/iios-messaging-ui/package.json +++ b/packages/iios-messaging-ui/package.json @@ -1,6 +1,6 @@ { "name": "@insignia/iios-messaging-ui", - "version": "0.1.8", + "version": "0.1.9", "type": "module", "main": "dist/index.js", "module": "dist/index.js", diff --git a/packages/iios-messaging-ui/src/components/composer.test.tsx b/packages/iios-messaging-ui/src/components/composer.test.tsx new file mode 100644 index 0000000..c6947f8 --- /dev/null +++ b/packages/iios-messaging-ui/src/components/composer.test.tsx @@ -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(); + return { onSend, box: screen.getByLabelText('Message') as HTMLTextAreaElement }; +} + +describe(' 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())); + }); +}); diff --git a/packages/iios-messaging-ui/src/components/composer.tsx b/packages/iios-messaging-ui/src/components/composer.tsx index 0437cfa..20ecc33 100644 --- a/packages/iios-messaging-ui/src/components/composer.tsx +++ b/packages/iios-messaging-ui/src/components/composer.tsx @@ -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 { SPECIAL_MENTIONS, insertMention, @@ -13,6 +13,17 @@ interface Suggestion { insert: string; } +/** Keyboard shortcut → the marker it wraps the selection in. */ +const SHORTCUTS: Record = { 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 * Thread and the ThreadPane (which passes a parentInteractionId so a reply lands in the thread). @@ -24,7 +35,7 @@ export function Composer({ onSend, onTyping, parentInteractionId, - placeholder = 'Type a message… @ to mention', + placeholder = 'Type a message… @ to mention, *bold*', }: { members: Person[]; canUpload: boolean; @@ -40,6 +51,36 @@ export function Composer({ const [uploading, setUploading] = useState(false); const [uploadingName, setUploadingName] = useState(null); const fileRef = useRef(null); + const inputRef = useRef(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 suggestions = useMemo(() => { @@ -93,10 +134,26 @@ export function Composer({ } } - function onKeyDown(e: KeyboardEvent): void { - if (showSuggest && e.key === 'Enter') { + function onKeyDown(e: KeyboardEvent): void { + // 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(); 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({ ) : null} +
+ {FORMAT_BUTTONS.map((b) => ( + + ))} +
{canUpload ? ( <> @@ -134,11 +198,18 @@ export function Composer({ ) : null} - { setDraft(e.target.value); onTyping?.(); diff --git a/packages/iios-messaging-ui/src/components/message-item.tsx b/packages/iios-messaging-ui/src/components/message-item.tsx index 6b5e243..e151a14 100644 --- a/packages/iios-messaging-ui/src/components/message-item.tsx +++ b/packages/iios-messaging-ui/src/components/message-item.tsx @@ -1,5 +1,5 @@ import { useRef, useState } from 'react'; -import { highlightMentions } from '../mentions'; +import { renderRichText } from '../rich-text'; import { PopoverPortal } from './popover-portal'; import type { Attachment } from '../types'; import type { UiMessage } from '../hooks/use-messages'; @@ -65,7 +65,7 @@ export function MessageItem({
- {m.text ? highlightMentions(m.text, memberNames) : null} + {m.text ? renderRichText(m.text, memberNames) : null} {m.attachment ? : null}