feat(channel): enhance message components

Update MessageItem with reactions and mentions, add PinPanel
for pinned messages, enhance ThreadPanel for threaded replies.
This commit is contained in:
ZhenYi 2026-05-14 23:15:26 +08:00
parent 8702312c32
commit c015871024
4 changed files with 107 additions and 38 deletions

View File

@ -21,6 +21,7 @@ interface Props {
onSetEmojiPicker: (msgId: string | null) => void;
onShowEditHistory: (msgId: string) => void;
roomId: string;
readOnly?: boolean;
}
const COMMON_EMOJIS = ['👍', '❤️', '😄', '😭', '😡', '🎉', '🚀', '👀'];
@ -37,14 +38,16 @@ export function MessageItem({
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 (isPinned) removePin(msg.id);
else addPin(msg.id);
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;
@ -53,6 +56,7 @@ export function MessageItem({
return (
<div
data-message-id={msg.id}
style={{
position: 'relative',
padding: isCompact ? '1px 16px 1px' : '4px 16px 4px',
@ -160,7 +164,7 @@ export function MessageItem({
{reactions.map((reaction) => (
<button
key={reaction.emoji}
onClick={() => onReaction(msg.id, reaction.emoji)}
onClick={readOnly ? undefined : () => onReaction(msg.id, reaction.emoji)}
style={{
display: 'flex',
alignItems: 'center',
@ -169,7 +173,7 @@ export function MessageItem({
background: reaction.reacted_by_me ? 'var(--accent-muted)' : 'var(--surface-elevated)',
border: `1px solid ${reaction.reacted_by_me ? 'var(--accent)' : 'transparent'}`,
borderRadius: 12,
cursor: 'pointer',
cursor: readOnly ? 'default' : 'pointer',
fontSize: 13,
transition: 'all 0.1s',
}}
@ -179,13 +183,13 @@ export function MessageItem({
</button>
))}
<button
onClick={() => onSetEmojiPicker(showPicker ? null : msg.id)}
onClick={readOnly ? undefined : () => onSetEmojiPicker(showPicker ? null : msg.id)}
style={{
padding: '2px 6px',
background: 'transparent',
border: '1px dashed var(--border-default)',
borderRadius: 12,
cursor: 'pointer',
cursor: readOnly ? 'default' : 'pointer',
color: 'var(--text-muted)',
fontSize: 13,
}}
@ -230,7 +234,7 @@ export function MessageItem({
)}
{/* Thread badge */}
{msg.thread && (
{msg.thread && !readOnly && (
<button
onClick={() => onOpenThread(msg)}
style={{
@ -254,7 +258,7 @@ export function MessageItem({
</div>
{/* Hover actions — Discord-style: button center aligns with message top edge */}
{isHovered && !isRevoked && (
{isHovered && !isRevoked && !readOnly && (
<div
style={{
position: 'absolute',
@ -285,6 +289,13 @@ export function MessageItem({
>
<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
@ -302,6 +313,8 @@ export function MessageItem({
>
<Trash2 className="w-3 h-3" />
</button>
</>
)}
<button
onClick={handleTogglePin}
title={isPinned ? 'Unpin' : 'Pin'}
@ -310,8 +323,6 @@ export function MessageItem({
>
<Pin className="w-3 h-3" />
</button>
</>
)}
</div>
)}
</div>

View File

@ -41,6 +41,7 @@ interface Props {
onShowEditHistory: (msgId: string) => void;
onStartReached?: () => void;
roomId: string;
readOnly?: boolean;
}
export function MessageList({
@ -57,6 +58,7 @@ export function MessageList({
onShowEditHistory,
onStartReached,
roomId,
readOnly = false,
}: Props) {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const initialScrollDoneRef = useRef(false);
@ -208,6 +210,7 @@ export function MessageList({
onSetEmojiPicker={onSetEmojiPicker}
onShowEditHistory={onShowEditHistory}
roomId={roomId}
readOnly={readOnly}
/>
);
}}

View File

@ -1,14 +1,18 @@
import { Pin, X } from 'lucide-react';
import type { PinnedMessage } from '@/contexts/room';
import { Pin, PinOff, X } from 'lucide-react';
import type { Message, PinnedMessage } from '@/contexts/room';
import { formatRelativeTime } from '@/contexts/room';
interface Props {
pins: PinnedMessage[];
messages: Message[];
onClose: () => void;
onGotoMessage: (messageId: string) => void;
onUnpin?: (messageId: string) => void;
}
export function PinPanel({ pins, onClose, onGotoMessage }: Props) {
export function PinPanel({ pins, messages, onClose, onGotoMessage, onUnpin }: Props) {
const messageById = new Map(messages.map((msg) => [msg.id, msg]));
return (
<div className="pin-panel">
<div className="pin-panel-header">
@ -28,21 +32,58 @@ export function PinPanel({ pins, onClose, onGotoMessage }: Props) {
No pinned messages yet.
</div>
) : (
pins.map((pin) => (
pins.map((pin) => {
const msg = messageById.get(pin.message);
return (
<div key={pin.message} className="pin-item" style={{ alignItems: 'flex-start', gap: 8 }}>
<button
key={pin.message}
className="pin-item"
onClick={() => onGotoMessage(pin.message)}
style={{
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
gap: 4,
background: 'none',
border: 'none',
padding: 0,
textAlign: 'left',
cursor: 'pointer',
}}
>
<Pin className="w-3 h-3" style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}>
#{pin.message.slice(0, 8)}
<span style={{ color: 'var(--text-primary)', fontSize: 13, fontWeight: 600 }}>
{msg?.display_name ?? msg?.sender_id ?? `#${pin.message.slice(0, 8)}`}
</span>
<span style={{ color: 'var(--text-muted)', fontSize: 11, marginLeft: 'auto' }}>
{formatRelativeTime(pin.pinned_at)}
<span
style={{
color: msg ? 'var(--text-secondary)' : 'var(--text-muted)',
fontSize: 12,
lineHeight: 1.4,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
}}
>
{msg?.content ?? 'Pinned message is not loaded in the current history window.'}
</span>
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}>
Pinned {formatRelativeTime(pin.pinned_at)}
</span>
</button>
))
{onUnpin && (
<button
onClick={() => onUnpin(pin.message)}
title="Unpin"
className="thread-close-btn"
style={{ flexShrink: 0 }}
>
<PinOff className="w-3 h-3" style={{ color: 'var(--text-muted)' }} />
</button>
)}
</div>
);
})
)}
</div>
</div>

View File

@ -5,6 +5,9 @@ import type { Message } from '@/contexts/room';
import { formatTime } from '@/contexts/room';
import { Avatar } from './Avatar';
import { getWsClient } from '@/ws';
import { ProjectJoinBanner } from '@/app/project/layout';
import { IrRenderer } from '@/lib/ir/renderer';
import { extractIrNodes } from '@/lib/ir/parser';
function safeGetClient() {
try { return getWsClient(); } catch { return null; }
@ -21,6 +24,7 @@ interface Props {
sendMessage: (content: string, opts?: { thread?: string }) => void;
onTypingStart: () => void;
onTypingStop: () => void;
readOnly?: boolean;
}
const COMMON_EMOJIS = ['👍', '❤️', '😄', '😭', '😡', '🎉'];
@ -32,6 +36,7 @@ export function ThreadPanel({
sendMessage,
onTypingStart,
onTypingStop,
readOnly = false,
}: Props) {
const [inputValue, setInputValue] = useState('');
const [showReactionPicker, setShowReactionPicker] = useState<string | null>(null);
@ -43,7 +48,7 @@ export function ThreadPanel({
}, [thread.messages]);
const handleSend = () => {
if (!inputValue.trim()) return;
if (readOnly || !inputValue.trim()) return;
sendMessage(inputValue.trim(), { thread: thread.id });
setInputValue('');
onTypingStop();
@ -51,6 +56,7 @@ export function ThreadPanel({
};
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (readOnly) return;
setInputValue(e.target.value);
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
onTypingStart();
@ -58,11 +64,13 @@ export function ThreadPanel({
};
const handleResolve = () => {
if (readOnly) return;
const c = safeGetClient();
if (c) c.resolveThread(thread.id);
};
const handleArchive = () => {
if (readOnly) return;
const c = safeGetClient();
if (c) c.archiveThread(thread.id);
};
@ -118,7 +126,7 @@ export function ThreadPanel({
{isRevoked ? (
<span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Message deleted</span>
) : (
msg.content
<IrRenderer nodes={extractIrNodes(msg.content)} />
)}
</div>
</div>
@ -136,6 +144,11 @@ export function ThreadPanel({
</div>
)}
{readOnly ? (
<div className="thread-input-area">
<ProjectJoinBanner compact message="Join this project to reply in threads." />
</div>
) : (
<div className="thread-input-area">
<button
className="thread-reaction-btn"
@ -164,6 +177,7 @@ export function ThreadPanel({
<Send className="w-4 h-4" />
</button>
</div>
)}
</div>
);
}