From 3d08fa42f8c33d0ffe0a5a31e1e5ae72529cbd95 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 25 Jul 2026 16:12:25 +0530 Subject: [PATCH] feat(messaging-ui): import a channel's roster from the settings panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase C of channel-roster import. Adds the optional addMembers(threadId, userIds) adapter seam (+ BulkAddResult) and, in ConversationSettings, a '#' mode on the existing Add-people box that lists the channels you belong to — public AND private, since the source list is your own membership-scoped conversation list. Picking a channel STAGES the import (reads its roster, diffs against who is already here) and shows a confirm — 'Add 9 people from #design? (3 already here)' — so an administrative action never fires on a stray click. The result line reports added / already-a-member / failed. Absent addMembers => the affordance is hidden entirely and '#' is just a search string. Bumped to 0.1.8. SDK suite: 15 files / 78 tests green. Co-Authored-By: Claude Opus 4.8 --- packages/iios-messaging-ui/package.json | 2 +- packages/iios-messaging-ui/src/adapter.ts | 6 + .../iios-messaging-ui/src/adapters/mock.ts | 17 +++ .../components/conversation-settings.test.tsx | 57 ++++++++ .../src/components/conversation-settings.tsx | 130 ++++++++++++++++-- packages/iios-messaging-ui/src/index.ts | 1 + packages/iios-messaging-ui/src/types.ts | 10 ++ 7 files changed, 211 insertions(+), 12 deletions(-) diff --git a/packages/iios-messaging-ui/package.json b/packages/iios-messaging-ui/package.json index 0b7dcf3..754f846 100644 --- a/packages/iios-messaging-ui/package.json +++ b/packages/iios-messaging-ui/package.json @@ -1,6 +1,6 @@ { "name": "@insignia/iios-messaging-ui", - "version": "0.1.7", + "version": "0.1.8", "type": "module", "main": "dist/index.js", "module": "dist/index.js", diff --git a/packages/iios-messaging-ui/src/adapter.ts b/packages/iios-messaging-ui/src/adapter.ts index 0f7ca27..0502c0b 100644 --- a/packages/iios-messaging-ui/src/adapter.ts +++ b/packages/iios-messaging-ui/src/adapter.ts @@ -1,5 +1,6 @@ import type { Attachment, + BulkAddResult, ChannelSummary, Conversation, CreateChannelInput, @@ -97,6 +98,11 @@ export interface MessagingAdapter { /** Add a person to a group or private channel. */ addMember?(threadId: string, userId: string): Promise; + /** Add many people in one call — powers "add everyone from another channel". The host is expected + * to enforce who may add (and who may read the source roster) server-side. + * Absent => the import-from-a-channel affordance is hidden; single add still works. */ + addMembers?(threadId: string, userIds: string[]): Promise; + /** Remove a person from a group or channel. */ removeMember?(threadId: string, userId: string): Promise; diff --git a/packages/iios-messaging-ui/src/adapters/mock.ts b/packages/iios-messaging-ui/src/adapters/mock.ts index b114d7c..5514657 100644 --- a/packages/iios-messaging-ui/src/adapters/mock.ts +++ b/packages/iios-messaging-ui/src/adapters/mock.ts @@ -1,6 +1,7 @@ import type { MessagingAdapter } from '../adapter'; import type { Attachment, + BulkAddResult, ChannelSummary, ChannelVisibility, Conversation, @@ -168,6 +169,22 @@ export class MockAdapter implements MessagingAdapter { if (t && !t.participants.includes(userId)) t.participants = [...t.participants, userId]; } + /** Bulk add, mirroring the live door: already-members come back as `skipped`, never re-added. */ + async addMembers(threadId: string, userIds: string[]): Promise { + const t = this.threads.get(threadId); + if (!t) return { added: [], skipped: [], failed: [...userIds] }; + const added: string[] = []; + const skipped: string[] = []; + for (const id of [...new Set(userIds)]) { + if (t.participants.includes(id)) skipped.push(id); + else { + t.participants = [...t.participants, id]; + added.push(id); + } + } + return { added, skipped, failed: [] }; + } + async removeMember(threadId: string, userId: string): Promise { const t = this.threads.get(threadId); if (t) t.participants = t.participants.filter((p) => p !== userId); diff --git a/packages/iios-messaging-ui/src/components/conversation-settings.test.tsx b/packages/iios-messaging-ui/src/components/conversation-settings.test.tsx index 3fa1c01..da45e78 100644 --- a/packages/iios-messaging-ui/src/components/conversation-settings.test.tsx +++ b/packages/iios-messaging-ui/src/components/conversation-settings.test.tsx @@ -37,4 +37,61 @@ describe('', () => { expect((await a.listMembers('th_mock_2')).some((m) => m.id === 'pp_sofia')).toBe(true); }); }); + + it('“#” switches the picker to channels you belong to, excluding this one', async () => { + mount(); + await screen.findByText('Dan Whitaker'); + fireEvent.change(screen.getByLabelText('Search people'), { target: { value: '#' } }); + // Channels the mock user is in show up as import sources… + await screen.findByText('#general'); + // …and the conversation being edited is never offered as its own source. + expect(screen.queryByText('#Storm crew')).toBeNull(); + }); + + it('imports a channel roster only after confirmation, and reports what landed', async () => { + const a = mount(); + await screen.findByText('Dan Whitaker'); + const before = (await a.listMembers('th_mock_2')).length; + + fireEvent.change(screen.getByLabelText('Search people'), { target: { value: '#' } }); + fireEvent.click(await screen.findByText('#general')); + + // Staged, NOT applied — picking a channel must not mutate membership on its own. + const confirm = await screen.findByRole('button', { name: /^Add \d+$/ }); + expect((await a.listMembers('th_mock_2')).length).toBe(before); + + fireEvent.click(confirm); + await waitFor(async () => { + expect((await a.listMembers('th_mock_2')).length).toBeGreaterThan(before); + }); + await screen.findByText(/Added \d+/); + }); + + it('cancelling a staged import leaves membership untouched', async () => { + const a = mount(); + await screen.findByText('Dan Whitaker'); + const before = (await a.listMembers('th_mock_2')).length; + + fireEvent.change(screen.getByLabelText('Search people'), { target: { value: '#' } }); + fireEvent.click(await screen.findByText('#general')); + fireEvent.click(await screen.findByRole('button', { name: 'Cancel' })); + + await screen.findByLabelText('Search people'); // back to the picker + expect((await a.listMembers('th_mock_2')).length).toBe(before); + }); + + it('hides the channel-import affordance when the adapter cannot bulk-add', async () => { + const a = new MockAdapter(); + // A host that implements single add but not addMembers => no import path offered. + (a as { addMembers?: unknown }).addMembers = undefined; + render( + + {}} /> + , + ); + await screen.findByText('Dan Whitaker'); + expect(screen.getByLabelText('Search people').getAttribute('placeholder')).toBe('Search people…'); + fireEvent.change(screen.getByLabelText('Search people'), { target: { value: '#' } }); + expect(screen.queryByText('#general')).toBeNull(); // '#' is just a search string here + }); }); diff --git a/packages/iios-messaging-ui/src/components/conversation-settings.tsx b/packages/iios-messaging-ui/src/components/conversation-settings.tsx index 2f627b7..66a2e94 100644 --- a/packages/iios-messaging-ui/src/components/conversation-settings.tsx +++ b/packages/iios-messaging-ui/src/components/conversation-settings.tsx @@ -1,7 +1,17 @@ import { useEffect, useMemo, useState } from 'react'; import { useAdapter } from '../provider'; +import { useConversations } from '../hooks/use-conversations'; import { ModalPortal } from './modal-portal'; -import type { Person } from '../types'; +import type { BulkAddResult, Conversation, Person } from '../types'; + +/** A source channel the caller belongs to, staged for a roster import once its members are read. */ +interface PendingImport { + source: Conversation; + /** Members of the source who are NOT already here — the ones an import would actually add. */ + newcomers: Person[]; + /** Members of the source already in this conversation; reported so the count is never surprising. */ + alreadyHere: number; +} /** * Settings for a group or channel (public + private): rename, member list, add/remove people, and @@ -30,9 +40,23 @@ export function ConversationSettings({ const [error, setError] = useState(null); const [nonce, setNonce] = useState(0); + const [pending, setPending] = useState(null); + const [imported, setImported] = useState(null); + const { conversations } = useConversations(); + const canManage = typeof adapter.addMember === 'function' && typeof adapter.removeMember === 'function'; const canRename = typeof adapter.renameConversation === 'function'; const canLeave = membership === 'channel' && typeof adapter.leaveChannel === 'function'; + // Importing a roster needs both halves: read the source's members, and bulk-add them here. + const canImport = typeof adapter.addMembers === 'function' && typeof adapter.listMembers === 'function'; + + // The channels you belong to are the only valid import sources — this list is already + // membership-scoped (it is your own conversation list), so private channels appear iff you're in + // them, and the server re-checks the source-membership rule on the roster read regardless. + const sourceChannels = useMemo( + () => conversations.filter((c) => c.membership === 'channel' && c.threadId !== threadId), + [conversations, threadId], + ); useEffect(() => { let alive = true; @@ -51,7 +75,41 @@ export function ConversationSettings({ }, [adapter, threadId, nonce]); const memberIds = useMemo(() => new Set(members.map((m) => m.id)), [members]); + // A leading '#' switches the picker from people to channels — the roster-import affordance. + const channelMode = canImport && q.trim().startsWith('#'); + const channelQuery = q.trim().slice(1).toLowerCase(); const addable = directory.filter((p) => !memberIds.has(p.id) && p.name.toLowerCase().includes(q.trim().toLowerCase())); + const matchingChannels = sourceChannels.filter((c) => c.title.toLowerCase().includes(channelQuery)); + + /** Stage an import: read the source roster, then diff it against who is already here. */ + async function stageImport(source: Conversation): Promise { + setBusy(true); + setError(null); + try { + const roster = await adapter.listMembers!(source.threadId); + setPending({ + source, + newcomers: roster.filter((p) => !memberIds.has(p.id)), + alreadyHere: roster.filter((p) => memberIds.has(p.id)).length, + }); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + } + + /** Commit the staged import. One bulk call; the result reports what actually landed. */ + async function confirmImport(): Promise { + if (!pending) return; + const ids = pending.newcomers.map((p) => p.id); + await run(async () => { + const res = await adapter.addMembers!(threadId, ids); + setImported(res); + setPending(null); + setQ(''); + }); + } async function run(fn: () => Promise): Promise { setBusy(true); @@ -109,23 +167,73 @@ export function ConversationSettings({ - {canManage ? ( + {canManage && pending ? ( + // Confirm step — an import is an administrative action, so it never fires on a stray click. +
+ Add from #{pending.source.title} + {pending.newcomers.length === 0 ? ( +
Everyone from #{pending.source.title} is already here.
+ ) : ( +
+ Add {pending.newcomers.length} {pending.newcomers.length === 1 ? 'person' : 'people'} from #{pending.source.title}? + {pending.alreadyHere > 0 ? ` (${pending.alreadyHere} already here)` : ''} +
+ )} +
+ + +
+
+ ) : null} + + {canManage && !pending ? (
Add people - setQ(e.target.value)} placeholder="Search people…" aria-label="Search people" /> + { setQ(e.target.value); setImported(null); }} + placeholder={canImport ? 'Search people… or # for a channel' : 'Search people…'} + aria-label="Search people" + />
- {addable.length === 0 ?
No one to add.
: null} - {addable.slice(0, 25).map((p) => ( - - ))} + {channelMode ? ( + <> + {matchingChannels.length === 0 ?
No channels to add from.
: null} + {matchingChannels.slice(0, 25).map((c) => ( + + ))} + + ) : ( + <> + {addable.length === 0 ?
No one to add.
: null} + {addable.slice(0, 25).map((p) => ( + + ))} + + )}
) : null} + {imported ? ( +
+ Added {imported.added.length} + {imported.skipped.length > 0 ? ` · ${imported.skipped.length} already a member` : ''} + {imported.failed.length > 0 ? ` · ${imported.failed.length} failed` : ''} +
+ ) : null} + {error ?
{error}
: null}
diff --git a/packages/iios-messaging-ui/src/index.ts b/packages/iios-messaging-ui/src/index.ts index be6f1b6..28b0220 100644 --- a/packages/iios-messaging-ui/src/index.ts +++ b/packages/iios-messaging-ui/src/index.ts @@ -30,6 +30,7 @@ export type { MessagesState, UiMessage } from './hooks/use-messages'; export type { ChannelsState } from './hooks/use-channels'; export type { Attachment, + BulkAddResult, ChannelSummary, ChannelVisibility, Conversation, diff --git a/packages/iios-messaging-ui/src/types.ts b/packages/iios-messaging-ui/src/types.ts index 27724a2..08f8b2e 100644 --- a/packages/iios-messaging-ui/src/types.ts +++ b/packages/iios-messaging-ui/src/types.ts @@ -24,6 +24,16 @@ export interface Conversation { topic?: string | null; } +/** + * Outcome of a bulk member import, reported per user so a partial result is never silent. + * `skipped` were already members (which makes a repeat import a no-op); `failed` could not be added. + */ +export interface BulkAddResult { + added: string[]; + skipped: string[]; + failed: string[]; +} + /** A discoverable channel (from browseChannels) — includes ones the caller has NOT joined. */ export interface ChannelSummary { threadId: string;