Feat/messaging ui foundation #12
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user