From 7a0fb2ca97b0197d1146fb34db615c9c4e750274 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 22 Jul 2026 00:08:26 +0530 Subject: [PATCH] feat(messaging-ui): reaction picker + attachments in the Thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/components/thread.tsx | 138 +++++++++++++++--- .../src/hooks/use-messages.ts | 12 +- packages/iios-messaging-ui/src/styles.css | 113 ++++++++++++++ 3 files changed, 238 insertions(+), 25 deletions(-) diff --git a/packages/iios-messaging-ui/src/components/thread.tsx b/packages/iios-messaging-ui/src/components/thread.tsx index a67911a..fc94b17 100644 --- a/packages/iios-messaging-ui/src/components/thread.tsx +++ b/packages/iios-messaging-ui/src/components/thread.tsx @@ -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 ( + + {att.name} + + ); + } + return ( + + πŸ“Ž {att.name} + + ); +} + /** - * 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(null); + const [staged, setStaged] = useState(null); + const [uploading, setUploading] = useState(false); + const fileRef = useRef(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): Promise { + 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 { 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 ?
{error}
: null} {messages.map((m) => (
-
{highlightMentions(m.text, memberNames)}
+
+
+ {m.text ? highlightMentions(m.text, memberNames) : null} + {m.attachment ? : null} +
+ {canReact ? ( +
+ + {pickerFor === m.id ? ( +
+ {REACTION_EMOJIS.map((e) => ( + + ))} +
+ ) : null} +
+ ) : null} +
{m.reactions && m.reactions.length > 0 ? (
{m.reactions.map((r) => ( - + ))}
) : null} @@ -104,20 +176,38 @@ export function Thread({ threadId }: { threadId: string | null }) { ))} ) : null} - { - setDraft(e.target.value); - sendTyping(); - }} - onKeyDown={onKeyDown} - /> - + {staged ? ( +
+ πŸ“Ž {staged.name} + +
+ ) : null} +
+ {canUpload ? ( + <> + + + + ) : null} + { + setDraft(e.target.value); + sendTyping(); + }} + onKeyDown={onKeyDown} + /> + +
); diff --git a/packages/iios-messaging-ui/src/hooks/use-messages.ts b/packages/iios-messaging-ui/src/hooks/use-messages.ts index d50954d..ccb49a6 100644 --- a/packages/iios-messaging-ui/src/hooks/use-messages.ts +++ b/packages/iios-messaging-ui/src/hooks/use-messages.ts @@ -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; react: (messageId: string, emoji: string) => Promise; + upload: (file: File) => Promise; typingUserIds: string[]; seenIds: Set; sendTyping: () => void; @@ -147,6 +148,14 @@ export function useMessages(threadId: string | null): MessagesState { [adapter, threadId], ); + const upload = useCallback( + async (file: File): Promise => { + 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, diff --git a/packages/iios-messaging-ui/src/styles.css b/packages/iios-messaging-ui/src/styles.css index 3017cde..2d0d0e5 100644 --- a/packages/iios-messaging-ui/src/styles.css +++ b/packages/iios-messaging-ui/src/styles.css @@ -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;