8c70b6d31f
Brings the shared SDK up to the fuller client chat-web had vendored, so frontends consume one source of truth instead of copying it: - types: Message gains senderId/senderName/attachment/parentInteractionId/ annotations; add Attachment, AnnotationGroup, AnnotationEvent, ThreadSummary, SavedItem, LoginResult; MessageEvents gains `annotation`; SocketLike gains connected + optional timeout. - MessageSocket: onConnected, openThread(opts), richer sendMessage(opts) (attachment/mentions/parentInteractionId, back-compatible with contentRef), pin/save/react via annotate, focus, reconnect-re-subscribe-all. - RestClient: login, listUsers, listThreads, createThread, listSaved, addParticipant. Bump 0.1.0 -> 0.1.1. Whole monorepo still builds (Message change is additive for iios-message-web/iios-support-web/demos). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
120 lines
4.5 KiB
TypeScript
120 lines
4.5 KiB
TypeScript
import { io } from 'socket.io-client';
|
|
import type { Message, OpenThreadResult, MessageEvents, SocketLike } from './types';
|
|
|
|
export interface MessageSocketConfig {
|
|
serviceUrl: string;
|
|
token: string;
|
|
autoConnect?: boolean;
|
|
}
|
|
|
|
/**
|
|
* 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 readonly joined = new Set<string>();
|
|
|
|
constructor(config: MessageSocketConfig, socket?: SocketLike) {
|
|
this.socket =
|
|
socket ??
|
|
(io(`${config.serviceUrl.replace(/\/$/, '')}/message`, {
|
|
auth: { token: config.token },
|
|
transports: ['websocket'],
|
|
autoConnect: config.autoConnect ?? true,
|
|
}) as unknown as SocketLike);
|
|
|
|
// Re-subscribe to every joined thread after a reconnect.
|
|
this.socket.on('connect', () => {
|
|
for (const id of this.joined) void this.socket.emitWithAck('open_thread', { threadId: id });
|
|
});
|
|
}
|
|
|
|
connect(): void {
|
|
this.socket.connect();
|
|
}
|
|
|
|
disconnect(): void {
|
|
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<E extends keyof MessageEvents>(event: E, handler: MessageEvents[E]): () => void {
|
|
const fn = handler as (...args: unknown[]) => void;
|
|
this.socket.on(event as string, fn);
|
|
return () => this.socket.off(event as string, fn);
|
|
}
|
|
|
|
async openThread(
|
|
threadId?: string,
|
|
opts?: { membership?: string; creatorRole?: string; subject?: string },
|
|
): Promise<OpenThreadResult> {
|
|
// 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;
|
|
parentInteractionId?: string;
|
|
mentions?: string[];
|
|
attachment?: { contentRef: string; mimeType: string; sizeBytes: number; checksumSha256?: string };
|
|
},
|
|
): Promise<Message> {
|
|
return (await this.socket.emitWithAck('send_message', {
|
|
threadId,
|
|
content,
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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 };
|
|
}
|
|
|
|
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 });
|
|
}
|
|
}
|