diff --git a/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz b/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz index c7622b0..c316427 100644 Binary files a/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz and b/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz differ diff --git a/packages/iios-messaging-ui/src/adapter.ts b/packages/iios-messaging-ui/src/adapter.ts index 1670e96..3ea31f5 100644 --- a/packages/iios-messaging-ui/src/adapter.ts +++ b/packages/iios-messaging-ui/src/adapter.ts @@ -61,6 +61,10 @@ export interface MessagingAdapter { * Absent => the composer offers no autocomplete (you can still type @text). */ listMembers?(threadId: string): Promise; + /** The people you can start a conversation with (org directory). Drives the "New message" + * people picker. Absent => the UI hides DM/group creation (you can still open channels). */ + directory?(): Promise; + // ── Channels (optional capability) ────────────────────────────── // A channel is just a third membership beyond dm/group: a discoverable, joinable room. // Implement all four to enable the channels UI; absent => the UI hides channels entirely. diff --git a/packages/iios-messaging-ui/src/adapters/mock.ts b/packages/iios-messaging-ui/src/adapters/mock.ts index d3c8ad7..e75b370 100644 --- a/packages/iios-messaging-ui/src/adapters/mock.ts +++ b/packages/iios-messaging-ui/src/adapters/mock.ts @@ -159,7 +159,20 @@ export class MockAdapter implements MessagingAdapter { }); } + async directory(): Promise { + return MOCK_PEOPLE.map((p) => ({ ...p })); + } + async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> { + // A DM to someone you already have reuses the existing 1:1 thread (dedupe, like the live door). + if ((p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group')) === 'dm' && p.participantIds.length === 1) { + const target = p.participantIds[0]; + for (const [id, t] of this.threads) { + if (t.membership === 'dm' && t.participants.length === 2 && t.participants.includes(ME) && t.participants.includes(target)) { + return { threadId: id }; + } + } + } const membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group'); const threadId = `th_mock_${this.seq++}`; this.threads.set(threadId, { diff --git a/packages/iios-messaging-ui/src/components/messenger.tsx b/packages/iios-messaging-ui/src/components/messenger.tsx index 9a5b198..2b7e54e 100644 --- a/packages/iios-messaging-ui/src/components/messenger.tsx +++ b/packages/iios-messaging-ui/src/components/messenger.tsx @@ -3,6 +3,7 @@ import { useConversations } from '../hooks/use-conversations'; import { useAdapter } from '../provider'; import { ConversationList } from './conversation-list'; import { ChannelBrowser } from './channel-browser'; +import { NewConversation } from './new-conversation'; import { Thread } from './thread'; import { ThreadPane } from './thread-pane'; @@ -15,28 +16,41 @@ export function Messenger() { const { conversations, loading, error, refetch } = useConversations(); const adapter = useAdapter(); const channelsSupported = typeof adapter.browseChannels === 'function'; + const directorySupported = typeof adapter.directory === 'function'; const [selected, setSelected] = useState(null); const [browsing, setBrowsing] = useState(false); + const [composing, setComposing] = useState(false); // "New message" people picker open const [activeRoot, setActiveRoot] = useState(null); // open thread pane's root message useEffect(() => { - if (browsing) return; + if (browsing || composing) return; if (selected && conversations.some((c) => c.threadId === selected)) return; setSelected(conversations[0]?.threadId ?? null); - }, [conversations, selected, browsing]); + }, [conversations, selected, browsing, composing]); - // Switching conversations (or into browse) closes any open thread pane. - useEffect(() => setActiveRoot(null), [selected, browsing]); + // Switching conversations (or into browse/compose) closes any open thread pane. + useEffect(() => setActiveRoot(null), [selected, browsing, composing]); const channels = useMemo(() => conversations.filter((c) => c.membership === 'channel'), [conversations]); const dms = useMemo(() => conversations.filter((c) => c.membership !== 'channel'), [conversations]); function pick(threadId: string): void { setBrowsing(false); + setComposing(false); setSelected(threadId); } + function openBrowse(): void { + setComposing(false); + setBrowsing(true); + } + + function openCompose(): void { + setBrowsing(false); + setComposing(true); + } + return (
@@ -77,12 +96,19 @@ export function Messenger() { pick(threadId); }} /> + ) : composing ? ( + { + refetch(); + pick(threadId); + }} + /> ) : ( )} - {!browsing && selected && activeRoot ? ( + {!browsing && !composing && selected && activeRoot ? ( setActiveRoot(null)} /> ) : null}
diff --git a/packages/iios-messaging-ui/src/components/new-conversation.test.tsx b/packages/iios-messaging-ui/src/components/new-conversation.test.tsx new file mode 100644 index 0000000..ab26ccd --- /dev/null +++ b/packages/iios-messaging-ui/src/components/new-conversation.test.tsx @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MessagingProvider } from '../provider'; +import { MockAdapter } from '../adapters/mock'; +import { Messenger } from './messenger'; + +function mount(adapter = new MockAdapter()) { + render( + + + , + ); + return adapter; +} + +describe('MockAdapter directory + openThread', () => { + it('directory lists people you can message', async () => { + const a = new MockAdapter(); + const people = await a.directory(); + expect(people.some((p) => p.name === 'Dan Whitaker')).toBe(true); + }); + + it('one participant opens a dm; two+ open a group with a subject', async () => { + const a = new MockAdapter(); + const dm = await a.openThread({ participantIds: ['pp_dan'], membership: 'dm' }); + const list = await a.listConversations(); + expect(list.find((c) => c.threadId === dm.threadId)?.membership).toBe('dm'); + + const group = await a.openThread({ participantIds: ['pp_dan', 'pp_priya'], membership: 'group', subject: 'Roof crew' }); + const g = (await a.listConversations()).find((c) => c.threadId === group.threadId); + expect(g?.membership).toBe('group'); + expect(g?.title).toBe('Roof crew'); + }); + + it('opening a dm with the same person reuses the existing thread', async () => { + const a = new MockAdapter(); + const first = await a.openThread({ participantIds: ['pp_priya'], membership: 'dm' }); + const second = await a.openThread({ participantIds: ['pp_priya'], membership: 'dm' }); + expect(second.threadId).toBe(first.threadId); + }); +}); + +describe(' new conversation flow', () => { + it('opens the picker from the Direct messages +, and starting a chat leaves the picker', async () => { + mount(); + // Open the "New message" picker. + fireEvent.click(await screen.findByTitle('New message')); + expect(await screen.findByText('New message')).toBeTruthy(); + + // Pick a person and start a DM. + fireEvent.click(await screen.findByText('Dan Whitaker')); + fireEvent.click(screen.getByText('Start chat')); + + // The picker closes (we're back in a thread view — the picker heading is gone). + await waitFor(() => expect(screen.queryByText('Start chat')).toBeNull()); + }); + + it('selecting two people switches the action to group create', async () => { + mount(); + fireEvent.click(await screen.findByTitle('New message')); + fireEvent.click(await screen.findByText('Dan Whitaker')); + fireEvent.click(await screen.findByText('Priya Nair')); + expect(screen.getByText(/Create group \(2\)/)).toBeTruthy(); + expect(screen.getByLabelText('Group name')).toBeTruthy(); + }); +}); diff --git a/packages/iios-messaging-ui/src/components/new-conversation.tsx b/packages/iios-messaging-ui/src/components/new-conversation.tsx new file mode 100644 index 0000000..2436c8a --- /dev/null +++ b/packages/iios-messaging-ui/src/components/new-conversation.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react'; +import { useAdapter } from '../provider'; +import type { Person } from '../types'; + +/** + * Start a direct message or a group — Slack-style. Pick people from the org directory: one selected + * opens a DM (deduped by the adapter), two or more create a group with an optional name. Shown in + * the main pane like the channel browser. + */ +export function NewConversation({ onCreated }: { onCreated?: (threadId: string) => void }) { + const adapter = useAdapter(); + const [people, setPeople] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [q, setQ] = useState(''); + const [selected, setSelected] = useState([]); + const [name, setName] = useState(''); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!adapter.directory) { + setLoading(false); + return; + } + let alive = true; + adapter + .directory() + .then((p) => { + if (alive) { + setPeople(p); + setError(null); + } + }) + .catch((e: unknown) => { + if (alive) setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; + }, [adapter]); + + const filtered = people.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase())); + const isGroup = selected.length > 1; + const canStart = selected.length >= 1 && !busy; + + function toggle(id: string): void { + setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id])); + } + + async function start(): Promise { + if (!canStart) return; + setBusy(true); + setError(null); + try { + const res = await adapter.openThread({ + participantIds: selected, + membership: isGroup ? 'group' : 'dm', + ...(isGroup && name.trim() ? { subject: name.trim() } : {}), + }); + onCreated?.(res.threadId); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + } + + return ( +
+
New message
+
+ setQ(e.target.value)} placeholder="Search people…" aria-label="Search people" /> + {isGroup ? ( + setName(e.target.value)} placeholder="Group name (optional)" aria-label="Group name" /> + ) : null} + {error ?
{error}
: null} +
+ {loading && people.length === 0 ?
Loading…
: null} + {!loading && filtered.length === 0 ?
No people found.
: null} + {filtered.map((p) => ( + + ))} +
+ +
+
+ ); +} diff --git a/packages/iios-messaging-ui/src/styles.css b/packages/iios-messaging-ui/src/styles.css index 0172ed0..43c8a81 100644 --- a/packages/iios-messaging-ui/src/styles.css +++ b/packages/iios-messaging-ui/src/styles.css @@ -500,6 +500,32 @@ gap: 5px; cursor: pointer; } +/* new conversation (DM / group) picker */ +.miu-newconv { + display: flex; + flex-direction: column; + gap: 10px; +} +.miu-person-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid var(--miu-border); + border-radius: 10px; + background: var(--miu-panel); + cursor: pointer; +} +.miu-person-row:hover { + border-color: var(--miu-accent); +} +.miu-person-row.is-active { + border-color: var(--miu-accent); + background: color-mix(in srgb, var(--miu-accent) 10%, var(--miu-panel)); +} +.miu-person-row .miu-browser-name { + flex: 1 1 auto; +} .miu-browser-list { display: flex; flex-direction: column;