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

View File

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

View File

@ -1,14 +1,18 @@
import { Pin, X } from 'lucide-react'; import { Pin, PinOff, X } from 'lucide-react';
import type { PinnedMessage } from '@/contexts/room'; import type { Message, PinnedMessage } from '@/contexts/room';
import { formatRelativeTime } from '@/contexts/room'; import { formatRelativeTime } from '@/contexts/room';
interface Props { interface Props {
pins: PinnedMessage[]; pins: PinnedMessage[];
messages: Message[];
onClose: () => void; onClose: () => void;
onGotoMessage: (messageId: string) => 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 ( return (
<div className="pin-panel"> <div className="pin-panel">
<div className="pin-panel-header"> <div className="pin-panel-header">
@ -28,23 +32,60 @@ export function PinPanel({ pins, onClose, onGotoMessage }: Props) {
No pinned messages yet. No pinned messages yet.
</div> </div>
) : ( ) : (
pins.map((pin) => ( pins.map((pin) => {
<button const msg = messageById.get(pin.message);
key={pin.message} return (
className="pin-item" <div key={pin.message} className="pin-item" style={{ alignItems: 'flex-start', gap: 8 }}>
onClick={() => onGotoMessage(pin.message)} <button
> onClick={() => onGotoMessage(pin.message)}
<Pin className="w-3 h-3" style={{ color: 'var(--text-muted)', flexShrink: 0 }} /> style={{
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}> flex: 1,
#{pin.message.slice(0, 8)} minWidth: 0,
</span> display: 'flex',
<span style={{ color: 'var(--text-muted)', fontSize: 11, marginLeft: 'auto' }}> flexDirection: 'column',
{formatRelativeTime(pin.pinned_at)} gap: 4,
</span> background: 'none',
</button> border: 'none',
)) padding: 0,
textAlign: 'left',
cursor: 'pointer',
}}
>
<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: 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>
</div> </div>
); );
} }

View File

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