c946f1e061
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>
129 lines
4.7 KiB
TypeScript
129 lines
4.7 KiB
TypeScript
import { useRef, useState } from 'react';
|
|
import { renderRichText } from '../rich-text';
|
|
import { PopoverPortal } from './popover-portal';
|
|
import type { Attachment } from '../types';
|
|
import type { UiMessage } from '../hooks/use-messages';
|
|
|
|
const REACTION_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
|
|
|
|
const isImage = (mime: string): boolean => mime.startsWith('image/');
|
|
|
|
/** Slack-style message time: today shows the clock, then "Yesterday", weekday, else a date. */
|
|
function messageTime(iso: string): string {
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(+d)) return '';
|
|
const now = new Date();
|
|
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
if (d.toDateString() === now.toDateString()) return time;
|
|
const yesterday = new Date(now);
|
|
yesterday.setDate(now.getDate() - 1);
|
|
if (d.toDateString() === yesterday.toDateString()) return `Yesterday ${time}`;
|
|
if (now.getTime() - d.getTime() < 7 * 86400000) return `${d.toLocaleDateString([], { weekday: 'short' })} ${time}`;
|
|
return `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${time}`;
|
|
}
|
|
|
|
function AttachmentView({ att }: { att: Attachment }) {
|
|
if (isImage(att.mime)) {
|
|
return (
|
|
<a href={att.url} target="_blank" rel="noreferrer" className="miu-att-img-link">
|
|
<img src={att.url} alt={att.name} className="miu-att-img" />
|
|
</a>
|
|
);
|
|
}
|
|
return (
|
|
<a href={att.url} target="_blank" rel="noreferrer" className="miu-att-file">
|
|
📎 {att.name}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
/** One rendered message: bubble (with @mention highlighting), attachment, reactions, seen tick,
|
|
* and — in the main thread only — a thread/reply affordance. */
|
|
export function MessageItem({
|
|
message,
|
|
memberNames,
|
|
canReact,
|
|
onReact,
|
|
seen,
|
|
replyCount,
|
|
onOpenThread,
|
|
}: {
|
|
message: UiMessage;
|
|
memberNames: string[];
|
|
canReact: boolean;
|
|
onReact: (messageId: string, emoji: string) => void;
|
|
seen: boolean;
|
|
/** Present only in the main thread (not inside the pane). undefined => no thread affordance. */
|
|
replyCount?: number;
|
|
onOpenThread?: (messageId: string) => void;
|
|
}) {
|
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
const reactBtnRef = useRef<HTMLButtonElement>(null);
|
|
const m = message;
|
|
|
|
return (
|
|
<div className={`miu-msg${message.mine ? ' is-mine' : ''}${message.pending ? ' is-pending' : ''}`}>
|
|
<div className="miu-bubble-row">
|
|
<div className="miu-bubble">
|
|
{m.text ? renderRichText(m.text, memberNames) : null}
|
|
{m.attachment ? <AttachmentView att={m.attachment} /> : null}
|
|
</div>
|
|
<time className="miu-msg-time" dateTime={m.at} title={Number.isNaN(+new Date(m.at)) ? '' : new Date(m.at).toLocaleString()}>
|
|
{messageTime(m.at)}
|
|
</time>
|
|
<div className="miu-msg-actions">
|
|
{canReact ? (
|
|
<div className="miu-react-wrap">
|
|
<button ref={reactBtnRef} type="button" className="miu-react-btn" title="React" onClick={() => setPickerOpen((p) => !p)}>
|
|
🙂
|
|
</button>
|
|
{pickerOpen ? (
|
|
<PopoverPortal anchorRef={reactBtnRef} onClose={() => setPickerOpen(false)}>
|
|
<div className="miu-react-picker">
|
|
{REACTION_EMOJIS.map((e) => (
|
|
<button
|
|
key={e}
|
|
type="button"
|
|
className="miu-react-emoji"
|
|
onClick={() => {
|
|
onReact(m.id, e);
|
|
setPickerOpen(false);
|
|
}}
|
|
>
|
|
{e}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</PopoverPortal>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
{onOpenThread ? (
|
|
<button type="button" className="miu-react-btn" title="Reply in thread" onClick={() => onOpenThread(m.id)}>
|
|
💬
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{m.reactions && m.reactions.length > 0 ? (
|
|
<div className="miu-reactions">
|
|
{m.reactions.map((r) => (
|
|
<button key={r.emoji} type="button" className={`miu-reaction${r.mine ? ' is-mine' : ''}`} onClick={() => canReact && onReact(m.id, r.emoji)}>
|
|
{r.emoji} {r.count}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{replyCount !== undefined && replyCount > 0 && onOpenThread ? (
|
|
<button type="button" className="miu-thread-link" onClick={() => onOpenThread(m.id)}>
|
|
💬 {replyCount} {replyCount === 1 ? 'reply' : 'replies'}
|
|
</button>
|
|
) : null}
|
|
|
|
{message.mine && seen ? <span className="miu-seen">Seen</span> : null}
|
|
</div>
|
|
);
|
|
}
|