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:
parent
8702312c32
commit
c015871024
@ -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,16 +313,16 @@ export function MessageItem({
|
||||
>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@ -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) => (
|
||||
<button
|
||||
key={pin.message}
|
||||
className="pin-item"
|
||||
onClick={() => onGotoMessage(pin.message)}
|
||||
>
|
||||
<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>
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: 11, marginLeft: 'auto' }}>
|
||||
{formatRelativeTime(pin.pinned_at)}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
pins.map((pin) => {
|
||||
const msg = messageById.get(pin.message);
|
||||
return (
|
||||
<div key={pin.message} className="pin-item" style={{ alignItems: 'flex-start', gap: 8 }}>
|
||||
<button
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user