Update MessageItem with reactions and mentions, add PinPanel for pinned messages, enhance ThreadPanel for threaded replies.
331 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|