fa93173b1f
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>
62 lines
2.4 KiB
TypeScript
62 lines
2.4 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { useMessages } from '../hooks/use-messages';
|
|
import { useMembers } from '../hooks/use-members';
|
|
import { Composer } from './composer';
|
|
import { MessageItem } from './message-item';
|
|
|
|
/**
|
|
* The main conversation view: top-level messages + composer. Replies (messages with a
|
|
* parentInteractionId) are hidden here and live in the ThreadPane — a message with replies shows a
|
|
* "N replies" link that opens it. @mentions, reactions, and attachments all work.
|
|
*/
|
|
export function Thread({
|
|
threadId,
|
|
activeRootId,
|
|
onOpenThread,
|
|
}: {
|
|
threadId: string | null;
|
|
activeRootId?: string | null;
|
|
onOpenThread?: (rootId: string) => void;
|
|
}) {
|
|
const { messages, loading, error, send, react, upload, typingUserIds, seenIds, sendTyping, canReact, canUpload } = useMessages(threadId);
|
|
const members = useMembers(threadId);
|
|
const memberNames = useMemo(() => members.map((m) => m.name), [members]);
|
|
|
|
const topLevel = useMemo(() => messages.filter((m) => !m.parentInteractionId), [messages]);
|
|
const replyCount = useMemo(() => {
|
|
const counts = new Map<string, number>();
|
|
for (const m of messages) if (m.parentInteractionId) counts.set(m.parentInteractionId, (counts.get(m.parentInteractionId) ?? 0) + 1);
|
|
return counts;
|
|
}, [messages]);
|
|
|
|
if (!threadId) {
|
|
return <div className="miu-empty miu-thread-empty">Select a conversation.</div>;
|
|
}
|
|
|
|
return (
|
|
<div className={`miu-thread${activeRootId ? ' has-pane' : ''}`}>
|
|
<div className="miu-messages">
|
|
{loading && messages.length === 0 ? <div className="miu-empty">Loading…</div> : null}
|
|
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
|
{topLevel.map((m) => (
|
|
<MessageItem
|
|
key={m.id}
|
|
message={m}
|
|
memberNames={memberNames}
|
|
canReact={canReact}
|
|
onReact={(id, emoji) => void react(id, emoji)}
|
|
seen={seenIds.has(m.id)}
|
|
replyCount={replyCount.get(m.id) ?? 0}
|
|
{...(onOpenThread ? { onOpenThread } : {})}
|
|
/>
|
|
))}
|
|
{typingUserIds.length > 0 ? (
|
|
<div className="miu-typing">{typingUserIds.length === 1 ? 'typing…' : 'several people are typing…'}</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<Composer members={members} canUpload={canUpload} upload={upload} onSend={send} onTyping={sendTyping} />
|
|
</div>
|
|
);
|
|
}
|