diff --git a/packages/iios-kernel-client/package.json b/packages/iios-kernel-client/package.json index 28ec6d9..f4ef857 100644 --- a/packages/iios-kernel-client/package.json +++ b/packages/iios-kernel-client/package.json @@ -1,6 +1,6 @@ { "name": "@insignia/iios-kernel-client", - "version": "0.1.0", + "version": "0.1.1", "type": "module", "main": "dist/index.js", "module": "dist/index.js", diff --git a/packages/iios-kernel-client/src/message-socket.ts b/packages/iios-kernel-client/src/message-socket.ts index 689d829..3d4385c 100644 --- a/packages/iios-kernel-client/src/message-socket.ts +++ b/packages/iios-kernel-client/src/message-socket.ts @@ -8,14 +8,14 @@ export interface MessageSocketConfig { } /** - * Framework-agnostic facade over the `/message` Socket.io namespace (ports the - * support-sdk MessageClient). No socket.io types leak out; RPCs use emitWithAck. - * On reconnect it re-opens the current thread so subscriptions resume with no - * lost messages (the docs' disconnect/reconnect requirement). + * Framework-agnostic facade over the `/message` Socket.io namespace. No socket.io + * types leak out; RPCs use emitWithAck. It tracks EVERY joined thread and re-opens + * all of them on reconnect (the docs' disconnect/reconnect requirement), so a UI + * that watches multiple conversations keeps receiving live messages after a drop. */ export class MessageSocket { private readonly socket: SocketLike; - private currentThreadId: string | null = null; + private readonly joined = new Set(); constructor(config: MessageSocketConfig, socket?: SocketLike) { this.socket = @@ -26,9 +26,9 @@ export class MessageSocket { autoConnect: config.autoConnect ?? true, }) as unknown as SocketLike); - // Re-open the active thread after a reconnect. + // Re-subscribe to every joined thread after a reconnect. this.socket.on('connect', () => { - if (this.currentThreadId) void this.socket.emitWithAck('open_thread', { threadId: this.currentThreadId }); + for (const id of this.joined) void this.socket.emitWithAck('open_thread', { threadId: id }); }); } @@ -40,27 +40,70 @@ export class MessageSocket { this.socket.disconnect(); } + /** Run `handler` on every (re)connect, and immediately if already connected. */ + onConnected(handler: () => void): () => void { + this.socket.on('connect', handler); + if (this.socket.connected) handler(); + return () => this.socket.off('connect', handler); + } + /** Subscribe to a server event; returns an unsubscribe fn. */ on(event: E, handler: MessageEvents[E]): () => void { const fn = handler as (...args: unknown[]) => void; - this.socket.on(event, fn); - return () => this.socket.off(event, fn); + this.socket.on(event as string, fn); + return () => this.socket.off(event as string, fn); } - async openThread(threadId?: string): Promise { - const result = (await this.socket.emitWithAck('open_thread', { threadId })) as OpenThreadResult; - this.currentThreadId = result.threadId; + async openThread( + threadId?: string, + opts?: { membership?: string; creatorRole?: string; subject?: string }, + ): Promise { + // Timeout (when the transport supports it) so a server error that never acks + // can't hang the caller forever. + const ack = this.socket.timeout ? this.socket.timeout(8000) : this.socket; + const result = (await ack.emitWithAck('open_thread', { threadId, ...opts })) as OpenThreadResult & { error?: string }; + if (result?.error) throw new Error(result.error); + this.joined.add(result.threadId); return result; } - async sendMessage(threadId: string, content: string, opts?: { contentRef?: string }): Promise { + async sendMessage( + threadId: string, + content: string, + opts?: { + contentRef?: string; + parentInteractionId?: string; + mentions?: string[]; + attachment?: { contentRef: string; mimeType: string; sizeBytes: number; checksumSha256?: string }; + }, + ): Promise { return (await this.socket.emitWithAck('send_message', { threadId, content, - contentRef: opts?.contentRef, + contentRef: opts?.attachment?.contentRef ?? opts?.contentRef, + mimeType: opts?.attachment?.mimeType, + sizeBytes: opts?.attachment?.sizeBytes, + checksumSha256: opts?.attachment?.checksumSha256, + parentInteractionId: opts?.parentInteractionId, + mentions: opts?.mentions, // opaque userId notify-list; the app parses "@", not the kernel })) as Message; } + /** Pin a message in the thread (shared, generic annotation type "pin"). */ + async pin(threadId: string, interactionId: string): Promise { + await this.socket.emitWithAck('annotate', { threadId, interactionId, type: 'pin', value: '' }); + } + + /** Save a message for myself (personal, generic annotation type "save"). */ + async save(threadId: string, interactionId: string): Promise { + await this.socket.emitWithAck('annotate', { threadId, interactionId, type: 'save', value: '' }); + } + + /** Toggle an emoji reaction on a message (a generic annotation of type "reaction"). */ + async react(threadId: string, interactionId: string, value: string): Promise { + await this.socket.emitWithAck('annotate', { threadId, interactionId, type: 'reaction', value }); + } + async markRead(threadId: string, interactionId: string): Promise<{ ok: boolean }> { return (await this.socket.emitWithAck('read', { threadId, interactionId })) as { ok: boolean }; } @@ -68,4 +111,9 @@ export class MessageSocket { typing(threadId: string): void { this.socket.emit('typing', { threadId }); } + + /** Tell the server which thread is in the foreground (or null when blurred) — drives presence. */ + focus(threadId: string | null): void { + this.socket.emit('focus_thread', { threadId }); + } } diff --git a/packages/iios-kernel-client/src/rest.ts b/packages/iios-kernel-client/src/rest.ts index 9bc7572..2bf9336 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 } from './types'; +import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem, ThreadSummary, SavedItem, LoginResult } from './types'; export interface RestConfig { serviceUrl: string; @@ -57,6 +57,57 @@ export class RestClient { return (await r.json()) as InboxItem; } + // ─── auth + threads (app surface) ───────────────────────────── + /** Dev IdP login (POST /v1/dev/login). A real IdP issues the same JWT — this is the swap point. */ + async login(username: string, password: string): Promise { + const r = await fetch(this.url('/v1/dev/login'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + if (r.status === 401) throw new Error('invalid username or password'); + if (!r.ok) throw new Error(`login failed (${r.status}) — is the service running with IIOS_DEV_TOKENS=1?`); + return (await r.json()) as LoginResult; + } + + /** Dev directory: known usernames. A real app validates against its user store / MDM. */ + async listUsers(): Promise { + const r = await fetch(this.url('/v1/dev/users'), { headers: this.headers() }); + if (!r.ok) return []; + return ((await r.json()) as { users: string[] }).users; + } + + /** Server-authoritative conversation list (works cross-device, shows unread + members). */ + async listThreads(): Promise { + const r = await fetch(this.url('/v1/threads'), { headers: this.headers() }); + if (!r.ok) throw new Error(`listThreads ${r.status}`); + return (await r.json()) as ThreadSummary[]; + } + + /** Create a thread with generic app attributes (membership hint, creator role, subject/name). */ + async createThread(opts: { membership?: string; creatorRole?: string; subject?: string }): Promise<{ threadId: string }> { + return this.post<{ threadId: string }>('/v1/threads', opts); + } + + /** 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() }); + if (!r.ok) throw new Error(`listSaved ${r.status}`); + return (await r.json()) as SavedItem[]; + } + + /** Governed add-participant. A policy 403 (e.g. DM cap) surfaces its reason. */ + async addParticipant(threadId: string, userId: string): Promise { + const r = await fetch(this.url(`/v1/threads/${threadId}/participants`), { + method: 'POST', + headers: this.headers(), + body: JSON.stringify({ userId }), + }); + if (r.ok) return; + const body = (await r.json().catch(() => ({}))) as { message?: string }; + throw new Error(body.message ?? `could not add member (${r.status})`); + } + // ─── support ────────────────────────────────────────────────── async createTicket(body: { subject: string; priority?: string; threadId?: string }): Promise { return this.post('/v1/support/tickets', body); diff --git a/packages/iios-kernel-client/src/types.ts b/packages/iios-kernel-client/src/types.ts index 3be602b..5185848 100644 --- a/packages/iios-kernel-client/src/types.ts +++ b/packages/iios-kernel-client/src/types.ts @@ -1,10 +1,30 @@ +/** A media/file part attached to a message (contentRef points at object storage). */ +export interface Attachment { + contentRef: string; + mimeType: string; + sizeBytes: number; + kind: 'image' | 'video' | 'audio' | 'file'; +} + +/** A generic annotation aggregate on a message (e.g. type "reaction", value = emoji). */ +export interface AnnotationGroup { + type: string; + value: string; + users: string[]; // usernames who applied it +} + /** Wire shapes the kernel emits over socket / returns over REST (align with service). */ export interface Message { id: string; threadId: string; senderActorId: string; + senderId: string; // sender's email/username — reliable "is this mine?" check + senderName: string; content: string; contentRef?: string; + attachment?: Attachment; + parentInteractionId?: string; + annotations?: AnnotationGroup[]; traceId?: string; createdAt: string; } @@ -26,10 +46,48 @@ export interface TypingEvent { userId: string; } +/** Broadcast when someone toggles an annotation — carries the refreshed user list. */ +export interface AnnotationEvent { + threadId: string; + interactionId: string; + type: string; + value: string; + op: 'add' | 'remove'; + users: string[]; + userId: string; +} + export interface MessageEvents { message: (m: Message) => void; receipt: (e: ReceiptEvent) => void; typing: (e: TypingEvent) => void; + annotation: (e: AnnotationEvent) => void; +} + +/** A "my threads" entry from GET /v1/threads (server-authoritative). */ +export interface ThreadSummary { + threadId: string; + subject: string | null; + membership?: string; // 'dm' | 'group' (opaque app attribute) + participants: string[]; // member usernames + participantCount: number; + unread: number; + muted?: boolean; + lastMessage?: string; + lastAt?: string; +} + +/** A personally-saved message with its thread context (GET /v1/threads/my-annotations?type=save). */ +export interface SavedItem { + message: Message; + threadId: string; + threadSubject: string | null; +} + +/** Result of the dev IdP login (POST /v1/dev/login). A real IdP issues the same claims. */ +export interface LoginResult { + token: string; + userId: string; } export type InboxState = 'OPEN' | 'SNOOZED' | 'DONE' | 'ARCHIVED' | 'CANCELLED' | 'STALE'; @@ -188,4 +246,8 @@ export interface SocketLike { emitWithAck(event: string, ...args: unknown[]): Promise; connect(): unknown; disconnect(): unknown; + /** True while the underlying transport is connected (socket.io exposes this). */ + connected?: boolean; + /** Per-call ack timeout (socket.io). Optional so fakes can omit it. */ + timeout?(ms: number): { emitWithAck(event: string, ...args: unknown[]): Promise }; }