feat(messaging-ui): import a channel's roster from the settings panel

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 16:12:25 +05:30
parent 0336621c01
commit 3d08fa42f8
7 changed files with 211 additions and 12 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@insignia/iios-messaging-ui", "name": "@insignia/iios-messaging-ui",
"version": "0.1.7", "version": "0.1.8",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.js", "module": "dist/index.js",
@@ -1,5 +1,6 @@
import type { import type {
Attachment, Attachment,
BulkAddResult,
ChannelSummary, ChannelSummary,
Conversation, Conversation,
CreateChannelInput, CreateChannelInput,
@@ -97,6 +98,11 @@ export interface MessagingAdapter {
/** Add a person to a group or private channel. */ /** Add a person to a group or private channel. */
addMember?(threadId: string, userId: string): Promise<void>; addMember?(threadId: string, userId: string): Promise<void>;
/** 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<BulkAddResult>;
/** Remove a person from a group or channel. */ /** Remove a person from a group or channel. */
removeMember?(threadId: string, userId: string): Promise<void>; removeMember?(threadId: string, userId: string): Promise<void>;
@@ -1,6 +1,7 @@
import type { MessagingAdapter } from '../adapter'; import type { MessagingAdapter } from '../adapter';
import type { import type {
Attachment, Attachment,
BulkAddResult,
ChannelSummary, ChannelSummary,
ChannelVisibility, ChannelVisibility,
Conversation, Conversation,
@@ -168,6 +169,22 @@ export class MockAdapter implements MessagingAdapter {
if (t && !t.participants.includes(userId)) t.participants = [...t.participants, userId]; 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<BulkAddResult> {
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<void> { async removeMember(threadId: string, userId: string): Promise<void> {
const t = this.threads.get(threadId); const t = this.threads.get(threadId);
if (t) t.participants = t.participants.filter((p) => p !== userId); if (t) t.participants = t.participants.filter((p) => p !== userId);
@@ -37,4 +37,61 @@ describe('<ConversationSettings />', () => {
expect((await a.listMembers('th_mock_2')).some((m) => m.id === 'pp_sofia')).toBe(true); 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(
<MessagingProvider adapter={a}>
<ConversationSettings threadId="th_mock_2" title="Storm crew" membership="group" onClose={() => {}} />
</MessagingProvider>,
);
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
});
}); });
@@ -1,7 +1,17 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useAdapter } from '../provider'; import { useAdapter } from '../provider';
import { useConversations } from '../hooks/use-conversations';
import { ModalPortal } from './modal-portal'; 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 * 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<string | null>(null); const [error, setError] = useState<string | null>(null);
const [nonce, setNonce] = useState(0); const [nonce, setNonce] = useState(0);
const [pending, setPending] = useState<PendingImport | null>(null);
const [imported, setImported] = useState<BulkAddResult | null>(null);
const { conversations } = useConversations();
const canManage = typeof adapter.addMember === 'function' && typeof adapter.removeMember === 'function'; const canManage = typeof adapter.addMember === 'function' && typeof adapter.removeMember === 'function';
const canRename = typeof adapter.renameConversation === 'function'; const canRename = typeof adapter.renameConversation === 'function';
const canLeave = membership === 'channel' && typeof adapter.leaveChannel === '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(() => { useEffect(() => {
let alive = true; let alive = true;
@@ -51,7 +75,41 @@ export function ConversationSettings({
}, [adapter, threadId, nonce]); }, [adapter, threadId, nonce]);
const memberIds = useMemo(() => new Set(members.map((m) => m.id)), [members]); 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 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<void> {
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<void> {
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<void>): Promise<void> { async function run(fn: () => Promise<void>): Promise<void> {
setBusy(true); setBusy(true);
@@ -109,23 +167,73 @@ export function ConversationSettings({
</div> </div>
</div> </div>
{canManage ? ( {canManage && pending ? (
// Confirm step — an import is an administrative action, so it never fires on a stray click.
<div className="miu-field">
<span className="miu-field-lbl">Add from #{pending.source.title}</span>
{pending.newcomers.length === 0 ? (
<div className="miu-empty">Everyone from #{pending.source.title} is already here.</div>
) : (
<div className="miu-empty">
Add {pending.newcomers.length} {pending.newcomers.length === 1 ? 'person' : 'people'} from #{pending.source.title}?
{pending.alreadyHere > 0 ? ` (${pending.alreadyHere} already here)` : ''}
</div>
)}
<div className="miu-composer-row">
<button type="button" className="miu-tab" disabled={busy} onClick={() => setPending(null)}>Cancel</button>
<button type="button" className="miu-send" disabled={busy || pending.newcomers.length === 0} onClick={() => void confirmImport()}>
Add {pending.newcomers.length > 0 ? pending.newcomers.length : ''}
</button>
</div>
</div>
) : null}
{canManage && !pending ? (
<div className="miu-field"> <div className="miu-field">
<span className="miu-field-lbl">Add people</span> <span className="miu-field-lbl">Add people</span>
<input className="miu-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" aria-label="Search people" /> <input
className="miu-input"
value={q}
onChange={(e) => { setQ(e.target.value); setImported(null); }}
placeholder={canImport ? 'Search people… or # for a channel' : 'Search people…'}
aria-label="Search people"
/>
<div className="miu-settings-list"> <div className="miu-settings-list">
{addable.length === 0 ? <div className="miu-empty">No one to add.</div> : null} {channelMode ? (
{addable.slice(0, 25).map((p) => ( <>
<button key={p.id} type="button" className="miu-settings-member is-add" disabled={busy} onClick={() => void run(() => adapter.addMember!(threadId, p.id))}> {matchingChannels.length === 0 ? <div className="miu-empty">No channels to add from.</div> : null}
<span className="miu-settings-name">{p.name}</span> {matchingChannels.slice(0, 25).map((c) => (
<span className="miu-pill">{p.kind}</span> <button key={c.threadId} type="button" className="miu-settings-member is-add" disabled={busy} onClick={() => void stageImport(c)}>
<span className="miu-settings-plus" aria-hidden="true"></span> <span className="miu-settings-name">#{c.title}</span>
</button> <span className="miu-pill">channel</span>
))} <span className="miu-settings-plus" aria-hidden="true"></span>
</button>
))}
</>
) : (
<>
{addable.length === 0 ? <div className="miu-empty">No one to add.</div> : null}
{addable.slice(0, 25).map((p) => (
<button key={p.id} type="button" className="miu-settings-member is-add" disabled={busy} onClick={() => void run(() => adapter.addMember!(threadId, p.id))}>
<span className="miu-settings-name">{p.name}</span>
<span className="miu-pill">{p.kind}</span>
<span className="miu-settings-plus" aria-hidden="true"></span>
</button>
))}
</>
)}
</div> </div>
</div> </div>
) : null} ) : null}
{imported ? (
<div className="miu-empty">
Added {imported.added.length}
{imported.skipped.length > 0 ? ` · ${imported.skipped.length} already a member` : ''}
{imported.failed.length > 0 ? ` · ${imported.failed.length} failed` : ''}
</div>
) : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null} {error ? <div className="miu-empty miu-error">{error}</div> : null}
</div> </div>
<div className="miu-modal-foot"> <div className="miu-modal-foot">
+1
View File
@@ -30,6 +30,7 @@ export type { MessagesState, UiMessage } from './hooks/use-messages';
export type { ChannelsState } from './hooks/use-channels'; export type { ChannelsState } from './hooks/use-channels';
export type { export type {
Attachment, Attachment,
BulkAddResult,
ChannelSummary, ChannelSummary,
ChannelVisibility, ChannelVisibility,
Conversation, Conversation,
+10
View File
@@ -24,6 +24,16 @@ export interface Conversation {
topic?: string | null; 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. */ /** A discoverable channel (from browseChannels) — includes ones the caller has NOT joined. */
export interface ChannelSummary { export interface ChannelSummary {
threadId: string; threadId: string;