gitdataai/src/components/channel/MessageItem.tsx
ZhenYi c015871024 feat(channel): enhance message components
Update MessageItem with reactions and mentions, add PinPanel
for pinned messages, enhance ThreadPanel for threaded replies.
2026-05-14 23:15:26 +08:00

331 lines
11 KiB
TypeScript

import { useState } from 'react';
import { Edit2, Trash2, Reply, MessageSquare, Pin } from 'lucide-react';
import { IrRenderer } from '@/lib/ir/renderer';
import { extractIrNodes } from '@/lib/ir/parser';
import type { Message } from '@/contexts/room';
import { formatRelativeTime } from '@/contexts/room';
import { Avatar } from './Avatar';
import { useRoom } from '@/contexts/room';
import { MESSAGE_ITEM } from '@/css/channel/styles';
interface Props {
msg: Message;
isCompact: boolean;
isFirst: boolean;
emojiPickerMessageId: string | null;
onReaction: (msgId: string, emoji: string) => void;
onEdit: (msg: Message) => void;
onDelete: (msgId: string) => void;
onReply: (msg: Message) => void;
onOpenThread: (msg: Message) => void;
onSetEmojiPicker: (msgId: string | null) => void;
onShowEditHistory: (msgId: string) => void;
roomId: string;
readOnly?: boolean;
}
const COMMON_EMOJIS = ['👍', '❤️', '😄', '😭', '😡', '🎉', '🚀', '👀'];
export function MessageItem({
msg,
isCompact,
isFirst,
emojiPickerMessageId,
onReaction,
onEdit,
onDelete,
onReply,
onOpenThread,
onSetEmojiPicker,
onShowEditHistory,
readOnly = false,
}: Props) {
const [isHovered, setIsHovered] = useState(false);
const isRevoked = !!msg.revoked;
const { addPin, removePin, pinnedMessages } = useRoom();
const isPinned = pinnedMessages.some((p) => p.message === msg.id);
const handleTogglePin = () => {
if (readOnly) return;
const action = isPinned ? removePin(msg.id) : addPin(msg.id);
action.catch((err) => console.error('[MessageItem] failed to toggle pin:', err));
};
const isEdited = !!msg.edited_at;
const isStreaming = msg.is_streaming === true;
const reactions = msg._localReactions ?? [];
const showPicker = emojiPickerMessageId === msg.id;
return (
<div
data-message-id={msg.id}
style={{
position: 'relative',
padding: isCompact ? '1px 16px 1px' : '4px 16px 4px',
backgroundColor: isHovered ? 'var(--hover-bg)' : 'transparent',
transition: 'background-color 0.1s',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div style={{ display: 'flex', alignItems: 'flex-start' }}>
{/* Avatar + spacer: first message shows avatar, compact uses spacer for alignment */}
{isFirst ? (
<Avatar
senderType={msg.sender_type}
displayName={msg.display_name}
avatarUrl={undefined}
size={40}
/>
) : isCompact ? (
<div style={{ width: 40, flexShrink: 0 }} />
) : null}
{/* Gap between avatar/spacer and content */}
{isFirst || isCompact ? <div style={{ width: 12, flexShrink: 0 }} /> : null}
<div className={MESSAGE_ITEM.content} style={{ flex: 1, minWidth: 0 }}>
{isFirst && (
<div
className={MESSAGE_ITEM.header}
style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 2 }}
>
<span style={{ color: 'var(--text-primary)', fontWeight: 500, fontSize: 16 }}>
{msg.display_name ?? msg.sender_id ?? 'Unknown'}
</span>
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}>
{formatRelativeTime(msg.send_at)}
</span>
{isEdited && (
<button
onClick={() => onShowEditHistory(msg.id)}
style={{
background: 'none',
border: 'none',
color: 'var(--text-muted)',
fontSize: 11,
cursor: 'pointer',
padding: 0,
}}
>
(edited)
</button>
)}
{isStreaming && (
<span
style={{ color: 'var(--accent)', fontSize: 12, animation: 'pulse 1.5s infinite' }}
>
</span>
)}
</div>
)}
<div style={{ fontSize: 13, lineHeight: 1.75, wordWrap: 'break-word' }}>
{isRevoked ? (
<span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>
This message was deleted
</span>
) : (
<>
{msg.thinking_content && (
<details
style={{
marginBottom: 6,
padding: '6px 10px',
background: 'var(--surface-elevated)',
borderRadius: 4,
fontSize: 12,
}}
>
<summary
style={{ cursor: 'pointer', color: 'var(--text-muted)', fontSize: 12 }}
>
Thinking...
</summary>
<pre
style={{
marginTop: 6,
whiteSpace: 'pre-wrap',
color: 'var(--text-secondary)',
fontSize: 12,
}}
>
{msg.thinking_content}
</pre>
</details>
)}
<IrRenderer nodes={extractIrNodes(msg.content)} />
</>
)}
</div>
{/* Reactions */}
{reactions.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 6 }}>
{reactions.map((reaction) => (
<button
key={reaction.emoji}
onClick={readOnly ? undefined : () => onReaction(msg.id, reaction.emoji)}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '2px 8px',
background: reaction.reacted_by_me ? 'var(--accent-muted)' : 'var(--surface-elevated)',
border: `1px solid ${reaction.reacted_by_me ? 'var(--accent)' : 'transparent'}`,
borderRadius: 12,
cursor: readOnly ? 'default' : 'pointer',
fontSize: 13,
transition: 'all 0.1s',
}}
>
<span>{reaction.emoji}</span>
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}>{reaction.count}</span>
</button>
))}
<button
onClick={readOnly ? undefined : () => onSetEmojiPicker(showPicker ? null : msg.id)}
style={{
padding: '2px 6px',
background: 'transparent',
border: '1px dashed var(--border-default)',
borderRadius: 12,
cursor: readOnly ? 'default' : 'pointer',
color: 'var(--text-muted)',
fontSize: 13,
}}
>
+
</button>
</div>
)}
{/* Quick emoji picker */}
{showPicker && (
<div
style={{
display: 'flex',
gap: 2,
padding: 6,
marginTop: 4,
background: 'var(--surface-elevated)',
borderRadius: 8,
}}
>
{COMMON_EMOJIS.map((e) => (
<button
key={e}
onClick={() => {
onReaction(msg.id, e);
onSetEmojiPicker(null);
}}
style={{
padding: 4,
background: 'transparent',
border: 'none',
borderRadius: 4,
cursor: 'pointer',
fontSize: 18,
}}
>
{e}
</button>
))}
</div>
)}
{/* Thread badge */}
{msg.thread && !readOnly && (
<button
onClick={() => onOpenThread(msg)}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
marginTop: 4,
background: 'none',
border: 'none',
color: 'var(--accent)',
fontSize: 12,
cursor: 'pointer',
padding: 0,
}}
>
<MessageSquare className="w-3 h-3" />
View thread
</button>
)}
</div>
</div>
{/* Hover actions — Discord-style: button center aligns with message top edge */}
{isHovered && !isRevoked && !readOnly && (
<div
style={{
position: 'absolute',
top: 0,
right: 16,
transform: 'translateY(-50%)',
display: 'flex',
gap: 2,
background: 'var(--surface-elevated)',
border: '1px solid var(--border-default)',
borderRadius: 8,
padding: 4,
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
zIndex: 10,
}}
>
<button
onClick={() => onReaction(msg.id, '👍')}
title="React"
className={MESSAGE_ITEM.actionButton}
>
👍
</button>
<button
onClick={() => onReply(msg)}
title="Reply"
className={MESSAGE_ITEM.actionButton}
>
<Reply className="w-3 h-3" />
</button>
<button
onClick={() => onOpenThread(msg)}
title={msg.thread ? 'Open thread' : 'Start thread'}
className={MESSAGE_ITEM.actionButton}
>
<MessageSquare className="w-3 h-3" />
</button>
{msg.sender_type !== 'ai' && (
<>
<button
onClick={() => onEdit(msg)}
title="Edit"
className={MESSAGE_ITEM.actionButton}
>
<Edit2 className="w-3 h-3" />
</button>
<button
onClick={() => onDelete(msg.id)}
title="Delete"
className={MESSAGE_ITEM.actionButton}
style={{ color: 'var(--destructive)' }}
>
<Trash2 className="w-3 h-3" />
</button>
</>
)}
<button
onClick={handleTogglePin}
title={isPinned ? 'Unpin' : 'Pin'}
className={MESSAGE_ITEM.actionButton}
style={{ color: isPinned ? 'var(--text-primary)' : undefined }}
>
<Pin className="w-3 h-3" />
</button>
</div>
)}
</div>
);
}