diff --git a/packages/iios-messaging-ui/src/components/composer.tsx b/packages/iios-messaging-ui/src/components/composer.tsx new file mode 100644 index 0000000..56c50ec --- /dev/null +++ b/packages/iios-messaging-ui/src/components/composer.tsx @@ -0,0 +1,147 @@ +import { useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from 'react'; +import { + SPECIAL_MENTIONS, + insertMention, + resolveMentions, + trailingMentionQuery, +} from '../mentions'; +import type { Attachment, Person, SendOpts } from '../types'; + +interface Suggestion { + key: string; + label: string; + insert: string; +} + +/** + * The message input: draft, @mention autocomplete, and attachment staging. Shared by the main + * Thread and the ThreadPane (which passes a parentInteractionId so a reply lands in the thread). + */ +export function Composer({ + members, + canUpload, + upload, + onSend, + onTyping, + parentInteractionId, + placeholder = 'Type a message… @ to mention', +}: { + members: Person[]; + canUpload: boolean; + upload: (file: File) => Promise; + onSend: (text: string, opts?: SendOpts) => Promise; + onTyping?: () => void; + parentInteractionId?: string; + placeholder?: string; +}) { + const [draft, setDraft] = useState(''); + const [sending, setSending] = useState(false); + const [staged, setStaged] = useState(null); + const [uploading, setUploading] = useState(false); + const fileRef = useRef(null); + + const query = trailingMentionQuery(draft); + const suggestions = useMemo(() => { + 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): Promise { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file) return; + setUploading(true); + try { + setStaged(await upload(file)); + } catch { + /* host surfaces upload errors */ + } finally { + setUploading(false); + } + } + + async function submit(e?: FormEvent): Promise { + 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 onSend(text, { + ...(mentions.length ? { mentions } : {}), + ...(att ? { attachment: att } : {}), + ...(parentInteractionId ? { parentInteractionId } : {}), + }); + } catch { + setStaged(att); + } finally { + setSending(false); + } + } + + function onKeyDown(e: KeyboardEvent): void { + if (showSuggest && e.key === 'Enter') { + e.preventDefault(); + pick(suggestions[0]!.insert); + } + } + + return ( +
+ {showSuggest ? ( +
    + {suggestions.map((s) => ( +
  • + +
  • + ))} +
+ ) : null} + {staged ? ( +
+ 📎 {staged.name} + +
+ ) : null} +
+ {canUpload ? ( + <> + + + + ) : null} + { + setDraft(e.target.value); + onTyping?.(); + }} + onKeyDown={onKeyDown} + /> + +
+
+ ); +} diff --git a/packages/iios-messaging-ui/src/components/message-item.tsx b/packages/iios-messaging-ui/src/components/message-item.tsx new file mode 100644 index 0000000..34c0f9f --- /dev/null +++ b/packages/iios-messaging-ui/src/components/message-item.tsx @@ -0,0 +1,107 @@ +import { useState } from 'react'; +import { highlightMentions } from '../mentions'; +import type { Attachment } from '../types'; +import type { UiMessage } from '../hooks/use-messages'; + +const REACTION_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀']; + +const isImage = (mime: string): boolean => mime.startsWith('image/'); + +function AttachmentView({ att }: { att: Attachment }) { + if (isImage(att.mime)) { + return ( + + {att.name} + + ); + } + return ( + + 📎 {att.name} + + ); +} + +/** One rendered message: bubble (with @mention highlighting), attachment, reactions, seen tick, + * and — in the main thread only — a thread/reply affordance. */ +export function MessageItem({ + message, + memberNames, + canReact, + onReact, + seen, + replyCount, + onOpenThread, +}: { + message: UiMessage; + memberNames: string[]; + canReact: boolean; + onReact: (messageId: string, emoji: string) => void; + seen: boolean; + /** Present only in the main thread (not inside the pane). undefined => no thread affordance. */ + replyCount?: number; + onOpenThread?: (messageId: string) => void; +}) { + const [pickerOpen, setPickerOpen] = useState(false); + const m = message; + + return ( +
+
+
+ {m.text ? highlightMentions(m.text, memberNames) : null} + {m.attachment ? : null} +
+
+ {canReact ? ( +
+ + {pickerOpen ? ( +
+ {REACTION_EMOJIS.map((e) => ( + + ))} +
+ ) : null} +
+ ) : null} + {onOpenThread ? ( + + ) : null} +
+
+ + {m.reactions && m.reactions.length > 0 ? ( +
+ {m.reactions.map((r) => ( + + ))} +
+ ) : null} + + {replyCount !== undefined && replyCount > 0 && onOpenThread ? ( + + ) : null} + + {message.mine && seen ? Seen : null} +
+ ); +} diff --git a/packages/iios-messaging-ui/src/components/messenger.tsx b/packages/iios-messaging-ui/src/components/messenger.tsx index 604df07..9a5b198 100644 --- a/packages/iios-messaging-ui/src/components/messenger.tsx +++ b/packages/iios-messaging-ui/src/components/messenger.tsx @@ -4,6 +4,7 @@ import { useAdapter } from '../provider'; import { ConversationList } from './conversation-list'; import { ChannelBrowser } from './channel-browser'; import { Thread } from './thread'; +import { ThreadPane } from './thread-pane'; /** * The drop-in messenger: sectioned conversation list (Channels / Direct messages) + open thread, @@ -17,6 +18,7 @@ export function Messenger() { const [selected, setSelected] = useState(null); const [browsing, setBrowsing] = useState(false); + const [activeRoot, setActiveRoot] = useState(null); // open thread pane's root message useEffect(() => { if (browsing) return; @@ -24,6 +26,9 @@ export function Messenger() { setSelected(conversations[0]?.threadId ?? null); }, [conversations, selected, browsing]); + // Switching conversations (or into browse) closes any open thread pane. + useEffect(() => setActiveRoot(null), [selected, browsing]); + const channels = useMemo(() => conversations.filter((c) => c.membership === 'channel'), [conversations]); const dms = useMemo(() => conversations.filter((c) => c.membership !== 'channel'), [conversations]); @@ -73,9 +78,13 @@ export function Messenger() { }} /> ) : ( - + )} + + {!browsing && selected && activeRoot ? ( + setActiveRoot(null)} /> + ) : null} ); } diff --git a/packages/iios-messaging-ui/src/components/thread-pane.test.tsx b/packages/iios-messaging-ui/src/components/thread-pane.test.tsx new file mode 100644 index 0000000..6cea6d3 --- /dev/null +++ b/packages/iios-messaging-ui/src/components/thread-pane.test.tsx @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MessagingProvider } from '../provider'; +import { Messenger } from './messenger'; +import { MockAdapter } from '../adapters/mock'; + +function mount() { + return render( + + + , + ); +} + +describe('Slack-style thread pane', () => { + it('opens a thread on 💬, posts a reply into it, and shows the reply count on the parent', async () => { + mount(); + // Wait for the auto-selected thread's message to render WITH its actions (not just the sidebar + // preview), then open the thread pane via the message's reply affordance (💬). + const replyButtons = await screen.findAllByTitle('Reply in thread'); + fireEvent.click(replyButtons[0]!); + expect(await screen.findByText('Thread')).toBeTruthy(); // pane header + + // The pane's composer (placeholder "Reply…") — send a threaded reply. + const replyInput = screen.getByPlaceholderText('Reply…') as HTMLInputElement; + fireEvent.change(replyInput, { target: { value: 'on it' } }); + // The pane has its own Send; grab the last one (pane is rendered after the main composer). + const sends = screen.getAllByText('Send'); + fireEvent.click(sends[sends.length - 1]!); + + // The reply shows in the pane (replies are hidden from the main thread, so this is unique)... + await waitFor(() => expect(screen.getByText('on it')).toBeTruthy()); + // ...and the parent now advertises the reply count as a thread-link button in the main thread. + await waitFor(() => expect(screen.getByRole('button', { name: /1 reply/ })).toBeTruthy()); + }); +}); diff --git a/packages/iios-messaging-ui/src/components/thread-pane.tsx b/packages/iios-messaging-ui/src/components/thread-pane.tsx new file mode 100644 index 0000000..7252dca --- /dev/null +++ b/packages/iios-messaging-ui/src/components/thread-pane.tsx @@ -0,0 +1,60 @@ +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 Slack-style thread side panel: the root message + its replies + a composer that posts back + * into the thread (parentInteractionId = root). Uses its own useMessages on the same thread; the + * shared adapter subscription keeps it and the main view in sync. + */ +export function ThreadPane({ + threadId, + rootId, + onClose, +}: { + threadId: string; + rootId: string; + onClose: () => void; +}) { + const { messages, send, react, upload, seenIds, sendTyping, canReact, canUpload } = useMessages(threadId); + const members = useMembers(threadId); + const memberNames = useMemo(() => members.map((m) => m.name), [members]); + + const root = useMemo(() => messages.find((m) => m.id === rootId), [messages, rootId]); + const replies = useMemo(() => messages.filter((m) => m.parentInteractionId === rootId), [messages, rootId]); + + return ( + + ); +} diff --git a/packages/iios-messaging-ui/src/components/thread.tsx b/packages/iios-messaging-ui/src/components/thread.tsx index fc94b17..0f21efa 100644 --- a/packages/iios-messaging-ui/src/components/thread.tsx +++ b/packages/iios-messaging-ui/src/components/thread.tsx @@ -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 ( - - {att.name} - - ); - } - return ( - - 📎 {att.name} - - ); -} +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(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); - const suggestions = useMemo(() => { - 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): 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 && !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): 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(); + for (const m of messages) if (m.parentInteractionId) counts.set(m.parentInteractionId, (counts.get(m.parentInteractionId) ?? 0) + 1); + return counts; + }, [messages]); if (!threadId) { return
Select a conversation.
; } return ( -
+
{loading && messages.length === 0 ?
Loading…
: null} {error ?
{error}
: null} - {messages.map((m) => ( -
-
-
- {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} - {m.mine && seenIds.has(m.id) ? Seen : null} -
+ {topLevel.map((m) => ( + void react(id, emoji)} + seen={seenIds.has(m.id)} + replyCount={replyCount.get(m.id) ?? 0} + {...(onOpenThread ? { onOpenThread } : {})} + /> ))} {typingUserIds.length > 0 ? (
{typingUserIds.length === 1 ? 'typing…' : 'several people are typing…'}
) : null}
-
- {showSuggest ? ( -
    - {suggestions.map((s) => ( -
  • - -
  • - ))} -
- ) : null} - {staged ? ( -
- 📎 {staged.name} - -
- ) : null} -
- {canUpload ? ( - <> - - - - ) : null} - { - setDraft(e.target.value); - sendTyping(); - }} - onKeyDown={onKeyDown} - /> - -
-
+
); } diff --git a/packages/iios-messaging-ui/src/index.ts b/packages/iios-messaging-ui/src/index.ts index 80aca86..1d160cd 100644 --- a/packages/iios-messaging-ui/src/index.ts +++ b/packages/iios-messaging-ui/src/index.ts @@ -15,6 +15,7 @@ export { Messenger } from './components/messenger'; export { ConversationList } from './components/conversation-list'; export { ChannelBrowser } from './components/channel-browser'; export { Thread } from './components/thread'; +export { ThreadPane } from './components/thread-pane'; export type { MessagingAdapter } from './adapter'; export type { ConversationsState } from './hooks/use-conversations'; diff --git a/packages/iios-messaging-ui/src/styles.css b/packages/iios-messaging-ui/src/styles.css index 2d0d0e5..97c58ac 100644 --- a/packages/iios-messaging-ui/src/styles.css +++ b/packages/iios-messaging-ui/src/styles.css @@ -157,6 +157,26 @@ .miu-msg.is-mine .miu-bubble-row { flex-direction: row-reverse; } +.miu-msg-actions { + display: flex; + gap: 2px; + flex-shrink: 0; +} +.miu-thread-link { + align-self: flex-start; + margin-top: 3px; + padding: 3px 9px; + border-radius: 999px; + border: 1px solid var(--miu-border); + background: var(--miu-panel); + color: var(--miu-accent); + font-size: 12px; + font-weight: 600; + cursor: pointer; +} +.miu-msg.is-mine .miu-thread-link { + align-self: flex-end; +} .miu-react-wrap { position: relative; flex-shrink: 0; @@ -490,6 +510,43 @@ color: var(--miu-muted); } +/* thread pane (Slack-style) */ +.miu-pane { + width: 340px; + flex-shrink: 0; + display: flex; + flex-direction: column; + min-height: 0; + border-left: 1px solid var(--miu-border); + background: var(--miu-panel); +} +.miu-pane-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--miu-border); + font-weight: 700; +} +.miu-pane-close { + border: none; + background: none; + color: var(--miu-muted); + cursor: pointer; + font-size: 15px; +} +.miu-pane-messages { + background: var(--miu-bg); +} +.miu-pane-divider { + font-size: 11.5px; + color: var(--miu-muted); + text-align: center; + margin: 4px 0; + padding-bottom: 4px; + border-bottom: 1px solid var(--miu-border); +} + /* misc */ .miu-empty { padding: 24px;