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;
|
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>
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user