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>
);
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAdapter } from '../provider';
import { isOwnMessage } from '../types';
import type { Message, SendOpts } from '../types';
import type { Attachment, Message, SendOpts } from '../types';
const TYPING_TTL_MS = 3500;
@@ -15,6 +15,7 @@ export interface MessagesState {
error: string | null;
send: (content: string, opts?: SendOpts) => Promise<void>;
react: (messageId: string, emoji: string) => Promise<void>;
upload: (file: File) => Promise<Attachment>;
typingUserIds: string[];
seenIds: Set<string>;
sendTyping: () => void;
@@ -147,6 +148,14 @@ export function useMessages(threadId: string | null): MessagesState {
[adapter, threadId],
);
const upload = useCallback(
async (file: File): Promise<Attachment> => {
if (!adapter.upload) throw new Error('uploads are not supported by this adapter');
return adapter.upload(file);
},
[adapter],
);
const sendTyping = useCallback(() => {
if (threadId) adapter.sendTyping(threadId);
}, [adapter, threadId]);
@@ -196,6 +205,7 @@ export function useMessages(threadId: string | null): MessagesState {
error,
send,
react,
upload,
typingUserIds,
seenIds: seenMine,
sendTyping,
+113
View File
@@ -149,6 +149,57 @@
.miu-msg.is-pending {
opacity: 0.6;
}
.miu-bubble-row {
display: flex;
align-items: center;
gap: 4px;
}
.miu-msg.is-mine .miu-bubble-row {
flex-direction: row-reverse;
}
.miu-react-wrap {
position: relative;
flex-shrink: 0;
}
.miu-react-btn {
border: none;
background: none;
cursor: pointer;
opacity: 0;
font-size: 14px;
padding: 2px;
transition: opacity 0.12s;
}
.miu-msg:hover .miu-react-btn {
opacity: 0.6;
}
.miu-react-btn:hover {
opacity: 1;
}
.miu-react-picker {
position: absolute;
bottom: 100%;
margin-bottom: 4px;
display: flex;
gap: 2px;
padding: 4px;
border-radius: 999px;
border: 1px solid var(--miu-border);
background: var(--miu-panel);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
z-index: 5;
}
.miu-react-emoji {
border: none;
background: none;
cursor: pointer;
font-size: 16px;
padding: 2px 4px;
border-radius: 6px;
}
.miu-react-emoji:hover {
background: var(--miu-panel-2);
}
.miu-reactions {
display: flex;
gap: 4px;
@@ -160,10 +211,35 @@
border-radius: 999px;
border: 1px solid var(--miu-border);
background: var(--miu-panel-2);
color: var(--miu-text);
cursor: pointer;
}
.miu-reaction.is-mine {
border-color: var(--miu-accent);
}
/* attachments */
.miu-att-img-link {
display: inline-block;
margin-top: 4px;
}
.miu-att-img {
max-width: 260px;
max-height: 220px;
border-radius: 8px;
border: 1px solid var(--miu-border);
}
.miu-att-file {
display: inline-block;
margin-top: 4px;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid var(--miu-border);
background: var(--miu-panel-2);
color: var(--miu-text);
text-decoration: none;
font-size: 13px;
}
.miu-seen {
font-size: 11px;
color: var(--miu-muted);
@@ -189,10 +265,47 @@
.miu-composer {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border-top: 1px solid var(--miu-border);
}
.miu-composer-row {
display: flex;
gap: 8px;
}
.miu-file-input {
display: none;
}
.miu-attach-btn {
flex-shrink: 0;
width: 38px;
border-radius: 10px;
border: 1px solid var(--miu-border);
background: var(--miu-panel);
color: var(--miu-text);
cursor: pointer;
font-size: 16px;
}
.miu-staged {
display: inline-flex;
align-items: center;
gap: 8px;
align-self: flex-start;
padding: 5px 10px;
border-radius: 999px;
border: 1px solid var(--miu-border);
background: var(--miu-panel-2);
font-size: 13px;
}
.miu-staged-x {
border: none;
background: none;
color: var(--miu-muted);
cursor: pointer;
padding: 0;
line-height: 1;
}
.miu-suggest {
position: absolute;
left: 12px;