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:
@@ -1,214 +1,61 @@
|
||||
import { useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useMessages } from '../hooks/use-messages';
|
||||
import { useMembers } from '../hooks/use-members';
|
||||
import {
|
||||
SPECIAL_MENTIONS,
|
||||
highlightMentions,
|
||||
insertMention,
|
||||
resolveMentions,
|
||||
trailingMentionQuery,
|
||||
} from '../mentions';
|
||||
import type { Attachment } from '../types';
|
||||
|
||||
const REACTION_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
|
||||
|
||||
interface Suggestion {
|
||||
key: string;
|
||||
label: string;
|
||||
insert: string;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
import { Composer } from './composer';
|
||||
import { MessageItem } from './message-item';
|
||||
|
||||
/**
|
||||
* One conversation: message list + composer, driven by useMessages. Includes @mention autocomplete,
|
||||
* emoji reactions, and attachments — each degrading if the adapter doesn't support it. Transport-agnostic.
|
||||
* 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 }: { threadId: string | null }) {
|
||||
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 [draft, setDraft] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [pickerFor, setPickerFor] = useState<string | null>(null);
|
||||
const [staged, setStaged] = useState<Attachment | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const memberNames = useMemo(() => members.map((m) => m.name), [members]);
|
||||
const query = trailingMentionQuery(draft);
|
||||
const suggestions = useMemo<Suggestion[]>(() => {
|
||||
if (query === null) return [];
|
||||
const q = query.toLowerCase();
|
||||
const specials = SPECIAL_MENTIONS.filter((s) => s.startsWith(q)).map((s) => ({ key: `@${s}`, label: `@${s}`, insert: s }));
|
||||
const people = members.filter((m) => m.name.toLowerCase().includes(q)).map((m) => ({ key: m.id, label: m.name, insert: m.name }));
|
||||
return [...specials, ...people].slice(0, 6);
|
||||
}, [query, members]);
|
||||
const showSuggest = query !== null && suggestions.length > 0;
|
||||
|
||||
function pick(insert: string): void {
|
||||
setDraft((d) => insertMention(d, insert));
|
||||
}
|
||||
|
||||
async function onPickFile(e: ChangeEvent<HTMLInputElement>): Promise<void> {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
setStaged(await upload(file));
|
||||
} catch {
|
||||
// swallow — a real host surfaces upload errors; keep the composer usable
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(e?: FormEvent): Promise<void> {
|
||||
e?.preventDefault();
|
||||
const text = draft.trim();
|
||||
if ((!text && !staged) || sending) return;
|
||||
const mentions = resolveMentions(text, members);
|
||||
const att = staged;
|
||||
setDraft('');
|
||||
setStaged(null);
|
||||
setSending(true);
|
||||
try {
|
||||
await send(text, {
|
||||
...(mentions.length ? { mentions } : {}),
|
||||
...(att ? { attachment: att } : {}),
|
||||
});
|
||||
} catch {
|
||||
setStaged(att);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent<HTMLInputElement>): void {
|
||||
if (showSuggest && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
pick(suggestions[0]!.insert);
|
||||
}
|
||||
}
|
||||
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">
|
||||
<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}
|
||||
{messages.map((m) => (
|
||||
<div key={m.id} className={`miu-msg${m.mine ? ' is-mine' : ''}${m.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>
|
||||
{canReact ? (
|
||||
<div className="miu-react-wrap">
|
||||
<button type="button" className="miu-react-btn" title="React" onClick={() => setPickerFor((p) => (p === m.id ? null : m.id))}>
|
||||
🙂
|
||||
</button>
|
||||
{pickerFor === m.id ? (
|
||||
<div className="miu-react-picker">
|
||||
{REACTION_EMOJIS.map((e) => (
|
||||
<button
|
||||
key={e}
|
||||
type="button"
|
||||
className="miu-react-emoji"
|
||||
onClick={() => {
|
||||
void react(m.id, e);
|
||||
setPickerFor(null);
|
||||
}}
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</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 && void react(m.id, r.emoji)}>
|
||||
{r.emoji} {r.count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{m.mine && seenIds.has(m.id) ? <span className="miu-seen">Seen</span> : null}
|
||||
</div>
|
||||
{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>
|
||||
|
||||
<form className="miu-composer" onSubmit={submit}>
|
||||
{showSuggest ? (
|
||||
<ul className="miu-suggest" role="listbox" aria-label="Mention suggestions">
|
||||
{suggestions.map((s) => (
|
||||
<li key={s.key}>
|
||||
<button type="button" role="option" aria-selected="false" className="miu-suggest-item" onClick={() => pick(s.insert)}>
|
||||
{s.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{staged ? (
|
||||
<div className="miu-staged">
|
||||
📎 {staged.name}
|
||||
<button type="button" className="miu-staged-x" onClick={() => setStaged(null)} aria-label="Remove attachment">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="miu-composer-row">
|
||||
{canUpload ? (
|
||||
<>
|
||||
<input ref={fileRef} type="file" className="miu-file-input" onChange={onPickFile} aria-label="Attach a file" />
|
||||
<button type="button" className="miu-attach-btn" title="Attach a file" disabled={uploading} onClick={() => fileRef.current?.click()}>
|
||||
{uploading ? '…' : '📎'}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<input
|
||||
className="miu-input"
|
||||
value={draft}
|
||||
placeholder="Type a message… @ to mention"
|
||||
aria-label="Message"
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
sendTyping();
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<button type="submit" className="miu-send" disabled={(!draft.trim() && !staged) || sending}>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<Composer members={members} canUpload={canUpload} upload={upload} onSend={send} onTyping={sendTyping} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user