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:
@@ -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<Attachment>;
|
||||||
|
onSend: (text: string, opts?: SendOpts) => Promise<void>;
|
||||||
|
onTyping?: () => void;
|
||||||
|
parentInteractionId?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [staged, setStaged] = useState<Attachment | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
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 {
|
||||||
|
/* host surfaces upload errors */
|
||||||
|
} 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 onSend(text, {
|
||||||
|
...(mentions.length ? { mentions } : {}),
|
||||||
|
...(att ? { attachment: att } : {}),
|
||||||
|
...(parentInteractionId ? { parentInteractionId } : {}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setStaged(att);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent<HTMLInputElement>): void {
|
||||||
|
if (showSuggest && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
pick(suggestions[0]!.insert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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={placeholder}
|
||||||
|
aria-label="Message"
|
||||||
|
onChange={(e) => {
|
||||||
|
setDraft(e.target.value);
|
||||||
|
onTyping?.();
|
||||||
|
}}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="miu-send" disabled={(!draft.trim() && !staged) || sending}>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<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 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 (
|
||||||
|
<div className={`miu-msg${message.mine ? ' is-mine' : ''}${message.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>
|
||||||
|
<div className="miu-msg-actions">
|
||||||
|
{canReact ? (
|
||||||
|
<div className="miu-react-wrap">
|
||||||
|
<button type="button" className="miu-react-btn" title="React" onClick={() => setPickerOpen((p) => !p)}>
|
||||||
|
🙂
|
||||||
|
</button>
|
||||||
|
{pickerOpen ? (
|
||||||
|
<div className="miu-react-picker">
|
||||||
|
{REACTION_EMOJIS.map((e) => (
|
||||||
|
<button
|
||||||
|
key={e}
|
||||||
|
type="button"
|
||||||
|
className="miu-react-emoji"
|
||||||
|
onClick={() => {
|
||||||
|
onReact(m.id, e);
|
||||||
|
setPickerOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{e}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{onOpenThread ? (
|
||||||
|
<button type="button" className="miu-react-btn" title="Reply in thread" onClick={() => onOpenThread(m.id)}>
|
||||||
|
💬
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</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 && onReact(m.id, r.emoji)}>
|
||||||
|
{r.emoji} {r.count}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{replyCount !== undefined && replyCount > 0 && onOpenThread ? (
|
||||||
|
<button type="button" className="miu-thread-link" onClick={() => onOpenThread(m.id)}>
|
||||||
|
💬 {replyCount} {replyCount === 1 ? 'reply' : 'replies'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{message.mine && seen ? <span className="miu-seen">Seen</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useAdapter } from '../provider';
|
|||||||
import { ConversationList } from './conversation-list';
|
import { ConversationList } from './conversation-list';
|
||||||
import { ChannelBrowser } from './channel-browser';
|
import { ChannelBrowser } from './channel-browser';
|
||||||
import { Thread } from './thread';
|
import { Thread } from './thread';
|
||||||
|
import { ThreadPane } from './thread-pane';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The drop-in messenger: sectioned conversation list (Channels / Direct messages) + open thread,
|
* The drop-in messenger: sectioned conversation list (Channels / Direct messages) + open thread,
|
||||||
@@ -17,6 +18,7 @@ export function Messenger() {
|
|||||||
|
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
const [browsing, setBrowsing] = useState(false);
|
const [browsing, setBrowsing] = useState(false);
|
||||||
|
const [activeRoot, setActiveRoot] = useState<string | null>(null); // open thread pane's root message
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (browsing) return;
|
if (browsing) return;
|
||||||
@@ -24,6 +26,9 @@ export function Messenger() {
|
|||||||
setSelected(conversations[0]?.threadId ?? null);
|
setSelected(conversations[0]?.threadId ?? null);
|
||||||
}, [conversations, selected, browsing]);
|
}, [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 channels = useMemo(() => conversations.filter((c) => c.membership === 'channel'), [conversations]);
|
||||||
const dms = 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() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Thread threadId={selected} />
|
<Thread threadId={selected} activeRootId={activeRoot} onOpenThread={setActiveRoot} />
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{!browsing && selected && activeRoot ? (
|
||||||
|
<ThreadPane threadId={selected} rootId={activeRoot} onClose={() => setActiveRoot(null)} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<MessagingProvider adapter={new MockAdapter()}>
|
||||||
|
<Messenger />
|
||||||
|
</MessagingProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<aside className="miu-pane">
|
||||||
|
<header className="miu-pane-head">
|
||||||
|
<span>Thread</span>
|
||||||
|
<button type="button" className="miu-pane-close" onClick={onClose} aria-label="Close thread">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="miu-messages miu-pane-messages">
|
||||||
|
{root ? (
|
||||||
|
<MessageItem message={root} memberNames={memberNames} canReact={canReact} onReact={(id, e) => void react(id, e)} seen={seenIds.has(root.id)} />
|
||||||
|
) : (
|
||||||
|
<div className="miu-empty">Message not found.</div>
|
||||||
|
)}
|
||||||
|
<div className="miu-pane-divider">{replies.length} {replies.length === 1 ? 'reply' : 'replies'}</div>
|
||||||
|
{replies.map((m) => (
|
||||||
|
<MessageItem key={m.id} message={m} memberNames={memberNames} canReact={canReact} onReact={(id, e) => void react(id, e)} seen={seenIds.has(m.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Composer
|
||||||
|
members={members}
|
||||||
|
canUpload={canUpload}
|
||||||
|
upload={upload}
|
||||||
|
onSend={send}
|
||||||
|
onTyping={sendTyping}
|
||||||
|
parentInteractionId={rootId}
|
||||||
|
placeholder="Reply…"
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { useMessages } from '../hooks/use-messages';
|
||||||
import { useMembers } from '../hooks/use-members';
|
import { useMembers } from '../hooks/use-members';
|
||||||
import {
|
import { Composer } from './composer';
|
||||||
SPECIAL_MENTIONS,
|
import { MessageItem } from './message-item';
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One conversation: message list + composer, driven by useMessages. Includes @mention autocomplete,
|
* The main conversation view: top-level messages + composer. Replies (messages with a
|
||||||
* emoji reactions, and attachments — each degrading if the adapter doesn't support it. Transport-agnostic.
|
* 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 { messages, loading, error, send, react, upload, typingUserIds, seenIds, sendTyping, canReact, canUpload } = useMessages(threadId);
|
||||||
const members = useMembers(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 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 {
|
const topLevel = useMemo(() => messages.filter((m) => !m.parentInteractionId), [messages]);
|
||||||
setDraft((d) => insertMention(d, insert));
|
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);
|
||||||
async function onPickFile(e: ChangeEvent<HTMLInputElement>): Promise<void> {
|
return counts;
|
||||||
const file = e.target.files?.[0];
|
}, [messages]);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!threadId) {
|
if (!threadId) {
|
||||||
return <div className="miu-empty miu-thread-empty">Select a conversation.</div>;
|
return <div className="miu-empty miu-thread-empty">Select a conversation.</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="miu-thread">
|
<div className={`miu-thread${activeRootId ? ' has-pane' : ''}`}>
|
||||||
<div className="miu-messages">
|
<div className="miu-messages">
|
||||||
{loading && messages.length === 0 ? <div className="miu-empty">Loading…</div> : null}
|
{loading && messages.length === 0 ? <div className="miu-empty">Loading…</div> : null}
|
||||||
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
||||||
{messages.map((m) => (
|
{topLevel.map((m) => (
|
||||||
<div key={m.id} className={`miu-msg${m.mine ? ' is-mine' : ''}${m.pending ? ' is-pending' : ''}`}>
|
<MessageItem
|
||||||
<div className="miu-bubble-row">
|
key={m.id}
|
||||||
<div className="miu-bubble">
|
message={m}
|
||||||
{m.text ? highlightMentions(m.text, memberNames) : null}
|
memberNames={memberNames}
|
||||||
{m.attachment ? <AttachmentView att={m.attachment} /> : null}
|
canReact={canReact}
|
||||||
</div>
|
onReact={(id, emoji) => void react(id, emoji)}
|
||||||
{canReact ? (
|
seen={seenIds.has(m.id)}
|
||||||
<div className="miu-react-wrap">
|
replyCount={replyCount.get(m.id) ?? 0}
|
||||||
<button type="button" className="miu-react-btn" title="React" onClick={() => setPickerFor((p) => (p === m.id ? null : m.id))}>
|
{...(onOpenThread ? { onOpenThread } : {})}
|
||||||
🙂
|
/>
|
||||||
</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>
|
|
||||||
))}
|
))}
|
||||||
{typingUserIds.length > 0 ? (
|
{typingUserIds.length > 0 ? (
|
||||||
<div className="miu-typing">{typingUserIds.length === 1 ? 'typing…' : 'several people are typing…'}</div>
|
<div className="miu-typing">{typingUserIds.length === 1 ? 'typing…' : 'several people are typing…'}</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="miu-composer" onSubmit={submit}>
|
<Composer members={members} canUpload={canUpload} upload={upload} onSend={send} onTyping={sendTyping} />
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export { Messenger } from './components/messenger';
|
|||||||
export { ConversationList } from './components/conversation-list';
|
export { ConversationList } from './components/conversation-list';
|
||||||
export { ChannelBrowser } from './components/channel-browser';
|
export { ChannelBrowser } from './components/channel-browser';
|
||||||
export { Thread } from './components/thread';
|
export { Thread } from './components/thread';
|
||||||
|
export { ThreadPane } from './components/thread-pane';
|
||||||
|
|
||||||
export type { MessagingAdapter } from './adapter';
|
export type { MessagingAdapter } from './adapter';
|
||||||
export type { ConversationsState } from './hooks/use-conversations';
|
export type { ConversationsState } from './hooks/use-conversations';
|
||||||
|
|||||||
@@ -157,6 +157,26 @@
|
|||||||
.miu-msg.is-mine .miu-bubble-row {
|
.miu-msg.is-mine .miu-bubble-row {
|
||||||
flex-direction: row-reverse;
|
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 {
|
.miu-react-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -490,6 +510,43 @@
|
|||||||
color: var(--miu-muted);
|
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 */
|
/* misc */
|
||||||
.miu-empty {
|
.miu-empty {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
|||||||
Reference in New Issue
Block a user