feat(messaging-ui): Slack-style threaded-replies side panel
Replies (messages with parentInteractionId) now open in a right-hand ThreadPane instead of inline quoting: - extracted a shared Composer (draft + @mention autocomplete + attach) and MessageItem (bubble + mentions + attachment + reactions + reply affordance) - Thread shows top-level messages only; a message with replies gets a '💬 N replies' link, and hovering shows 💬 'Reply in thread' - ThreadPane renders the root + its replies + a composer that posts back with parentInteractionId; Messenger becomes 3-column (list | thread | pane), pane closes on conversation switch - no contract/adapter/backend change (parentInteractionId was already wired) - thread-pane render test (open → reply → parent shows the count); 58 green Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { useState } from 'react';
|
||||
import { highlightMentions } from '../mentions';
|
||||
import type { Attachment } from '../types';
|
||||
import type { UiMessage } from '../hooks/use-messages';
|
||||
|
||||
const REACTION_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
|
||||
|
||||
const isImage = (mime: string): boolean => mime.startsWith('image/');
|
||||
|
||||
function AttachmentView({ att }: { att: Attachment }) {
|
||||
if (isImage(att.mime)) {
|
||||
return (
|
||||
<a href={att.url} target="_blank" rel="noreferrer" className="miu-att-img-link">
|
||||
<img src={att.url} alt={att.name} className="miu-att-img" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={att.url} target="_blank" rel="noreferrer" className="miu-att-file">
|
||||
📎 {att.name}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** One rendered message: bubble (with @mention highlighting), attachment, reactions, seen tick,
|
||||
* and — in the main thread only — a thread/reply affordance. */
|
||||
export function MessageItem({
|
||||
message,
|
||||
memberNames,
|
||||
canReact,
|
||||
onReact,
|
||||
seen,
|
||||
replyCount,
|
||||
onOpenThread,
|
||||
}: {
|
||||
message: UiMessage;
|
||||
memberNames: string[];
|
||||
canReact: boolean;
|
||||
onReact: (messageId: string, emoji: string) => void;
|
||||
seen: boolean;
|
||||
/** Present only in the main thread (not inside the pane). undefined => no thread affordance. */
|
||||
replyCount?: number;
|
||||
onOpenThread?: (messageId: string) => void;
|
||||
}) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const m = message;
|
||||
|
||||
return (
|
||||
<div className={`miu-msg${message.mine ? ' is-mine' : ''}${message.pending ? ' is-pending' : ''}`}>
|
||||
<div className="miu-bubble-row">
|
||||
<div className="miu-bubble">
|
||||
{m.text ? highlightMentions(m.text, memberNames) : null}
|
||||
{m.attachment ? <AttachmentView att={m.attachment} /> : null}
|
||||
</div>
|
||||
<div className="miu-msg-actions">
|
||||
{canReact ? (
|
||||
<div className="miu-react-wrap">
|
||||
<button type="button" className="miu-react-btn" title="React" onClick={() => setPickerOpen((p) => !p)}>
|
||||
🙂
|
||||
</button>
|
||||
{pickerOpen ? (
|
||||
<div className="miu-react-picker">
|
||||
{REACTION_EMOJIS.map((e) => (
|
||||
<button
|
||||
key={e}
|
||||
type="button"
|
||||
className="miu-react-emoji"
|
||||
onClick={() => {
|
||||
onReact(m.id, e);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{onOpenThread ? (
|
||||
<button type="button" className="miu-react-btn" title="Reply in thread" onClick={() => onOpenThread(m.id)}>
|
||||
💬
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{m.reactions && m.reactions.length > 0 ? (
|
||||
<div className="miu-reactions">
|
||||
{m.reactions.map((r) => (
|
||||
<button key={r.emoji} type="button" className={`miu-reaction${r.mine ? ' is-mine' : ''}`} onClick={() => canReact && onReact(m.id, r.emoji)}>
|
||||
{r.emoji} {r.count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{replyCount !== undefined && replyCount > 0 && onOpenThread ? (
|
||||
<button type="button" className="miu-thread-link" onClick={() => onOpenThread(m.id)}>
|
||||
💬 {replyCount} {replyCount === 1 ? 'reply' : 'replies'}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{message.mine && seen ? <span className="miu-seen">Seen</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user