3c5c6964e2
- Mail reader: .miu-mail-msg gets flex:0 0 auto so cards keep their natural height in the scrolling column instead of being compressed + clipped by overflow:hidden. - Reaction/emoji picker renders through a PopoverPortal to <body> (positioned + themed) so it floats above the message list instead of being cut by the scroll container's overflow. 72 tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
112 lines
3.8 KiB
TypeScript
112 lines
3.8 KiB
TypeScript
import { useRef, useState } from 'react';
|
|
import { highlightMentions } from '../mentions';
|
|
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/');
|
|
|
|
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 ? highlightMentions(m.text, memberNames) : null}
|
|
{m.attachment ? <AttachmentView att={m.attachment} /> : null}
|
|
</div>
|
|
<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>
|
|
);
|
|
}
|