feat(messaging-ui): reaction picker + attachments in the Thread

- useMessages exposes upload(); Thread gains a hover reaction picker (react to
  a message) and an attach button (upload → stage → send), plus inline image /
  file rendering of message attachments — all capability-degrading (hidden when
  the adapter lacks react/upload)
- themeable styles for the picker, attach button, staged chip, and attachments
- 57 tests still green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 00:08:26 +05:30
parent ade1d68015
commit 7a0fb2ca97
3 changed files with 238 additions and 25 deletions
@@ -1,4 +1,4 @@
import { useMemo, useState, type FormEvent, type KeyboardEvent } from 'react';
import { useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from 'react';
import { useMessages } from '../hooks/use-messages';
import { useMembers } from '../hooks/use-members';
import {
@@ -8,6 +8,9 @@ import {
resolveMentions,
trailingMentionQuery,
} from '../mentions';
import type { Attachment } from '../types';
const REACTION_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
interface Suggestion {
key: string;
@@ -15,15 +18,36 @@ interface Suggestion {
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>
);
}
/**
* One conversation: message list + composer, driven by useMessages. Adds @mention autocomplete
* (from the thread's members) and highlights mentions in message bodies. Transport-agnostic.
* 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.
*/
export function Thread({ threadId }: { threadId: string | null }) {
const { messages, loading, error, send, typingUserIds, seenIds, sendTyping } = useMessages(threadId);
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);
@@ -40,17 +64,36 @@ export function Thread({ threadId }: { threadId: string | null }) {
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 || sending) return;
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 } : undefined);
await send(text, {
...(mentions.length ? { mentions } : {}),
...(att ? { attachment: att } : {}),
});
} catch {
// surfaced via the hook's error
setStaged(att);
} finally {
setSending(false);
}
@@ -74,13 +117,42 @@ export function Thread({ threadId }: { threadId: string | 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">{highlightMentions(m.text, memberNames)}</div>
<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) => (
<span key={r.emoji} className={`miu-reaction${r.mine ? ' is-mine' : ''}`}>
<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}
</span>
</button>
))}
</div>
) : null}
@@ -104,20 +176,38 @@ export function Thread({ threadId }: { threadId: string | null }) {
))}
</ul>
) : 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() || sending}>
Send
</button>
{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>
</div>
);