From 0336621c01f5d954b553f6624df366ae81fc05b7 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 25 Jul 2026 16:05:14 +0530 Subject: [PATCH 1/4] feat(threads): govern roster reads + add a bulk participant primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A of channel-roster import (add everyone from another channel). SECURITY: listParticipants was gated by iios.thread.read, which has no case in DevOpaPort and so fell through to default-allow — any caller in a scope could enumerate ANY thread's members, including private channels they aren't in. Since PlatformModule binds LocalDevPorts unconditionally (no real OPA adapter exists), that was live. Adds iios.thread.participant.list: members only, public channels exempt, ungoverned threads unchanged. This is also Zoom's rule for this feature ('you must be a member of the channel to invite all of its members'). Adds MessageService.addParticipants(): many users in ONE governed call, capped at MAX_BULK_PARTICIPANTS (200), idempotent (already-members are 'skipped'), and deliberately non-atomic so one unresolvable user can't sink an import — outcome is reported per user as {added, skipped, failed}. The dm two-person cap now reads targetCount so it holds for a batch, not just one add at a time. New REST route POST /v1/threads/:id/participants/bulk. The kernel stays generic: it takes an explicit userId list and never learns where that list came from — chat meaning lives only in the policy plane. Co-Authored-By: Claude Opus 4.8 --- .../src/messaging/message.service.ts | 90 ++++++++++++++++++- .../src/messaging/message.spec.ts | 55 ++++++++++++ .../src/platform/dev-opa.port.spec.ts | 23 +++++ .../iios-service/src/platform/dev-opa.port.ts | 15 +++- .../src/threads/threads.controller.ts | 11 +++ 5 files changed, 191 insertions(+), 3 deletions(-) diff --git a/packages/iios-service/src/messaging/message.service.ts b/packages/iios-service/src/messaging/message.service.ts index 450aba8..b500002 100644 --- a/packages/iios-service/src/messaging/message.service.ts +++ b/packages/iios-service/src/messaging/message.service.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { CloudEvent, IIOS_EVENTS, IiosPlatformPorts } from '@insignia/iios-contracts'; import { PrismaService } from '../prisma/prisma.service'; @@ -74,6 +74,10 @@ export interface OpenThreadResult { */ @Injectable() export class MessageService { + /** Ceiling on one bulk participant import — bounds the work per request and the blast radius of a + * mistaken import. Raise here if a larger roster copy is ever needed. */ + static readonly MAX_BULK_PARTICIPANTS = 200; + constructor( private readonly prisma: PrismaService, @Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts, @@ -167,6 +171,76 @@ export class MessageService { return { threadId, participantCount: participantCount + 1 }; } + /** + * Bulk sibling of {@link addParticipant}: add many users in ONE governed operation. The app uses + * this to import a roster (e.g. "add everyone from that channel"); the kernel stays generic — it + * receives an explicit list of userIds and never learns where the list came from. + * + * Deliberately NOT atomic: a single unresolvable user must not sink the whole import, so each is + * attempted independently and the outcome is reported per user. Already-members are `skipped`, + * which makes a re-run a no-op. + */ + async addParticipants( + threadId: string, + principal: MessagePrincipal, + targetUserIds: string[], + role = 'MEMBER', + ): Promise<{ threadId: string; added: string[]; skipped: string[]; failed: string[]; participantCount: number }> { + const unique = [...new Set(targetUserIds.map((u) => u.trim()).filter(Boolean))]; + if (unique.length === 0) throw new BadRequestException('at least one userId is required'); + if (unique.length > MessageService.MAX_BULK_PARTICIPANTS) { + throw new BadRequestException(`at most ${MessageService.MAX_BULK_PARTICIPANTS} participants can be added at once`); + } + + const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } }); + if (!thread) throw new NotFoundException('thread not found'); + const caller = await this.actors.resolveActor(thread.scopeId, principal); + const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } }); + const participantCount = await this.prisma.iiosThreadParticipant.count({ where: { threadId } }); + const membership = (thread.metadata as { membership?: string } | null)?.membership; + + // ONE decision for the whole batch — `targetCount` lets policy reason about the resulting size + // (e.g. the dm two-person cap) instead of being asked the same question N times. + await decideOrThrow(this.ports, { + action: 'iios.thread.participant.add', + threadId, + scopeId: thread.scopeId, + membership, + participantCount, + callerRole: callerP?.participantRole, + targetCount: unique.length, + role, + }); + + const scope = await this.actors.resolveScope(principal); + const added: string[] = []; + const skipped: string[] = []; + const failed: string[] = []; + for (const targetUserId of unique) { + try { + const target = await this.actors.resolveActor(scope.id, { + userId: targetUserId, + appId: principal.appId, + orgId: principal.orgId, + tenantId: principal.tenantId, + displayName: targetUserId, + }); + const existing = await this.prisma.iiosThreadParticipant.findUnique({ + where: { threadId_actorId: { threadId, actorId: target.id } }, + }); + if (existing) { + skipped.push(targetUserId); + continue; + } + await this.actors.ensureParticipant(threadId, target.id, role); + added.push(targetUserId); + } catch { + failed.push(targetUserId); + } + } + return { threadId, added, skipped, failed, participantCount: participantCount + added.length }; + } + /** * Governed thread rename (a generic subject update). Policy decides who may rename — for a * membership thread the dev OPA requires the caller be a group ADMIN. The kernel only writes @@ -224,7 +298,19 @@ export class MessageService { async listParticipants(threadId: string, principal: MessagePrincipal): Promise> { const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } }); if (!thread) throw new NotFoundException('thread not found'); - await decideOrThrow(this.ports, { action: 'iios.thread.read', threadId, scopeId: thread.scopeId }); + // Roster reads are membership-governed (a private channel's members must not be enumerable by + // a non-member), so the decision carries the caller's role + the thread's opaque attributes. + const caller = await this.actors.resolveActor(thread.scopeId, principal); + const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } }); + const meta = thread.metadata as { membership?: string; visibility?: string } | null; + await decideOrThrow(this.ports, { + action: 'iios.thread.participant.list', + threadId, + scopeId: thread.scopeId, + membership: meta?.membership, + visibility: meta?.visibility, + callerRole: callerP?.participantRole, + }); const parts = await this.prisma.iiosThreadParticipant.findMany({ where: { threadId }, include: { actor: { include: { sourceHandle: true } } }, diff --git a/packages/iios-service/src/messaging/message.spec.ts b/packages/iios-service/src/messaging/message.spec.ts index 9514ba8..94e7ec5 100644 --- a/packages/iios-service/src/messaging/message.spec.ts +++ b/packages/iios-service/src/messaging/message.spec.ts @@ -146,6 +146,61 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => { expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(1); }); + it('roster reads are membership-governed: a non-member cannot list a private channel’s members', async () => { + const s = gov(); + const { threadId: priv } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'private' }, subject: 'deals', creatorRole: 'ADMIN' }); + // bob is not a member — he must not be able to enumerate who is. + await expect(s.listParticipants(priv, bob)).rejects.toBeInstanceOf(PolicyDeniedError); + await s.addParticipant(priv, alice, 'bob'); + expect((await s.listParticipants(priv, bob)).map((m) => m.userId).sort()).toEqual(['alice', 'bob']); + + // a PUBLIC channel's roster stays open (it is discoverable anyway) + const { threadId: pub } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'public' }, subject: 'general', creatorRole: 'ADMIN' }); + expect((await s.listParticipants(pub, bob)).map((m) => m.userId)).toEqual(['alice']); + }); + + it('addParticipants: bulk-adds in one governed call, skipping existing members (re-run is a no-op)', async () => { + const s = gov(); + const { threadId } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'private' }, subject: 'ops', creatorRole: 'ADMIN' }); + await s.addParticipant(threadId, alice, 'bob'); // bob is already in + + const res = await s.addParticipants(threadId, alice, ['bob', 'carol', 'dave']); + expect(res.added.sort()).toEqual(['carol', 'dave']); + expect(res.skipped).toEqual(['bob']); + expect(res.failed).toEqual([]); + expect(res.participantCount).toBe(4); // alice, bob, carol, dave + expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(4); + + // idempotent: a second identical import adds nobody + const again = await s.addParticipants(threadId, alice, ['bob', 'carol', 'dave']); + expect(again.added).toEqual([]); + expect(again.skipped.sort()).toEqual(['bob', 'carol', 'dave']); + expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(4); + }); + + it('addParticipants is governed by the same policy as a single add (a plain member is denied)', async () => { + const s = gov(); + const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' }); + await s.addParticipant(threadId, alice, 'bob'); // bob joins as a plain MEMBER + await expect(s.addParticipants(threadId, bob, ['carol', 'dave'])).rejects.toBeInstanceOf(PolicyDeniedError); + }); + + it('addParticipants rejects an empty list and anything over the batch cap', async () => { + const s = gov(); + const { threadId } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'private' }, creatorRole: 'ADMIN' }); + await expect(s.addParticipants(threadId, alice, [])).rejects.toThrow(/at least one/i); + const tooMany = Array.from({ length: MessageService.MAX_BULK_PARTICIPANTS + 1 }, (_, n) => `user_${n}`); + await expect(s.addParticipants(threadId, alice, tooMany)).rejects.toThrow(/at most/i); + }); + + it('addParticipants respects the dm two-person cap for the whole batch', async () => { + const s = gov(); + const { threadId } = await s.openThread(null, alice, { membership: 'dm' }); + // alice is alone; adding two at once would make three — policy must deny the batch. + await expect(s.addParticipants(threadId, alice, ['bob', 'carol'])).rejects.toBeInstanceOf(PolicyDeniedError); + expect((await s.addParticipants(threadId, alice, ['bob'])).added).toEqual(['bob']); + }); + it('self-join is governed: a non-member cannot open a thread by id; after being added, they can', async () => { const s = gov(); const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' }); diff --git a/packages/iios-service/src/platform/dev-opa.port.spec.ts b/packages/iios-service/src/platform/dev-opa.port.spec.ts index 614d5bb..5387ea9 100644 --- a/packages/iios-service/src/platform/dev-opa.port.spec.ts +++ b/packages/iios-service/src/platform/dev-opa.port.spec.ts @@ -49,6 +49,29 @@ describe('DevOpaPort (dev policy plane — membership rules)', () => { expect((await opa.decide({ action: 'iios.thread.participant.remove', callerRole: 'MEMBER' })).allow).toBe(true); }); + it('the dm cap counts the whole batch, not one add at a time', async () => { + const add = (participantCount: number, targetCount: number) => + opa.decide({ action: 'iios.thread.participant.add', membership: 'dm', callerRole: 'MEMBER', participantCount, targetCount }); + expect((await add(1, 1)).allow).toBe(true); // 1 + 1 = 2, fine + const batch = await add(1, 2); // 1 + 2 = 3 — must be denied even though count is only 1 + expect(batch.allow).toBe(false); + expect(batch.obligations[0]?.reason).toMatch(/two people/); + }); + + it('listing a roster requires membership, except on a public channel', async () => { + const list = (extra: Record) => opa.decide({ action: 'iios.thread.participant.list', ...extra }); + // a private channel / group / dm: members only + const stranger = await list({ membership: 'channel', visibility: 'private', callerRole: undefined }); + expect(stranger.allow).toBe(false); + expect(stranger.obligations[0]?.reason).toMatch(/not a member/); + expect((await list({ membership: 'channel', visibility: 'private', callerRole: 'MEMBER' })).allow).toBe(true); + expect((await list({ membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true); + expect((await list({ membership: 'group', callerRole: undefined })).allow).toBe(false); + // a public channel's roster is open, and ungoverned threads are unchanged + expect((await list({ membership: 'channel', visibility: 'public', callerRole: undefined })).allow).toBe(true); + expect((await list({})).allow).toBe(true); + }); + it('media upload allows images + docs (incl. html/markdown/csv), denies unknown types and oversize', async () => { const up = (mime: string, sizeBytes = 1024) => opa.decide({ action: 'iios.media.upload', mime, sizeBytes }); for (const mime of ['image/png', 'video/mp4', 'audio/mpeg', 'application/pdf', 'text/plain', 'text/markdown', 'text/html', 'text/csv']) { diff --git a/packages/iios-service/src/platform/dev-opa.port.ts b/packages/iios-service/src/platform/dev-opa.port.ts index c96c33a..32804db 100644 --- a/packages/iios-service/src/platform/dev-opa.port.ts +++ b/packages/iios-service/src/platform/dev-opa.port.ts @@ -12,6 +12,7 @@ export interface OpaInput { membership?: string; // app-set generic thread attribute: 'dm' | 'group' | 'channel' visibility?: string; // 'public' | 'private' (channels) participantCount?: number; + targetCount?: number; // how many participants a single add call is inviting (1 for a single add) callerRole?: string; // 'MEMBER' | 'ADMIN' alreadyMember?: boolean; isMember?: boolean; @@ -38,11 +39,23 @@ export class DevOpaPort { switch (i.action) { case 'iios.thread.participant.add': { const count = i.participantCount ?? 0; - if (i.membership === 'dm' && count >= 2) return deny('a direct message is limited to two people'); + // `targetCount` is how many are being added in this call (1 for a single add), so the dm cap + // holds for a bulk import too instead of being evaluated one-at-a-time. + if (i.membership === 'dm' && count + (i.targetCount ?? 1) > 2) return deny('a direct message is limited to two people'); if (i.callerRole !== 'MEMBER' && i.callerRole !== 'ADMIN') return deny('only a member can add participants'); if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members'); return allow(); } + case 'iios.thread.participant.list': { + // Reading a thread's ROSTER. A public channel is open (it is discoverable anyway), but every + // other membership thread — dm, group, private channel — requires you to be a member, or any + // caller in the scope could enumerate a private channel's members by id. Ungoverned threads + // (no membership attribute, e.g. support) keep the previous open behaviour. + if (!i.membership) return allow(); + if (i.membership === 'channel' && i.visibility === 'public') return allow(); + if (i.callerRole === 'MEMBER' || i.callerRole === 'ADMIN') return allow(); + return deny('you are not a member of this thread'); + } case 'iios.thread.participant.remove': { // Same governance as add: for a group, only an ADMIN removes members. Ungoverned // (no membership attr) threads allow removal by any member. diff --git a/packages/iios-service/src/threads/threads.controller.ts b/packages/iios-service/src/threads/threads.controller.ts index 5a8d023..c80bc56 100644 --- a/packages/iios-service/src/threads/threads.controller.ts +++ b/packages/iios-service/src/threads/threads.controller.ts @@ -70,6 +70,17 @@ export class ThreadsController { return this.messages.addParticipant(id, this.principal(auth), body.userId, body.role); } + /** + * Governed bulk membership: add many users in one call (e.g. importing another channel's roster). + * Reports per-user outcome — `skipped` are already members — so a partial result is never silent. + */ + @Post(':id/participants/bulk') + @HttpCode(200) + async addParticipants(@Param('id') id: string, @Body() body: { userIds?: string[]; role?: string }, @Headers('authorization') auth?: string) { + if (!Array.isArray(body?.userIds)) throw new BadRequestException('userIds must be an array'); + return this.messages.addParticipants(id, this.principal(auth), body.userIds, body.role); + } + /** Members of a thread with their role — drives the group settings member list. */ @Get(':id/participants') async listParticipants(@Param('id') id: string, @Headers('authorization') auth?: string) { From 3d08fa42f8c33d0ffe0a5a31e1e5ae72529cbd95 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 25 Jul 2026 16:12:25 +0530 Subject: [PATCH 2/4] 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; From c946f1e061a706d33f62498ca0f27d043ae667ad Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 25 Jul 2026 16:29:01 +0530 Subject: [PATCH 3/4] feat(messaging-ui): message formatting + native spellcheck in the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composer becomes a