diff --git a/packages/iios-kernel-client/src/rest.ts b/packages/iios-kernel-client/src/rest.ts index 5b04608..dd1234d 100644 --- a/packages/iios-kernel-client/src/rest.ts +++ b/packages/iios-kernel-client/src/rest.ts @@ -1,5 +1,5 @@ import type { IngestInteractionRequest } from '@insignia/iios-contracts'; -import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem, ThreadSummary, SavedItem, LoginResult } from './types'; +import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem, ThreadSummary, DiscoveredThread, SavedItem, LoginResult } from './types'; export interface RestConfig { serviceUrl: string; @@ -103,6 +103,27 @@ export class RestClient { return this.post<{ threadId: string }>('/v1/threads', opts); } + /** + * Discover threads across your scope matching an opaque metadata filter (e.g. browse public + * channels: `{ membership: 'channel', visibility: 'public' }`). Returns ones you have NOT joined + * too, each flagged `joined`. + */ + async discoverThreads(filter?: { metadata?: Record }): Promise { + const qs = filter?.metadata + ? '?' + Object.entries(filter.metadata).map(([k, v]) => `metadata[${encodeURIComponent(k)}]=${encodeURIComponent(v)}`).join('&') + : ''; + const r = await fetch(this.url(`/v1/threads/discover${qs}`), { headers: this.headers() }); + if (!r.ok) throw new Error(`discoverThreads ${r.status}`); + return (await r.json()) as DiscoveredThread[]; + } + + /** Self-leave a thread (e.g. leave a channel). */ + async leaveThread(threadId: string): Promise<{ threadId: string; participantCount: number }> { + const r = await fetch(this.url(`/v1/threads/${threadId}/me`), { method: 'DELETE', headers: this.headers() }); + if (!r.ok) throw new Error(`leaveThread ${r.status}`); + return (await r.json()) as { threadId: string; participantCount: number }; + } + /** My saved messages (personal bookmarks), newest first, with thread context. */ async listSaved(): Promise { const r = await fetch(this.url('/v1/threads/my-annotations?type=save'), { headers: this.headers() }); diff --git a/packages/iios-kernel-client/src/types.ts b/packages/iios-kernel-client/src/types.ts index 8b32d24..d1b74dd 100644 --- a/packages/iios-kernel-client/src/types.ts +++ b/packages/iios-kernel-client/src/types.ts @@ -64,6 +64,15 @@ export interface MessageEvents { annotation: (e: AnnotationEvent) => void; } +/** A discoverable thread from GET /v1/threads/discover — includes ones you have NOT joined. */ +export interface DiscoveredThread { + threadId: string; + subject: string | null; + metadata: Record | null; + participantCount: number; + joined: boolean; +} + /** A "my threads" entry from GET /v1/threads (server-authoritative). */ export interface ThreadSummary { threadId: string; diff --git a/packages/iios-messaging-ui/src/adapters/kernel-client.test.ts b/packages/iios-messaging-ui/src/adapters/kernel-client.test.ts index 961be41..0785ee5 100644 --- a/packages/iios-messaging-ui/src/adapters/kernel-client.test.ts +++ b/packages/iios-messaging-ui/src/adapters/kernel-client.test.ts @@ -3,6 +3,7 @@ // layer is faked (via kernel-client's own SocketLike seam), so the facade's wire mapping is // exercised for real. No live IIOS required. +import { describe, it, expect } from 'vitest'; import { MessageSocket } from '@insignia/iios-kernel-client'; import type { Message as KernelMessage, SocketLike } from '@insignia/iios-kernel-client'; import { runAdapterConformance } from '../conformance'; @@ -113,6 +114,20 @@ function makeFakeRest(): RestPort { async addParticipant() { /* governed server-side; a fake always allows */ }, + async discoverThreads() { + return [ + { + threadId: 'th_pub', + subject: 'general', + metadata: { membership: 'channel', visibility: 'public', topic: 'Company-wide' }, + participantCount: 3, + joined: false, + }, + ]; + }, + async leaveThread(threadId) { + return { threadId, participantCount: 0 }; + }, }; } @@ -122,3 +137,17 @@ function makeAdapter(): KernelClientAdapter { } runAdapterConformance({ makeAdapter, seededThreadId: SEEDED, openWith: ['pp_a'] }); + +describe('KernelClientAdapter channels', () => { + it('browse maps discovered public channels; create/join/leave delegate to the transport', async () => { + const adapter = makeAdapter(); + const list = await adapter.browseChannels!(); + expect(list[0]).toMatchObject({ threadId: 'th_pub', name: 'general', visibility: 'public', joined: false, memberCount: 3, topic: 'Company-wide' }); + + const { threadId } = await adapter.createChannel!({ name: 'design', topic: 'UI', visibility: 'public' }); + expect(typeof threadId).toBe('string'); + + await expect(adapter.joinChannel!('th_pub')).resolves.toBeUndefined(); + await expect(adapter.leaveChannel!('th_pub')).resolves.toBeUndefined(); + }); +}); diff --git a/packages/iios-messaging-ui/src/adapters/kernel-client.ts b/packages/iios-messaging-ui/src/adapters/kernel-client.ts index 148fdbb..178aafa 100644 --- a/packages/iios-messaging-ui/src/adapters/kernel-client.ts +++ b/packages/iios-messaging-ui/src/adapters/kernel-client.ts @@ -8,6 +8,7 @@ import { MessageSocket, RestClient } from '@insignia/iios-kernel-client'; import type { AnnotationEvent, + DiscoveredThread, Message as KernelMessage, MessageEvents, OpenThreadResult, @@ -17,7 +18,10 @@ import type { } from '@insignia/iios-kernel-client'; import type { MessagingAdapter } from '../adapter'; import type { + ChannelSummary, + ChannelVisibility, Conversation, + CreateChannelInput, Membership, Message, MessageEvent, @@ -41,6 +45,8 @@ export interface RestPort { listThreads(filter?: { metadata?: Record }): Promise; createThread(opts: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record }): Promise<{ threadId: string }>; addParticipant(threadId: string, userId: string): Promise; + discoverThreads(filter?: { metadata?: Record }): Promise; + leaveThread(threadId: string): Promise<{ threadId: string; participantCount: number }>; } export interface KernelClientAdapterConfig { @@ -165,6 +171,43 @@ export class KernelClientAdapter implements MessagingAdapter { await this.socket.markRead(threadId, messageId); } + // ── Channels ──────────────────────────────────────────────────── + async browseChannels(): Promise { + const found = await this.rest.discoverThreads({ metadata: { membership: 'channel', visibility: 'public' } }); + return found.map((d) => { + const bag = (d.metadata as { topic?: string; visibility?: string } | null) ?? {}; + const visibility: ChannelVisibility = bag.visibility === 'private' ? 'private' : 'public'; + return { + threadId: d.threadId, + name: d.subject ?? 'channel', + topic: bag.topic ?? null, + visibility, + memberCount: d.participantCount, + joined: d.joined, + }; + }); + } + + async createChannel(input: CreateChannelInput): Promise<{ threadId: string }> { + return this.rest.createThread({ + membership: 'channel', + creatorRole: 'ADMIN', + subject: input.name, + metadata: { visibility: input.visibility, ...(input.topic ? { topic: input.topic } : {}) }, + }); + } + + async joinChannel(threadId: string): Promise { + // Self-join is a governed open_thread; OPA allows it for a public channel. + await this.socket.openThread(threadId); + this.joined.add(threadId); + } + + async leaveChannel(threadId: string): Promise { + await this.rest.leaveThread(threadId); + this.joined.delete(threadId); + } + /** Detach socket handlers. Not part of the contract — call on teardown to avoid leaks. */ close(): void { for (const off of this.offs) off();