@ai-matrx/messaging 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +78 -0
- package/LICENSE +21 -0
- package/README.md +287 -2
- package/dist/index.cjs +1880 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +995 -0
- package/dist/index.d.ts +995 -0
- package/dist/index.js +1862 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +2990 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +1273 -0
- package/dist/react.d.ts +1273 -0
- package/dist/react.js +2980 -0
- package/dist/react.js.map +1 -0
- package/dist/styles.css +745 -0
- package/dist/tokens.css +119 -0
- package/package.json +100 -7
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,1273 @@
|
|
|
1
|
+
import { ReactNode, SVGProps } from 'react';
|
|
2
|
+
import { MatrxTransport } from '@ai-matrx/agents/matrx';
|
|
3
|
+
import { RealtimeClientLike, RealtimeManager } from '@ai-matrx/realtime';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* THE MESSAGING DOMAIN TYPES.
|
|
7
|
+
*
|
|
8
|
+
* Ported from `matrx-frontend/features/messaging/types.ts` with the coupling
|
|
9
|
+
* seams inverted: no imported host types, no app singletons. The DB-shaped
|
|
10
|
+
* types mirror `communication.dm_*` exactly (snake_case, nullable where the
|
|
11
|
+
* column is nullable) so a row read from PostgREST IS one of these without a
|
|
12
|
+
* mapping step that can silently drop a column.
|
|
13
|
+
*
|
|
14
|
+
* Strictness posture (C22): identity-bearing values are BRANDED, invalid states
|
|
15
|
+
* are unrepresentable (a `sending` message has a `clientMessageId`; a `failed`
|
|
16
|
+
* one carries a reason), and no surface here is `any`. The one honest `unknown`
|
|
17
|
+
* is the JSON boundary — `metadata` and `action_data` are host/DB-generated
|
|
18
|
+
* shapes this package does not own (the data 0.2.1 `Json = unknown` lesson).
|
|
19
|
+
*/
|
|
20
|
+
/** Nominal id types. A conversation id can never be passed where a user id goes. */
|
|
21
|
+
declare const brand: unique symbol;
|
|
22
|
+
type Brand<T, B extends string> = T & {
|
|
23
|
+
readonly [brand]: B;
|
|
24
|
+
};
|
|
25
|
+
type ConversationId = Brand<string, "ConversationId">;
|
|
26
|
+
type MessageId = Brand<string, "MessageId">;
|
|
27
|
+
type UserId = Brand<string, "UserId">;
|
|
28
|
+
type OrganizationId = Brand<string, "OrganizationId">;
|
|
29
|
+
/** The client-minted idempotency key that makes a send exactly-once end to end. */
|
|
30
|
+
type ClientMessageId = Brand<string, "ClientMessageId">;
|
|
31
|
+
declare const asConversationId: (value: string) => ConversationId;
|
|
32
|
+
declare const asMessageId: (value: string) => MessageId;
|
|
33
|
+
declare const asUserId: (value: string) => UserId;
|
|
34
|
+
declare const asOrganizationId: (value: string) => OrganizationId;
|
|
35
|
+
declare const asClientMessageId: (value: string) => ClientMessageId;
|
|
36
|
+
type JsonValue = unknown;
|
|
37
|
+
type JsonObject = Readonly<Record<string, JsonValue>>;
|
|
38
|
+
type ConversationType = "direct" | "group" | "org";
|
|
39
|
+
type ParticipantRole = "owner" | "admin" | "member";
|
|
40
|
+
type MessageKind = "text" | "image" | "video" | "audio" | "file" | "system" | "action";
|
|
41
|
+
/**
|
|
42
|
+
* The delivery ladder. `sending` and `failed` exist only client-side: they are
|
|
43
|
+
* the outbox's states, and the DB's `status` column never holds them.
|
|
44
|
+
*/
|
|
45
|
+
type DeliveryState = "sending" | "sent" | "delivered" | "read" | "failed";
|
|
46
|
+
interface Conversation {
|
|
47
|
+
readonly id: ConversationId;
|
|
48
|
+
readonly type: ConversationType;
|
|
49
|
+
readonly groupName: string | null;
|
|
50
|
+
readonly groupImageUrl: string | null;
|
|
51
|
+
readonly createdBy: UserId | null;
|
|
52
|
+
readonly organizationId: OrganizationId;
|
|
53
|
+
readonly createdAt: string;
|
|
54
|
+
readonly updatedAt: string;
|
|
55
|
+
readonly metadata: JsonObject;
|
|
56
|
+
}
|
|
57
|
+
interface Participant {
|
|
58
|
+
readonly conversationId: ConversationId;
|
|
59
|
+
readonly userId: UserId;
|
|
60
|
+
readonly role: ParticipantRole;
|
|
61
|
+
readonly joinedAt: string | null;
|
|
62
|
+
readonly lastReadAt: string | null;
|
|
63
|
+
readonly isMuted: boolean;
|
|
64
|
+
readonly isArchived: boolean;
|
|
65
|
+
}
|
|
66
|
+
interface UserSummary {
|
|
67
|
+
readonly userId: UserId;
|
|
68
|
+
readonly displayName: string;
|
|
69
|
+
readonly email: string | null;
|
|
70
|
+
readonly avatarUrl: string | null;
|
|
71
|
+
/** True when this participant is an AI agent rather than a person (R4). */
|
|
72
|
+
readonly isAgent: boolean;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A structured action carried by a message. THE SEAM other packages extend —
|
|
76
|
+
* `@ai-matrx/meet` puts a call invitation here rather than inventing a message
|
|
77
|
+
* type (D1).
|
|
78
|
+
*
|
|
79
|
+
* `version` is not decoration: a renderer that does not understand a version
|
|
80
|
+
* renders NOTHING rather than guessing, which is what lets a new sender ship
|
|
81
|
+
* before every reader has caught up.
|
|
82
|
+
*/
|
|
83
|
+
interface MessageAction<TKind extends string = string, TPayload = JsonObject> {
|
|
84
|
+
readonly kind: TKind;
|
|
85
|
+
readonly version: number;
|
|
86
|
+
readonly payload: TPayload;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A typed reference to a platform entity, rendered as a live openable card.
|
|
90
|
+
* NO DEAD ENDS: everything a message names must open, so a reference always
|
|
91
|
+
* carries enough to resolve — never a bare label.
|
|
92
|
+
*/
|
|
93
|
+
interface MatrxReference {
|
|
94
|
+
readonly entityType: string;
|
|
95
|
+
readonly entityId: string;
|
|
96
|
+
readonly label: string;
|
|
97
|
+
readonly href?: string | undefined;
|
|
98
|
+
}
|
|
99
|
+
interface Attachment {
|
|
100
|
+
/** A DURABLE file ref, never a signed URL. Signed URLs expire; identities do not. */
|
|
101
|
+
readonly fileId: string;
|
|
102
|
+
readonly fileName: string;
|
|
103
|
+
readonly mimeType: string | null;
|
|
104
|
+
readonly sizeBytes: number | null;
|
|
105
|
+
readonly width: number | null;
|
|
106
|
+
readonly height: number | null;
|
|
107
|
+
}
|
|
108
|
+
interface Message {
|
|
109
|
+
readonly id: MessageId;
|
|
110
|
+
readonly conversationId: ConversationId;
|
|
111
|
+
readonly senderId: UserId;
|
|
112
|
+
readonly organizationId: OrganizationId;
|
|
113
|
+
readonly content: string;
|
|
114
|
+
readonly kind: MessageKind;
|
|
115
|
+
readonly deliveryState: DeliveryState;
|
|
116
|
+
readonly replyToId: MessageId | null;
|
|
117
|
+
readonly clientMessageId: ClientMessageId | null;
|
|
118
|
+
readonly createdAt: string;
|
|
119
|
+
readonly editedAt: string | null;
|
|
120
|
+
readonly deletedAt: string | null;
|
|
121
|
+
readonly deletedForEveryone: boolean;
|
|
122
|
+
readonly action: MessageAction | null;
|
|
123
|
+
readonly attachments: readonly Attachment[];
|
|
124
|
+
readonly references: readonly MatrxReference[];
|
|
125
|
+
readonly metadata: JsonObject;
|
|
126
|
+
/** Set only while this message is in the outbox and its last send failed. */
|
|
127
|
+
readonly failureReason?: string | undefined;
|
|
128
|
+
}
|
|
129
|
+
interface ConversationSummary {
|
|
130
|
+
readonly conversation: Conversation;
|
|
131
|
+
readonly participants: readonly UserSummary[];
|
|
132
|
+
readonly lastMessageContent: string | null;
|
|
133
|
+
readonly lastMessageSenderId: UserId | null;
|
|
134
|
+
readonly lastMessageAt: string | null;
|
|
135
|
+
readonly unreadCount: number;
|
|
136
|
+
readonly isMuted: boolean;
|
|
137
|
+
readonly isArchived: boolean;
|
|
138
|
+
/** Resolved for display: the group name, or the other participant's name. */
|
|
139
|
+
readonly displayName: string;
|
|
140
|
+
readonly displayImageUrl: string | null;
|
|
141
|
+
/** The keyset sort value. Pagination cursors are built from this + `id`. */
|
|
142
|
+
readonly sortAt: string;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* THE PAGINATION CURSOR. Stable ordering is terminated by a UNIQUE column
|
|
146
|
+
* (R5 scale honesty): ordering by a timestamp alone silently duplicates or
|
|
147
|
+
* skips rows whenever two rows share a millisecond, which in a large org is
|
|
148
|
+
* every page.
|
|
149
|
+
*/
|
|
150
|
+
interface ConversationCursor {
|
|
151
|
+
readonly beforeSortAt: string;
|
|
152
|
+
readonly beforeConversationId: ConversationId;
|
|
153
|
+
}
|
|
154
|
+
interface MessageCursor {
|
|
155
|
+
readonly beforeCreatedAt: string;
|
|
156
|
+
readonly beforeMessageId: MessageId;
|
|
157
|
+
}
|
|
158
|
+
interface Page<TItem, TCursor> {
|
|
159
|
+
readonly items: readonly TItem[];
|
|
160
|
+
readonly nextCursor: TCursor | null;
|
|
161
|
+
readonly hasMore: boolean;
|
|
162
|
+
}
|
|
163
|
+
/** What the host injects. Identity ONLY — every hard part is in the package. */
|
|
164
|
+
interface MessagingIdentity {
|
|
165
|
+
readonly userId: UserId;
|
|
166
|
+
readonly organizationId: OrganizationId;
|
|
167
|
+
}
|
|
168
|
+
interface DraftMessage {
|
|
169
|
+
readonly conversationId: ConversationId;
|
|
170
|
+
readonly content: string;
|
|
171
|
+
readonly kind?: MessageKind | undefined;
|
|
172
|
+
readonly replyToId?: MessageId | undefined;
|
|
173
|
+
readonly action?: MessageAction | undefined;
|
|
174
|
+
readonly attachments?: readonly Attachment[] | undefined;
|
|
175
|
+
readonly references?: readonly MatrxReference[] | undefined;
|
|
176
|
+
readonly metadata?: JsonObject | undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* ACTIONABLE MESSAGES — a message that carries something you can DO.
|
|
181
|
+
*
|
|
182
|
+
* A registry, never a `switch`. Two properties come from that choice, and both
|
|
183
|
+
* are load-bearing:
|
|
184
|
+
*
|
|
185
|
+
* - **Forward compatibility.** An unknown `kind` — or a known kind at a version
|
|
186
|
+
* this build does not understand — renders NOTHING and executes nothing. A
|
|
187
|
+
* `switch` with a `default: throw` means the day a new sender ships, every
|
|
188
|
+
* older reader's thread breaks. Silently rendering an unknown payload with a
|
|
189
|
+
* generic button is worse: it offers an action nobody can honor.
|
|
190
|
+
* - **Extensibility across packages.** `@ai-matrx/meet` registers its call
|
|
191
|
+
* invitation here rather than this package learning about meetings (D1).
|
|
192
|
+
*
|
|
193
|
+
* 🚨 **IDEMPOTENCE IS ENFORCED HERE, NOT PROMISED BY HANDLERS.** Two tabs, a
|
|
194
|
+
* double-click, and a retried tap must apply an action ONCE. The registry
|
|
195
|
+
* de-duplicates by `(kind, messageId, actorId)`: a second execution while the
|
|
196
|
+
* first is in flight AWAITS it and returns the same receipt, and a settled
|
|
197
|
+
* action returns its stored receipt without touching the server.
|
|
198
|
+
*
|
|
199
|
+
* 🚨 **THE PAYLOAD IS NOT AUTHORIZATION.** A handler must re-resolve the
|
|
200
|
+
* durable, caller-authorized request row server-side before it writes. The
|
|
201
|
+
* message's `action_data` says what was ASKED, never what is ALLOWED — trusting
|
|
202
|
+
* it turns any message into a privilege grant. Handlers declare this by
|
|
203
|
+
* construction: they receive the payload and the actor, and must go to the
|
|
204
|
+
* database themselves.
|
|
205
|
+
*/
|
|
206
|
+
|
|
207
|
+
type ActionOutcome = "applied" | "already" | "declined" | "unavailable";
|
|
208
|
+
interface ActionReceipt {
|
|
209
|
+
readonly kind: string;
|
|
210
|
+
readonly messageId: MessageId;
|
|
211
|
+
readonly actorId: UserId;
|
|
212
|
+
readonly outcome: ActionOutcome;
|
|
213
|
+
readonly label: string;
|
|
214
|
+
readonly settledAt: string;
|
|
215
|
+
readonly detail?: string | undefined;
|
|
216
|
+
}
|
|
217
|
+
interface ActionContext {
|
|
218
|
+
readonly messageId: MessageId;
|
|
219
|
+
readonly actorId: UserId;
|
|
220
|
+
readonly organizationId: string;
|
|
221
|
+
/** Which of the choices the user picked, e.g. `approve` / `decline`. */
|
|
222
|
+
readonly choice: string;
|
|
223
|
+
}
|
|
224
|
+
interface ActionChoice {
|
|
225
|
+
readonly id: string;
|
|
226
|
+
readonly label: string;
|
|
227
|
+
readonly tone: "primary" | "neutral" | "danger";
|
|
228
|
+
}
|
|
229
|
+
interface ActionHandler<TPayload = JsonObject> {
|
|
230
|
+
readonly kind: string;
|
|
231
|
+
/** Versions this build understands. An unlisted version renders nothing. */
|
|
232
|
+
readonly versions: readonly number[];
|
|
233
|
+
/** What the chips say. Return `[]` to render the action as read-only. */
|
|
234
|
+
choices: (payload: TPayload) => readonly ActionChoice[];
|
|
235
|
+
/** One-line summary for the inbox preview and notifications. */
|
|
236
|
+
summarize?: (payload: TPayload) => string;
|
|
237
|
+
/**
|
|
238
|
+
* Do the thing. MUST re-resolve the authorizing row server-side; the payload
|
|
239
|
+
* is the request, not the permission. Returning `already` is how a handler
|
|
240
|
+
* reports that the server had settled it — the registry treats it as success.
|
|
241
|
+
*/
|
|
242
|
+
execute: (payload: TPayload, context: ActionContext) => Promise<ActionReceipt>;
|
|
243
|
+
}
|
|
244
|
+
interface ActionRegistry {
|
|
245
|
+
register<TPayload = JsonObject>(handler: ActionHandler<TPayload>): void;
|
|
246
|
+
/** The handler for an action, or null when this build cannot honor it. */
|
|
247
|
+
resolve(action: MessageAction): ActionHandler | null;
|
|
248
|
+
choicesFor(action: MessageAction): readonly ActionChoice[];
|
|
249
|
+
summarize(action: MessageAction): string | null;
|
|
250
|
+
/** Idempotent execution. Concurrent callers share one in-flight result. */
|
|
251
|
+
execute(action: MessageAction, context: ActionContext): Promise<ActionReceipt>;
|
|
252
|
+
/** The stored receipt for a settled action, if there is one. */
|
|
253
|
+
receiptFor(kind: string, messageId: MessageId, actorId: UserId): ActionReceipt | null;
|
|
254
|
+
/** Record a receipt observed from another client's broadcast. */
|
|
255
|
+
observeReceipt(receipt: ActionReceipt): void;
|
|
256
|
+
known(): readonly string[];
|
|
257
|
+
}
|
|
258
|
+
declare function createActionRegistry(): ActionRegistry;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* AI-NATIVE, OUT OF THE BOX (R4).
|
|
262
|
+
*
|
|
263
|
+
* Four conversation intelligences — catch me up, summarize, extract action
|
|
264
|
+
* items, draft a reply — plus agents as participants. All of it executes on the
|
|
265
|
+
* platform's agent system through `@ai-matrx/agents`; this package never talks
|
|
266
|
+
* to a model, never holds a prompt, and never carries an agent definition.
|
|
267
|
+
*
|
|
268
|
+
* 🚨 **THE USER-INPUT LAW.** `user_input` is what a HUMAN typed. Machine
|
|
269
|
+
* content — a transcript, a participant roster, a cutoff timestamp — travels as
|
|
270
|
+
* NAMED VARIABLES. This is not style: the server treats `user_input` as the
|
|
271
|
+
* turn's human utterance (it is stored as such, shown as such, and shapes the
|
|
272
|
+
* agent's framing), so smuggling a transcript through it silently corrupts
|
|
273
|
+
* every conversation it touches. Every call below sends `variables` and either
|
|
274
|
+
* a short human-shaped `user_input` or none.
|
|
275
|
+
*
|
|
276
|
+
* 🚨 **AGENT DEFINITIONS LIVE IN THE DATABASE.** The package takes agent IDs as
|
|
277
|
+
* injected identity. When an id is not configured the capability reports
|
|
278
|
+
* `unavailable` WITH the remedy — it never silently no-ops, and the UI hides
|
|
279
|
+
* the action rather than offering a dead button (no dead ends).
|
|
280
|
+
*/
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Which platform agent backs each capability. Every field is a DATABASE row id
|
|
284
|
+
* supplied by the host; an omitted one disables exactly that capability.
|
|
285
|
+
*/
|
|
286
|
+
interface MessagingAgents {
|
|
287
|
+
readonly catchUp?: string | undefined;
|
|
288
|
+
readonly summarize?: string | undefined;
|
|
289
|
+
readonly actionItems?: string | undefined;
|
|
290
|
+
readonly draftReply?: string | undefined;
|
|
291
|
+
}
|
|
292
|
+
interface MessagingAiOptions {
|
|
293
|
+
transport: MatrxTransport;
|
|
294
|
+
organizationId: string;
|
|
295
|
+
agents: MessagingAgents;
|
|
296
|
+
/** Stable source slugs so server-side analytics can see where a run came from. */
|
|
297
|
+
sourceApp?: string | undefined;
|
|
298
|
+
sourceFeature?: string | undefined;
|
|
299
|
+
/** Cap on how much transcript is sent. Default 200 messages. */
|
|
300
|
+
maxTranscriptMessages?: number | undefined;
|
|
301
|
+
}
|
|
302
|
+
type AiCapability = keyof MessagingAgents;
|
|
303
|
+
interface AiResult {
|
|
304
|
+
readonly capability: AiCapability;
|
|
305
|
+
readonly text: string;
|
|
306
|
+
readonly conversationId: string | null;
|
|
307
|
+
}
|
|
308
|
+
interface MessagingAi {
|
|
309
|
+
/** Which capabilities this host actually configured. Drives what the UI offers. */
|
|
310
|
+
available(): readonly AiCapability[];
|
|
311
|
+
isAvailable(capability: AiCapability): boolean;
|
|
312
|
+
catchMeUp(args: {
|
|
313
|
+
conversationId: ConversationId;
|
|
314
|
+
messages: readonly Message[];
|
|
315
|
+
participants: readonly UserSummary[];
|
|
316
|
+
since: string | null;
|
|
317
|
+
signal?: AbortSignal;
|
|
318
|
+
}): Promise<AiResult>;
|
|
319
|
+
summarize(args: {
|
|
320
|
+
conversationId: ConversationId;
|
|
321
|
+
messages: readonly Message[];
|
|
322
|
+
participants: readonly UserSummary[];
|
|
323
|
+
signal?: AbortSignal;
|
|
324
|
+
}): Promise<AiResult>;
|
|
325
|
+
extractActionItems(args: {
|
|
326
|
+
conversationId: ConversationId;
|
|
327
|
+
messages: readonly Message[];
|
|
328
|
+
participants: readonly UserSummary[];
|
|
329
|
+
signal?: AbortSignal;
|
|
330
|
+
}): Promise<AiResult>;
|
|
331
|
+
draftReply(args: {
|
|
332
|
+
conversationId: ConversationId;
|
|
333
|
+
messages: readonly Message[];
|
|
334
|
+
participants: readonly UserSummary[];
|
|
335
|
+
/** What the human asked for, if anything — this IS a human utterance. */
|
|
336
|
+
instruction?: string | undefined;
|
|
337
|
+
signal?: AbortSignal;
|
|
338
|
+
}): Promise<AiResult>;
|
|
339
|
+
}
|
|
340
|
+
declare function createMessagingAi(options: MessagingAiOptions): MessagingAi;
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* THE OUTBOX — a message you typed is NEVER lost (R2, table-stakes law).
|
|
344
|
+
*
|
|
345
|
+
* This is the piece hosts always get wrong, so it is entirely in the package
|
|
346
|
+
* (C22). What "never lost" actually requires, and what each part defends:
|
|
347
|
+
*
|
|
348
|
+
* - **Durability across a reload.** The draft is persisted BEFORE the network
|
|
349
|
+
* call, not after it succeeds. A tab closed mid-send comes back with the
|
|
350
|
+
* message still queued. The storage port has a working default; a host that
|
|
351
|
+
* injects nothing still gets in-memory queuing, and it SAYS so rather than
|
|
352
|
+
* pretending to be durable.
|
|
353
|
+
* - **Exactly-once on retry.** Every entry carries a client-minted
|
|
354
|
+
* `clientMessageId` that is generated ONCE and reused by every attempt. A
|
|
355
|
+
* retry after a response that was actually delivered is deduped by the
|
|
356
|
+
* database's idempotency key instead of posting the message twice.
|
|
357
|
+
* - **Order.** Entries for one conversation send strictly in order; message 2
|
|
358
|
+
* never overtakes a retrying message 1.
|
|
359
|
+
* - **Bounded, honest failure.** Attempts back off; after `maxAttempts` the
|
|
360
|
+
* entry stays in the queue marked `failed` WITH a reason and a retry door.
|
|
361
|
+
* It is never silently dropped, and it never retries forever.
|
|
362
|
+
*/
|
|
363
|
+
|
|
364
|
+
interface OutboxEntry {
|
|
365
|
+
readonly id: string;
|
|
366
|
+
readonly clientMessageId: ClientMessageId;
|
|
367
|
+
readonly draft: DraftMessage;
|
|
368
|
+
readonly queuedAt: number;
|
|
369
|
+
readonly attempts: number;
|
|
370
|
+
readonly state: "queued" | "sending" | "failed";
|
|
371
|
+
readonly failureReason: string | null;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Persistence for pending sends. A default is always supplied — the package
|
|
375
|
+
* never leaves a port empty (THE ALL-INCLUSIVE LAW).
|
|
376
|
+
*/
|
|
377
|
+
interface OutboxStorage {
|
|
378
|
+
/** Human-readable, shown in diagnostics so "durable" is never assumed. */
|
|
379
|
+
readonly name: string;
|
|
380
|
+
readonly durable: boolean;
|
|
381
|
+
load(): readonly OutboxEntry[];
|
|
382
|
+
save(entries: readonly OutboxEntry[]): void;
|
|
383
|
+
}
|
|
384
|
+
declare function createMemoryOutboxStorage(): OutboxStorage;
|
|
385
|
+
interface WebStorageLike {
|
|
386
|
+
getItem(key: string): string | null;
|
|
387
|
+
setItem(key: string, value: string): void;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* The real default in a browser. Falls back to memory — announcing itself —
|
|
391
|
+
* when storage is unavailable (private mode, a disabled-cookies browser, SSR).
|
|
392
|
+
*/
|
|
393
|
+
declare function createWebOutboxStorage(args: {
|
|
394
|
+
key?: string;
|
|
395
|
+
storage?: WebStorageLike | null;
|
|
396
|
+
onFallback?: (reason: string) => void;
|
|
397
|
+
}): OutboxStorage;
|
|
398
|
+
interface OutboxOptions {
|
|
399
|
+
/** Actually send. Returns the persisted message. */
|
|
400
|
+
send: (draft: DraftMessage, clientMessageId: ClientMessageId) => Promise<Message>;
|
|
401
|
+
/** Called when an entry lands. The store collapses it with the optimistic row. */
|
|
402
|
+
onSent: (entry: OutboxEntry, message: Message) => void;
|
|
403
|
+
onChange: (entries: readonly OutboxEntry[]) => void;
|
|
404
|
+
onDiagnostic?: (message: string) => void;
|
|
405
|
+
storage?: OutboxStorage;
|
|
406
|
+
maxAttempts?: number;
|
|
407
|
+
/** Backoff schedule per attempt, ms. The last value repeats. */
|
|
408
|
+
backoffMs?: readonly number[];
|
|
409
|
+
timers?: {
|
|
410
|
+
setTimeout: (run: () => void, ms: number) => unknown;
|
|
411
|
+
clearTimeout: (handle: unknown) => void;
|
|
412
|
+
now: () => number;
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
interface Outbox {
|
|
416
|
+
entries(): readonly OutboxEntry[];
|
|
417
|
+
/** Queue a draft. Returns the id the optimistic message must carry. */
|
|
418
|
+
enqueue(draft: DraftMessage): ClientMessageId;
|
|
419
|
+
/** Retry one failed entry, on the user's explicit ask. */
|
|
420
|
+
retry(entryId: string): void;
|
|
421
|
+
/** Drop one entry — the only sanctioned way a typed message leaves the queue. */
|
|
422
|
+
discard(entryId: string): void;
|
|
423
|
+
/** Network came back / tab woke: try everything that is waiting. */
|
|
424
|
+
flush(): void;
|
|
425
|
+
pendingFor(conversationId: ConversationId): readonly OutboxEntry[];
|
|
426
|
+
dispose(): void;
|
|
427
|
+
}
|
|
428
|
+
declare function createOutbox(options: OutboxOptions): Outbox;
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* THE STRUCTURAL SUPABASE SHAPE.
|
|
432
|
+
*
|
|
433
|
+
* D2 says messaging is Supabase-locked — there is no provider abstraction and
|
|
434
|
+
* never will be. That is a statement about the CONTRACT, not about the import
|
|
435
|
+
* graph: this package still accepts the client STRUCTURALLY and never imports
|
|
436
|
+
* `@supabase/supabase-js` at runtime.
|
|
437
|
+
*
|
|
438
|
+
* Two reasons, both learned the hard way across the fleet:
|
|
439
|
+
*
|
|
440
|
+
* 1. A host that ships two copies of `@supabase/supabase-js` (the Next.js
|
|
441
|
+
* server/browser split is the usual way) would get two different `Session`
|
|
442
|
+
* identities and a package that refuses the host's own client.
|
|
443
|
+
* 2. `supabase-js` is a heavy dependency with its own release train; a package
|
|
444
|
+
* that pins one is a version-drift generator (THE LATEST LAW's failure mode
|
|
445
|
+
* in miniature).
|
|
446
|
+
*
|
|
447
|
+
* `supabase-shape.test.ts` proves at COMPILE TIME that a real `SupabaseClient`
|
|
448
|
+
* satisfies these interfaces. If that file stops compiling, the interface here
|
|
449
|
+
* is wrong — fix it here, never with a cast at the call site, and never by
|
|
450
|
+
* asking the consumer to adapt.
|
|
451
|
+
*/
|
|
452
|
+
|
|
453
|
+
/** What PostgREST hands back. `error` is opaque on purpose — we normalize it. */
|
|
454
|
+
interface PostgrestLikeResponse<T> {
|
|
455
|
+
data: T | null;
|
|
456
|
+
error: {
|
|
457
|
+
message: string;
|
|
458
|
+
code?: string;
|
|
459
|
+
details?: string | null;
|
|
460
|
+
} | null;
|
|
461
|
+
}
|
|
462
|
+
interface PostgrestFilterLike<TRow> extends PromiseLike<PostgrestLikeResponse<TRow[]>> {
|
|
463
|
+
eq(column: string, value: unknown): PostgrestFilterLike<TRow>;
|
|
464
|
+
neq(column: string, value: unknown): PostgrestFilterLike<TRow>;
|
|
465
|
+
in(column: string, values: readonly unknown[]): PostgrestFilterLike<TRow>;
|
|
466
|
+
is(column: string, value: unknown): PostgrestFilterLike<TRow>;
|
|
467
|
+
lt(column: string, value: unknown): PostgrestFilterLike<TRow>;
|
|
468
|
+
gt(column: string, value: unknown): PostgrestFilterLike<TRow>;
|
|
469
|
+
or(filters: string): PostgrestFilterLike<TRow>;
|
|
470
|
+
order(column: string, options?: {
|
|
471
|
+
ascending?: boolean;
|
|
472
|
+
}): PostgrestFilterLike<TRow>;
|
|
473
|
+
limit(count: number): PostgrestFilterLike<TRow>;
|
|
474
|
+
select(columns?: string): PostgrestFilterLike<TRow>;
|
|
475
|
+
single(): PromiseLike<PostgrestLikeResponse<TRow>>;
|
|
476
|
+
maybeSingle(): PromiseLike<PostgrestLikeResponse<TRow>>;
|
|
477
|
+
}
|
|
478
|
+
interface PostgrestTableLike<TRow> {
|
|
479
|
+
select(columns?: string): PostgrestFilterLike<TRow>;
|
|
480
|
+
insert(values: unknown): PostgrestFilterLike<TRow>;
|
|
481
|
+
update(values: unknown): PostgrestFilterLike<TRow>;
|
|
482
|
+
upsert(values: unknown, options?: {
|
|
483
|
+
onConflict?: string;
|
|
484
|
+
}): PostgrestFilterLike<TRow>;
|
|
485
|
+
delete(): PostgrestFilterLike<TRow>;
|
|
486
|
+
}
|
|
487
|
+
interface SchemaLike {
|
|
488
|
+
from<TRow = Record<string, unknown>>(table: string): PostgrestTableLike<TRow>;
|
|
489
|
+
rpc<TResult = unknown>(fn: string, args?: Record<string, unknown>): PromiseLike<PostgrestLikeResponse<TResult>>;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* The subset of the Supabase client this package uses. `schema()` matters:
|
|
493
|
+
* every messaging table lives in the `communication` schema, never `public`
|
|
494
|
+
* (the platform's canonical DB conventions, R8).
|
|
495
|
+
*/
|
|
496
|
+
interface SupabaseLike extends SchemaLike {
|
|
497
|
+
schema(name: string): SchemaLike;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* What `<MessagingProvider client={...}>` actually needs: the PostgREST surface
|
|
501
|
+
* above AND the realtime surface `@ai-matrx/realtime` accepts. A real
|
|
502
|
+
* `SupabaseClient` satisfies both, which is the point — the host passes the one
|
|
503
|
+
* client it already has, and the package never casts to make it fit.
|
|
504
|
+
*/
|
|
505
|
+
type MessagingSupabaseClient = SupabaseLike & RealtimeClientLike;
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* THE DATA CONTRACT — the ONE place a messaging table or RPC name exists.
|
|
509
|
+
*
|
|
510
|
+
* Every name below was verified against Matrx Main (`https://db.matrxserver.com`)
|
|
511
|
+
* on 2026-08-31, not guessed from a client. `matrx-dm` grew a parallel schema in
|
|
512
|
+
* its own project (`public.conversations` / `messages` / `message_reactions`);
|
|
513
|
+
* per R8 exactly ONE canonical schema survives and it is this one — the
|
|
514
|
+
* platform-conventional `communication.dm_*` tables with explicit
|
|
515
|
+
* `organization_id`, `version`, and soft-delete columns, behind auth-checked
|
|
516
|
+
* SECURITY DEFINER RPCs.
|
|
517
|
+
*
|
|
518
|
+
* Four rules hold this file together:
|
|
519
|
+
*
|
|
520
|
+
* 1. **Org is explicit on every write (R5).** Not defaulted by a trigger we
|
|
521
|
+
* hope fires, not inherited — passed, and refused in-package when absent.
|
|
522
|
+
* 2. **Authorization is the database's job.** There is no permission branch in
|
|
523
|
+
* this file. An RPC that says no is a `forbidden` MessagingError, never a
|
|
524
|
+
* client-side re-decision.
|
|
525
|
+
* 3. **Direct conversations are created ATOMICALLY, by RPC.** The banned
|
|
526
|
+
* pattern — read `find_dm_direct_conversation`, then insert — races two
|
|
527
|
+
* tabs into two conversations for the same pair. The RPC advisory-locks the
|
|
528
|
+
* unordered pair. Nothing here may reintroduce the read-then-insert shape.
|
|
529
|
+
* 4. **Pagination is keyset and terminated by a unique column.** Ordering by a
|
|
530
|
+
* timestamp alone duplicates or skips rows whenever two share a millisecond
|
|
531
|
+
* — which in a large org is every page.
|
|
532
|
+
*/
|
|
533
|
+
|
|
534
|
+
/** The schema. Messaging is never in `public`. */
|
|
535
|
+
declare const MESSAGING_SCHEMA = "communication";
|
|
536
|
+
declare const TABLES: {
|
|
537
|
+
readonly conversations: "dm_conversations";
|
|
538
|
+
readonly participants: "dm_conversation_participants";
|
|
539
|
+
readonly messages: "dm_messages";
|
|
540
|
+
};
|
|
541
|
+
declare const RPCS: {
|
|
542
|
+
/** Atomic direct-conversation creation. Advisory-locks the unordered pair. */
|
|
543
|
+
readonly getOrCreateDirect: "dm_get_or_create_direct_conversation";
|
|
544
|
+
/** Conversation list + participants + last message + unread, keyset paged. */
|
|
545
|
+
readonly conversationsWithDetails: "get_dm_conversations_with_details";
|
|
546
|
+
readonly unreadCount: "get_dm_unread_count";
|
|
547
|
+
readonly userInfo: "get_dm_user_info";
|
|
548
|
+
readonly isParticipant: "is_dm_participant";
|
|
549
|
+
};
|
|
550
|
+
/**
|
|
551
|
+
* The host's session source. A messaging read that lands between sign-in and
|
|
552
|
+
* token arrival must not become a red error — it retries ONCE after the host
|
|
553
|
+
* resolves a session, then reports `session-unavailable`.
|
|
554
|
+
*/
|
|
555
|
+
type SessionResolver = () => Promise<unknown>;
|
|
556
|
+
interface RepositoryOptions {
|
|
557
|
+
client: SupabaseLike;
|
|
558
|
+
identity: MessagingIdentity;
|
|
559
|
+
/** Called once before a retry when a read fails with a missing session. */
|
|
560
|
+
resolveSession?: SessionResolver | undefined;
|
|
561
|
+
/** Profile-lookup cache TTL. Default 5 minutes (the frontend's proven value). */
|
|
562
|
+
userTtlMs?: number | undefined;
|
|
563
|
+
now?: (() => number) | undefined;
|
|
564
|
+
}
|
|
565
|
+
interface MessagingRepository {
|
|
566
|
+
readonly identity: MessagingIdentity;
|
|
567
|
+
listConversations(args?: {
|
|
568
|
+
limit?: number;
|
|
569
|
+
cursor?: ConversationCursor | null;
|
|
570
|
+
}): Promise<Page<ConversationSummary, ConversationCursor>>;
|
|
571
|
+
getConversation(id: ConversationId): Promise<Conversation>;
|
|
572
|
+
listMessages(conversationId: ConversationId, args?: {
|
|
573
|
+
limit?: number;
|
|
574
|
+
cursor?: MessageCursor | null;
|
|
575
|
+
}): Promise<Page<Message, MessageCursor>>;
|
|
576
|
+
/** Messages created strictly after `since` — the reconnect backfill read. */
|
|
577
|
+
messagesSince(conversationId: ConversationId, since: string): Promise<readonly Message[]>;
|
|
578
|
+
getOrCreateDirectConversation(otherUserId: UserId): Promise<ConversationId>;
|
|
579
|
+
createGroupConversation(args: {
|
|
580
|
+
name: string;
|
|
581
|
+
memberIds: readonly UserId[];
|
|
582
|
+
}): Promise<ConversationId>;
|
|
583
|
+
insertMessage(draft: DraftMessage, clientMessageId: ClientMessageId): Promise<Message>;
|
|
584
|
+
editMessage(id: MessageId, content: string): Promise<Message>;
|
|
585
|
+
deleteMessage(id: MessageId, forEveryone: boolean): Promise<void>;
|
|
586
|
+
markRead(conversationId: ConversationId, at?: string): Promise<void>;
|
|
587
|
+
setConversationFlags(conversationId: ConversationId, flags: {
|
|
588
|
+
isMuted?: boolean;
|
|
589
|
+
isArchived?: boolean;
|
|
590
|
+
}): Promise<void>;
|
|
591
|
+
addMembers(conversationId: ConversationId, memberIds: readonly UserId[]): Promise<void>;
|
|
592
|
+
removeMember(conversationId: ConversationId, memberId: UserId): Promise<void>;
|
|
593
|
+
setMemberRole(conversationId: ConversationId, memberId: UserId, role: ParticipantRole): Promise<void>;
|
|
594
|
+
/** Cached + in-flight-deduped. N callers for one id make ONE request. */
|
|
595
|
+
getUser(userId: UserId): Promise<UserSummary | null>;
|
|
596
|
+
getUsers(userIds: readonly UserId[]): Promise<ReadonlyMap<UserId, UserSummary>>;
|
|
597
|
+
searchMessages(args: {
|
|
598
|
+
query: string;
|
|
599
|
+
conversationId?: ConversationId | null;
|
|
600
|
+
limit?: number;
|
|
601
|
+
}): Promise<readonly Message[]>;
|
|
602
|
+
invalidateUser(userId: UserId): void;
|
|
603
|
+
}
|
|
604
|
+
declare function createMessagingRepository(options: RepositoryOptions): MessagingRepository;
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* THE MESSAGE STORE — where "the same message twice" is made impossible.
|
|
608
|
+
*
|
|
609
|
+
* A message can reach this store by FOUR different paths, often in any order:
|
|
610
|
+
* the optimistic bubble, the insert's own response, the broadcast, and the
|
|
611
|
+
* Postgres Changes row. Every duplicate bug in every chat product is one of
|
|
612
|
+
* those four arriving out of order. The rules here, each with a regression test:
|
|
613
|
+
*
|
|
614
|
+
* 1. **Identity is `id` OR `clientMessageId`.** The confirmed row and the
|
|
615
|
+
* optimistic bubble are the SAME message; the client-minted key is what
|
|
616
|
+
* proves it. Matching on `(sender, content)` — the tempting shortcut — merges
|
|
617
|
+
* two genuinely different "ok" messages into one.
|
|
618
|
+
* 2. **Merge collapses; it never appends.** A confirmed row that finds an
|
|
619
|
+
* optimistic twin REPLACES it in place, keeping the twin's position so the
|
|
620
|
+
* bubble does not jump.
|
|
621
|
+
* 3. **Older never overwrites newer.** An out-of-order UPDATE carrying an older
|
|
622
|
+
* `edited_at`/`created_at` than the held copy is DROPPED. Without this an
|
|
623
|
+
* edit visibly reverts itself a second later.
|
|
624
|
+
* 4. **Order is `created_at` then `id`.** The unique tiebreaker is not
|
|
625
|
+
* decoration: two messages in the same millisecond otherwise swap places on
|
|
626
|
+
* every re-render.
|
|
627
|
+
* 5. **The active conversation's unread count is forced to zero.** A stale
|
|
628
|
+
* server count must never re-badge a conversation the user is reading — a
|
|
629
|
+
* safety net the origin needed in three separate places, so it lives in one
|
|
630
|
+
* place here.
|
|
631
|
+
*/
|
|
632
|
+
|
|
633
|
+
interface ConversationThread {
|
|
634
|
+
readonly conversationId: ConversationId;
|
|
635
|
+
readonly messages: readonly Message[];
|
|
636
|
+
readonly hasMoreOlder: boolean;
|
|
637
|
+
/** The newest `created_at` held — the reconnect backfill's high-water mark. */
|
|
638
|
+
readonly latestAt: string | null;
|
|
639
|
+
}
|
|
640
|
+
interface MessagingSnapshot {
|
|
641
|
+
readonly conversations: readonly ConversationSummary[];
|
|
642
|
+
readonly hasMoreConversations: boolean;
|
|
643
|
+
/**
|
|
644
|
+
* True once the inbox has actually been READ at least once.
|
|
645
|
+
*
|
|
646
|
+
* An empty list means two completely different things — "you have no
|
|
647
|
+
* conversations" and "we have not looked yet" — and a UI that cannot tell
|
|
648
|
+
* them apart shows a confident "No conversations yet" to someone who has
|
|
649
|
+
* hundreds. That is a screen telling a lie, so the distinction is a fact the
|
|
650
|
+
* store carries rather than something each surface guesses from a timer.
|
|
651
|
+
*/
|
|
652
|
+
readonly hasLoadedConversations: boolean;
|
|
653
|
+
readonly threads: ReadonlyMap<ConversationId, ConversationThread>;
|
|
654
|
+
readonly activeConversationId: ConversationId | null;
|
|
655
|
+
/** Conversations WITH unread, not total unread messages (the origin's semantics). */
|
|
656
|
+
readonly totalUnreadConversations: number;
|
|
657
|
+
}
|
|
658
|
+
interface MessagingStore {
|
|
659
|
+
snapshot(): MessagingSnapshot;
|
|
660
|
+
subscribe(listener: (snapshot: MessagingSnapshot) => void): () => void;
|
|
661
|
+
setConversations(items: readonly ConversationSummary[], hasMore: boolean): void;
|
|
662
|
+
appendConversations(items: readonly ConversationSummary[], hasMore: boolean): void;
|
|
663
|
+
upsertConversation(item: ConversationSummary): void;
|
|
664
|
+
removeConversation(id: ConversationId): void;
|
|
665
|
+
setActiveConversation(id: ConversationId | null): void;
|
|
666
|
+
setThread(id: ConversationId, messages: readonly Message[], args?: {
|
|
667
|
+
hasMoreOlder?: boolean;
|
|
668
|
+
}): void;
|
|
669
|
+
prependOlder(id: ConversationId, messages: readonly Message[], hasMoreOlder: boolean): void;
|
|
670
|
+
/** The one door every incoming message uses. Returns what actually happened. */
|
|
671
|
+
ingest(message: Message): "added" | "merged" | "dropped-stale" | "dropped-duplicate";
|
|
672
|
+
ingestMany(messages: readonly Message[]): void;
|
|
673
|
+
removeMessage(conversationId: ConversationId, messageId: MessageId): void;
|
|
674
|
+
markConversationRead(id: ConversationId): void;
|
|
675
|
+
setUnreadCount(id: ConversationId, count: number): void;
|
|
676
|
+
}
|
|
677
|
+
declare function createMessagingStore(): MessagingStore;
|
|
678
|
+
/**
|
|
679
|
+
* The optimistic row a composer shows the instant Enter is pressed. It carries
|
|
680
|
+
* the outbox's client key, which is the whole reason the confirmed row can find
|
|
681
|
+
* and replace it rather than appearing beside it.
|
|
682
|
+
*/
|
|
683
|
+
declare function optimisticMessage(args: {
|
|
684
|
+
conversationId: ConversationId;
|
|
685
|
+
senderId: UserId;
|
|
686
|
+
organizationId: Message["organizationId"];
|
|
687
|
+
content: string;
|
|
688
|
+
clientMessageId: ClientMessageId;
|
|
689
|
+
kind?: Message["kind"];
|
|
690
|
+
replyToId?: MessageId | null;
|
|
691
|
+
action?: Message["action"];
|
|
692
|
+
attachments?: readonly Message["attachments"][number][];
|
|
693
|
+
references?: readonly Message["references"][number][];
|
|
694
|
+
now?: () => number;
|
|
695
|
+
}): Message;
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* THE MESSAGING ENGINE — the object a host mounts once and never reasons about.
|
|
699
|
+
*
|
|
700
|
+
* It owns the wiring that every chat implementation gets wrong: which channel
|
|
701
|
+
* carries what, what happens on reconnect, which of four arrival paths wins,
|
|
702
|
+
* when a read receipt is written, and what a failed send does. All of it is
|
|
703
|
+
* here so a consumer's job is `engine.send(draft)` and rendering a snapshot.
|
|
704
|
+
*
|
|
705
|
+
* THE RECONNECT RULE, inherited from `@ai-matrx/realtime` and honored here:
|
|
706
|
+
* realtime has NO REPLAY. Every channel this engine opens declares an
|
|
707
|
+
* `onBackfill` door, and those doors re-read from the database. A reconnect
|
|
708
|
+
* without a re-read leaves a permanently wrong thread that looks perfectly
|
|
709
|
+
* healthy — the single most expensive bug class in messaging.
|
|
710
|
+
*/
|
|
711
|
+
|
|
712
|
+
interface EngineDiagnostic {
|
|
713
|
+
readonly level: "info" | "warn" | "error";
|
|
714
|
+
readonly message: string;
|
|
715
|
+
readonly remedy?: string | undefined;
|
|
716
|
+
}
|
|
717
|
+
interface MessagingEngineOptions {
|
|
718
|
+
repository: MessagingRepository;
|
|
719
|
+
manager: RealtimeManager;
|
|
720
|
+
identity: MessagingIdentity;
|
|
721
|
+
outboxStorage?: OutboxStorage | undefined;
|
|
722
|
+
onDiagnostic?: ((event: EngineDiagnostic) => void) | undefined;
|
|
723
|
+
/** Fired for a message from someone else, for notification sinks. */
|
|
724
|
+
onIncoming?: ((message: Message) => void) | undefined;
|
|
725
|
+
conversationPageSize?: number | undefined;
|
|
726
|
+
messagePageSize?: number | undefined;
|
|
727
|
+
}
|
|
728
|
+
interface MessagingEngine {
|
|
729
|
+
readonly store: MessagingStore;
|
|
730
|
+
readonly outbox: Outbox;
|
|
731
|
+
readonly identity: MessagingIdentity;
|
|
732
|
+
/** Load page one of the inbox and start the inbox channel. */
|
|
733
|
+
start(): Promise<void>;
|
|
734
|
+
loadMoreConversations(): Promise<void>;
|
|
735
|
+
/** Open a conversation: load its thread and subscribe to its channel. */
|
|
736
|
+
openConversation(id: ConversationId): Promise<void>;
|
|
737
|
+
closeConversation(id: ConversationId): void;
|
|
738
|
+
loadOlderMessages(id: ConversationId): Promise<void>;
|
|
739
|
+
/** Queue a message. Returns immediately with the optimistic row on screen. */
|
|
740
|
+
send(draft: DraftMessage): ClientMessageId;
|
|
741
|
+
retry(entryId: string): void;
|
|
742
|
+
discard(entryId: string): void;
|
|
743
|
+
editMessage(id: MessageId, conversationId: ConversationId, content: string): Promise<void>;
|
|
744
|
+
deleteMessage(id: MessageId, conversationId: ConversationId, forEveryone: boolean): Promise<void>;
|
|
745
|
+
markRead(id: ConversationId): Promise<void>;
|
|
746
|
+
startDirectConversation(otherUserId: UserId): Promise<ConversationId>;
|
|
747
|
+
dispose(): void;
|
|
748
|
+
}
|
|
749
|
+
declare function createMessagingEngine(options: MessagingEngineOptions): MessagingEngine;
|
|
750
|
+
/** The realtime client session id, exposed for diagnostics surfaces. */
|
|
751
|
+
declare function messagingClientId(): string;
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* `<MessagingProvider>` — the one place an app wires messaging.
|
|
755
|
+
*
|
|
756
|
+
* THE ONE-HOUR INTEGRATION CONTRACT (R7) is this component's job: a host passes
|
|
757
|
+
* a Supabase client, who they are, and their org. Everything else — the
|
|
758
|
+
* repository, the realtime manager and its channels, the store, the outbox and
|
|
759
|
+
* its durable storage, the action registry, the AI surface — is constructed and
|
|
760
|
+
* owned here.
|
|
761
|
+
*
|
|
762
|
+
* What a host may inject is IDENTITY and app chrome: session source, agent ids,
|
|
763
|
+
* a navigator for reference cards, a notification sink, action handlers. What a
|
|
764
|
+
* host must NEVER have to inject is behavior — if you find yourself catching a
|
|
765
|
+
* package error, retrying a package call, or branching on a package quirk in
|
|
766
|
+
* host code, that logic belongs in here (C22).
|
|
767
|
+
*
|
|
768
|
+
* 🚨 THE PROVIDER'S LIFETIME IS `client` + `userId` + `organizationId`, AND
|
|
769
|
+
* NOTHING ELSE. Hosts pass fresh object literals for `agents`, `actions`, and
|
|
770
|
+
* `onDiagnostic` on every render; keying the engine on those identities would
|
|
771
|
+
* tear down and rebuild every channel on every parent re-render — a permanent
|
|
772
|
+
* resubscribe loop that looks exactly like "realtime is broken".
|
|
773
|
+
*/
|
|
774
|
+
|
|
775
|
+
interface MessagingHost {
|
|
776
|
+
readonly engine: MessagingEngine;
|
|
777
|
+
readonly actions: ActionRegistry;
|
|
778
|
+
readonly ai: MessagingAi | null;
|
|
779
|
+
readonly identity: MessagingIdentity;
|
|
780
|
+
readonly openReference: ((reference: MatrxReference) => void) | null;
|
|
781
|
+
}
|
|
782
|
+
interface MessagingProviderProps {
|
|
783
|
+
/** Any Supabase client. `null` while the host is still resolving one. */
|
|
784
|
+
client: MessagingSupabaseClient | null | undefined;
|
|
785
|
+
/** The signed-in user. `null` while auth is still resolving. */
|
|
786
|
+
userId: string | null | undefined;
|
|
787
|
+
/** The active organization. Every write carries it (R5). */
|
|
788
|
+
organizationId: string | null | undefined;
|
|
789
|
+
/**
|
|
790
|
+
* The Matrx API transport for AI features. Omit to run messaging without AI;
|
|
791
|
+
* the AI actions then do not render at all (never a dead button).
|
|
792
|
+
*/
|
|
793
|
+
transport?: MatrxTransport | undefined;
|
|
794
|
+
/** Which database agent backs each AI capability. */
|
|
795
|
+
agents?: MessagingAgents | undefined;
|
|
796
|
+
/** Handlers for actionable messages. Registered once per handler identity. */
|
|
797
|
+
actions?: readonly ActionHandler<never>[] | undefined;
|
|
798
|
+
/** Called when a reference card is clicked. Omit and cards render inert-but-labeled. */
|
|
799
|
+
onOpenReference?: ((reference: MatrxReference) => void) | undefined;
|
|
800
|
+
/** A message arrived from someone else — for a toast, sound, or badge. */
|
|
801
|
+
onIncomingMessage?: ((message: Message) => void) | undefined;
|
|
802
|
+
/** Background failures, with a remedy. Defaults to a console sink. */
|
|
803
|
+
onDiagnostic?: ((event: EngineDiagnostic) => void) | undefined;
|
|
804
|
+
/** Re-resolve a session after one goes missing. Enables the single retry. */
|
|
805
|
+
resolveSession?: SessionResolver | undefined;
|
|
806
|
+
/** Override outbox persistence. Defaults to localStorage, then memory. */
|
|
807
|
+
outboxStorage?: OutboxStorage | undefined;
|
|
808
|
+
children: ReactNode;
|
|
809
|
+
}
|
|
810
|
+
declare function MessagingProvider(props: MessagingProviderProps): ReactNode;
|
|
811
|
+
/** The host, or `null` while auth/org are still resolving. */
|
|
812
|
+
declare function useMessagingHost(): MessagingHost | null;
|
|
813
|
+
/**
|
|
814
|
+
* The host, or a thrown error naming the fix. Used by components that cannot
|
|
815
|
+
* render anything meaningful without it — they are inside `<MessagingInbox>`,
|
|
816
|
+
* which itself renders a loading state until the host exists.
|
|
817
|
+
*/
|
|
818
|
+
declare function useRequiredMessagingHost(): MessagingHost;
|
|
819
|
+
/** The whole messaging snapshot, re-rendering only when it actually changes. */
|
|
820
|
+
declare function useMessagingSnapshot(): MessagingSnapshot | null;
|
|
821
|
+
|
|
822
|
+
interface UseConversationsResult {
|
|
823
|
+
conversations: readonly ConversationSummary[];
|
|
824
|
+
hasMore: boolean;
|
|
825
|
+
/** True only on the FIRST load; a refresh must not blank the list. */
|
|
826
|
+
isInitialLoading: boolean;
|
|
827
|
+
totalUnreadConversations: number;
|
|
828
|
+
loadMore: () => void;
|
|
829
|
+
select: (id: ConversationId) => void;
|
|
830
|
+
activeConversationId: ConversationId | null;
|
|
831
|
+
startDirect: (otherUserId: UserId) => Promise<ConversationId>;
|
|
832
|
+
}
|
|
833
|
+
declare function useConversations(): UseConversationsResult;
|
|
834
|
+
interface UseConversationResult {
|
|
835
|
+
summary: ConversationSummary | null;
|
|
836
|
+
thread: ConversationThread | null;
|
|
837
|
+
messages: readonly Message[];
|
|
838
|
+
participants: readonly UserSummary[];
|
|
839
|
+
isLoading: boolean;
|
|
840
|
+
hasMoreOlder: boolean;
|
|
841
|
+
loadOlder: () => void;
|
|
842
|
+
pending: readonly OutboxEntry[];
|
|
843
|
+
retry: (entryId: string) => void;
|
|
844
|
+
discard: (entryId: string) => void;
|
|
845
|
+
edit: (messageId: Message["id"], content: string) => Promise<void>;
|
|
846
|
+
remove: (messageId: Message["id"], forEveryone: boolean) => Promise<void>;
|
|
847
|
+
}
|
|
848
|
+
declare function useConversation(id: ConversationId | null): UseConversationResult;
|
|
849
|
+
interface UseComposerResult {
|
|
850
|
+
value: string;
|
|
851
|
+
setValue: (value: string) => void;
|
|
852
|
+
canSend: boolean;
|
|
853
|
+
send: () => void;
|
|
854
|
+
/** Call on EVERY keystroke; the typing lifecycle is entirely inside. */
|
|
855
|
+
onKeystroke: () => void;
|
|
856
|
+
replyTo: Message | null;
|
|
857
|
+
setReplyTo: (message: Message | null) => void;
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* The composer's state. `value` lives here so a draft SURVIVES a re-render and
|
|
861
|
+
* a conversation switch-and-back — losing a half-typed message on a re-render
|
|
862
|
+
* is the same defect as losing a sent one, one step earlier.
|
|
863
|
+
*/
|
|
864
|
+
declare function useComposer(conversationId: ConversationId | null): UseComposerResult;
|
|
865
|
+
interface UseTypistsResult {
|
|
866
|
+
/** Ready-made label: "Ana is typing…". `null` when nobody is. */
|
|
867
|
+
label: string | null;
|
|
868
|
+
userIds: readonly UserId[];
|
|
869
|
+
}
|
|
870
|
+
declare function useTypists(conversationId: ConversationId | null, participants: readonly UserSummary[]): UseTypistsResult;
|
|
871
|
+
declare function useOnlineUserIds(conversationId: ConversationId | null): ReadonlySet<string>;
|
|
872
|
+
interface UseMessagingAiResult {
|
|
873
|
+
available: readonly AiCapability[];
|
|
874
|
+
isRunning: boolean;
|
|
875
|
+
result: AiResult | null;
|
|
876
|
+
error: string | null;
|
|
877
|
+
run: (capability: AiCapability, args?: {
|
|
878
|
+
instruction?: string;
|
|
879
|
+
}) => void;
|
|
880
|
+
clear: () => void;
|
|
881
|
+
}
|
|
882
|
+
declare function useMessagingAi(conversationId: ConversationId | null): UseMessagingAiResult;
|
|
883
|
+
interface UseMessageActionResult {
|
|
884
|
+
choices: ReturnType<ActionRegistry["choicesFor"]>;
|
|
885
|
+
receipt: ActionReceipt | null;
|
|
886
|
+
isRunning: boolean;
|
|
887
|
+
execute: (choice: string) => void;
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* An actionable message's chips. The double-click guard is in the registry, not
|
|
891
|
+
* here — this hook only reflects it, which is why two components rendering the
|
|
892
|
+
* same action cannot double-apply it either.
|
|
893
|
+
*/
|
|
894
|
+
declare function useMessageAction(message: Message): UseMessageActionResult;
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* THE DROP-IN SURFACES (R6/R7).
|
|
898
|
+
*
|
|
899
|
+
* `<MessagingInbox />` is the whole product in one element. The paired pieces
|
|
900
|
+
* are exported too, for a host that wants the list in a sidebar and the thread
|
|
901
|
+
* on a route — but composing pages is the host's job, and building a product
|
|
902
|
+
* is not.
|
|
903
|
+
*
|
|
904
|
+
* Every screen here obeys the same three rules: a control is absent or honest
|
|
905
|
+
* (never dead or disabled-looking with no reason); loading is a skeleton of the
|
|
906
|
+
* real thing, never the word "Loading…"; and a degraded connection SAYS so
|
|
907
|
+
* rather than quietly showing a stale thread.
|
|
908
|
+
*/
|
|
909
|
+
|
|
910
|
+
interface ConversationListProps {
|
|
911
|
+
onSelect?: (id: ConversationId) => void;
|
|
912
|
+
onNewConversation?: () => void;
|
|
913
|
+
className?: string;
|
|
914
|
+
}
|
|
915
|
+
declare function ConversationList(props: ConversationListProps): ReactNode;
|
|
916
|
+
interface ConversationViewProps {
|
|
917
|
+
conversationId: ConversationId | null;
|
|
918
|
+
onBack?: () => void;
|
|
919
|
+
className?: string;
|
|
920
|
+
}
|
|
921
|
+
declare function ConversationView(props: ConversationViewProps): ReactNode;
|
|
922
|
+
declare function MessageBubble(props: {
|
|
923
|
+
message: Message;
|
|
924
|
+
isMine: boolean;
|
|
925
|
+
isLast: boolean;
|
|
926
|
+
onRetry?: (entryId: string) => void;
|
|
927
|
+
onReply?: (message: Message) => void;
|
|
928
|
+
failedEntryId?: string | null;
|
|
929
|
+
}): ReactNode;
|
|
930
|
+
/**
|
|
931
|
+
* An actionable message's chips. An action this build cannot honor renders
|
|
932
|
+
* NOTHING — never a generic button that offers something nobody can deliver.
|
|
933
|
+
*/
|
|
934
|
+
declare function MessageActionChips(props: {
|
|
935
|
+
message: Message;
|
|
936
|
+
}): ReactNode;
|
|
937
|
+
declare function Composer(props: {
|
|
938
|
+
conversationId: ConversationId | null;
|
|
939
|
+
}): ReactNode;
|
|
940
|
+
interface MessagingInboxProps {
|
|
941
|
+
/** Start on a specific conversation (e.g. from a route param). */
|
|
942
|
+
conversationId?: ConversationId | null;
|
|
943
|
+
onConversationChange?: (id: ConversationId | null) => void;
|
|
944
|
+
onNewConversation?: () => void;
|
|
945
|
+
className?: string;
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* THE WHOLE PRODUCT IN ONE ELEMENT — step 2 of the one-hour contract (R7).
|
|
949
|
+
*
|
|
950
|
+
* On a phone the two panes become one pane that swaps, driven by `data-pane`;
|
|
951
|
+
* a 340px sidebar beside a thread on a 390px screen is the layout every chat
|
|
952
|
+
* app gets wrong exactly once.
|
|
953
|
+
*/
|
|
954
|
+
declare function MessagingInbox(props: MessagingInboxProps): ReactNode;
|
|
955
|
+
|
|
956
|
+
declare function Avatar(props: {
|
|
957
|
+
name: string;
|
|
958
|
+
imageUrl?: string | null;
|
|
959
|
+
seed?: string;
|
|
960
|
+
small?: boolean;
|
|
961
|
+
online?: boolean;
|
|
962
|
+
}): React.ReactElement;
|
|
963
|
+
/**
|
|
964
|
+
* The delivery tick. Every state has a DISTINCT glyph and a distinct label —
|
|
965
|
+
* a spinner that means both "sending" and "failed" is the lie this prevents.
|
|
966
|
+
*/
|
|
967
|
+
declare function DeliveryTick(props: {
|
|
968
|
+
state: DeliveryState;
|
|
969
|
+
}): React.ReactElement | null;
|
|
970
|
+
/**
|
|
971
|
+
* A reference card. NO DEAD ENDS: when the host wired `onOpenReference` it is a
|
|
972
|
+
* button that opens; when it did not, it renders as a labeled, non-interactive
|
|
973
|
+
* card with a title explaining why — never a button that does nothing.
|
|
974
|
+
*/
|
|
975
|
+
declare function ReferenceCard(props: {
|
|
976
|
+
reference: MatrxReference;
|
|
977
|
+
onOpen?: ((reference: MatrxReference) => void) | null;
|
|
978
|
+
}): React.ReactElement;
|
|
979
|
+
declare function AgentTag(): React.ReactElement;
|
|
980
|
+
declare function TypingDots(): React.ReactElement;
|
|
981
|
+
/**
|
|
982
|
+
* Loading states are SKELETONS of the real thing, never the word "Loading…".
|
|
983
|
+
* The shape tells the user what is coming.
|
|
984
|
+
*/
|
|
985
|
+
declare function ConversationSkeleton(props: {
|
|
986
|
+
rows?: number;
|
|
987
|
+
}): React.ReactElement;
|
|
988
|
+
declare function EmptyState(props: {
|
|
989
|
+
title: string;
|
|
990
|
+
body?: string;
|
|
991
|
+
action?: React.ReactNode;
|
|
992
|
+
}): React.ReactElement;
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* INLINED SVGs. NO ICON LIBRARY, EVER (C19).
|
|
996
|
+
*
|
|
997
|
+
* A package that depends on an icon library forces its version on every
|
|
998
|
+
* consumer and ships a whole set to deliver a dozen glyphs. These are drawn in
|
|
999
|
+
* the Lucide idiom (24×24, 2px stroke, round caps) so they sit correctly beside
|
|
1000
|
+
* a host's Lucide icons, and cost a fraction of a percent of the bundle.
|
|
1001
|
+
*
|
|
1002
|
+
* Every icon inherits `currentColor` and takes its size from `1em` — they are
|
|
1003
|
+
* text, and they scale with the text they sit next to.
|
|
1004
|
+
*/
|
|
1005
|
+
|
|
1006
|
+
type IconProps = Omit<SVGProps<SVGSVGElement>, "children">;
|
|
1007
|
+
declare const SendIcon: (props: IconProps) => React.ReactElement;
|
|
1008
|
+
declare const PaperclipIcon: (props: IconProps) => React.ReactElement;
|
|
1009
|
+
declare const SparklesIcon: (props: IconProps) => React.ReactElement;
|
|
1010
|
+
declare const CheckIcon: (props: IconProps) => React.ReactElement;
|
|
1011
|
+
declare const DoubleCheckIcon: (props: IconProps) => React.ReactElement;
|
|
1012
|
+
declare const ClockIcon: (props: IconProps) => React.ReactElement;
|
|
1013
|
+
declare const AlertIcon: (props: IconProps) => React.ReactElement;
|
|
1014
|
+
declare const SearchIcon: (props: IconProps) => React.ReactElement;
|
|
1015
|
+
declare const PlusIcon: (props: IconProps) => React.ReactElement;
|
|
1016
|
+
declare const CloseIcon: (props: IconProps) => React.ReactElement;
|
|
1017
|
+
declare const ChevronLeftIcon: (props: IconProps) => React.ReactElement;
|
|
1018
|
+
declare const ReplyIcon: (props: IconProps) => React.ReactElement;
|
|
1019
|
+
declare const LinkIcon: (props: IconProps) => React.ReactElement;
|
|
1020
|
+
declare const BotIcon: (props: IconProps) => React.ReactElement;
|
|
1021
|
+
declare const UsersIcon: (props: IconProps) => React.ReactElement;
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* EFFECTIVE-ACTOR RESOLUTION — who a message is FROM, versus who wrote the row.
|
|
1025
|
+
*
|
|
1026
|
+
* `sender_id` is the AUDIT PRINCIPAL: the session the write happened under. It
|
|
1027
|
+
* must never change, and it must never be what the UI renders when an agent
|
|
1028
|
+
* acted through a human's session. Otherwise an automated message wears a
|
|
1029
|
+
* colleague's face and name — the failure this module exists to prevent (R4
|
|
1030
|
+
* puts agents in conversations, which makes it the normal case, not an edge).
|
|
1031
|
+
*
|
|
1032
|
+
* The effective actor is declared in the message's own metadata by whoever sent
|
|
1033
|
+
* it. Nothing here infers an agent from heuristics on the content.
|
|
1034
|
+
*/
|
|
1035
|
+
|
|
1036
|
+
interface ActorPresentation {
|
|
1037
|
+
readonly displayName: string;
|
|
1038
|
+
readonly avatarUrl: string | null;
|
|
1039
|
+
/** True when the message was authored by an agent, not the human principal. */
|
|
1040
|
+
readonly isAgent: boolean;
|
|
1041
|
+
/** Set only for an agent: the human whose session it acted under. */
|
|
1042
|
+
readonly onBehalfOfName: string | null;
|
|
1043
|
+
/** The audit principal. Always the row's `sender_id`. Never rewritten. */
|
|
1044
|
+
readonly principalUserId: Message["senderId"];
|
|
1045
|
+
}
|
|
1046
|
+
declare function resolveActor(message: Message, sender: UserSummary | null): ActorPresentation;
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* IN-FLIGHT DEDUP + TTL CACHE — the cure for the request storm.
|
|
1050
|
+
*
|
|
1051
|
+
* The incident (matrx-frontend, 2026-08-21): every conversation row rendered
|
|
1052
|
+
* called the profile lookup for its participants, every one of those calls hit
|
|
1053
|
+
* the network independently, the transport wobbled, and the app captured
|
|
1054
|
+
* **909 errors in 0.6 seconds**. The bug was never the transport — it was that
|
|
1055
|
+
* N identical concurrent reads were N requests.
|
|
1056
|
+
*
|
|
1057
|
+
* Two guarantees, and both are load-bearing:
|
|
1058
|
+
*
|
|
1059
|
+
* 1. **Concurrent callers for the same key share ONE promise.** The second
|
|
1060
|
+
* caller does not start a second request; it awaits the first.
|
|
1061
|
+
* 2. **A failure does not poison the cache.** The rejected promise is evicted
|
|
1062
|
+
* the moment it settles, so the next caller retries instead of inheriting a
|
|
1063
|
+
* permanent error. (The naive version caches the rejection and the surface
|
|
1064
|
+
* stays broken until reload — the reason this has a named test.)
|
|
1065
|
+
*
|
|
1066
|
+
* A resolved value is held for `ttlMs` and then re-read. This is a READ cache
|
|
1067
|
+
* only: writes go straight through and invalidate.
|
|
1068
|
+
*/
|
|
1069
|
+
interface ReadCacheOptions {
|
|
1070
|
+
/** How long a resolved value is served without a re-read. */
|
|
1071
|
+
ttlMs: number;
|
|
1072
|
+
/** Injected clock — the tests must not sleep. */
|
|
1073
|
+
now?: () => number;
|
|
1074
|
+
/** Bound on retained entries; the oldest is evicted first. */
|
|
1075
|
+
maxEntries?: number;
|
|
1076
|
+
}
|
|
1077
|
+
interface ReadCache<T> {
|
|
1078
|
+
/** Read through the cache. Identical concurrent keys share one call. */
|
|
1079
|
+
read(key: string, load: () => Promise<T>): Promise<T>;
|
|
1080
|
+
/** Drop one key (after a write that changes it). */
|
|
1081
|
+
invalidate(key: string): void;
|
|
1082
|
+
/** Drop everything (sign-out, org switch). */
|
|
1083
|
+
clear(): void;
|
|
1084
|
+
/** Diagnostics: how many resolved entries are held. */
|
|
1085
|
+
size(): number;
|
|
1086
|
+
}
|
|
1087
|
+
declare function createReadCache<T>(options: ReadCacheOptions): ReadCache<T>;
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* THE MESSAGING CHANNEL NAMESPACES.
|
|
1091
|
+
*
|
|
1092
|
+
* D5: zero hand-rolled `.channel(` in this package. Every topic is declared
|
|
1093
|
+
* through `@ai-matrx/realtime`'s registry, which refuses a colliding
|
|
1094
|
+
* re-declaration and hands every connection attempt a unique instance topic.
|
|
1095
|
+
*
|
|
1096
|
+
* TWO channels, deliberately — not four. The origin ran `messages:`, `typing:`,
|
|
1097
|
+
* `presence:` and a global list channel as four independent subscriptions, and
|
|
1098
|
+
* three of the four hardening items were about them fighting each other. Here:
|
|
1099
|
+
*
|
|
1100
|
+
* - `messaging-inbox` (per user): the conversation list's own feed.
|
|
1101
|
+
* - `messaging-conversation` (per conversation): messages, presence, AND
|
|
1102
|
+
* typing on ONE channel, because they are one room. Sharing the channel is
|
|
1103
|
+
* what makes "the person typing is also present" true by construction
|
|
1104
|
+
* instead of by two subscriptions agreeing.
|
|
1105
|
+
*
|
|
1106
|
+
* `defineChannelNamespace` throws on a conflicting re-declaration, so these are
|
|
1107
|
+
* created lazily through a `globalThis` slot: with dual ESM/CJS output the
|
|
1108
|
+
* module can be evaluated twice in one process, and a second identical
|
|
1109
|
+
* declaration must be a no-op rather than a crash.
|
|
1110
|
+
*/
|
|
1111
|
+
|
|
1112
|
+
declare function inboxTopic(userId: UserId): string;
|
|
1113
|
+
declare function conversationTopic(conversationId: ConversationId): string;
|
|
1114
|
+
/** Broadcast event names. One place, so a sender and a receiver cannot drift. */
|
|
1115
|
+
declare const MESSAGING_EVENTS: {
|
|
1116
|
+
/** A freshly sent message, broadcast beside the Postgres Changes row so a
|
|
1117
|
+
* receiver gets it on whichever path arrives first (both are deduped). */
|
|
1118
|
+
readonly message: "mx.message";
|
|
1119
|
+
/** A message edited or soft-deleted. */
|
|
1120
|
+
readonly messageUpdated: "mx.message.updated";
|
|
1121
|
+
/** An action receipt, so every viewer's chip settles at once. */
|
|
1122
|
+
readonly actionSettled: "mx.action.settled";
|
|
1123
|
+
};
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* ONE error classification for the whole package (C22).
|
|
1127
|
+
*
|
|
1128
|
+
* The banned alternative is the host catching a PostgREST error and deciding
|
|
1129
|
+
* for itself whether it was an auth blip, a permission denial, or a real bug —
|
|
1130
|
+
* which is how the same three-branch `if` ends up copy-pasted into every
|
|
1131
|
+
* consumer and drifts. Everything this package throws is a `MessagingError`
|
|
1132
|
+
* with a stable `code`, and the two that hosts genuinely treat differently
|
|
1133
|
+
* (`session-unavailable`, `forbidden`) say so in the type.
|
|
1134
|
+
*
|
|
1135
|
+
* The incident behind `session-unavailable`: a DM reader mounted before the
|
|
1136
|
+
* access token existed, every RPC came back `42501`, and the app captured 909
|
|
1137
|
+
* errors in 0.6 seconds. A missing session is a NORMAL lifecycle moment — it
|
|
1138
|
+
* warns and retries; it is never a red error.
|
|
1139
|
+
*/
|
|
1140
|
+
type MessagingErrorCode = "session-unavailable" | "forbidden" | "not-found" | "conflict" | "transport" | "invalid-response" | "misconfigured" | "unknown";
|
|
1141
|
+
declare class MessagingError extends Error {
|
|
1142
|
+
readonly code: MessagingErrorCode;
|
|
1143
|
+
/** The remedy. Nothing fails silently, and nothing fails without saying what to do. */
|
|
1144
|
+
readonly remedy: string;
|
|
1145
|
+
readonly cause?: unknown;
|
|
1146
|
+
constructor(code: MessagingErrorCode, message: string, remedy: string, cause?: unknown);
|
|
1147
|
+
/** True when retrying after the host re-establishes a session is the fix. */
|
|
1148
|
+
get isRetryable(): boolean;
|
|
1149
|
+
}
|
|
1150
|
+
interface PostgrestErrorLike {
|
|
1151
|
+
message: string;
|
|
1152
|
+
code?: string | undefined;
|
|
1153
|
+
details?: string | null | undefined;
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* THE ONE PLACE a raw PostgREST/transport error becomes a typed one. Called at
|
|
1157
|
+
* every boundary in `repository.ts`; nothing else in the package inspects a raw
|
|
1158
|
+
* error, and no consumer should ever have to.
|
|
1159
|
+
*/
|
|
1160
|
+
declare function normalizeMessagingError(error: PostgrestErrorLike | Error | unknown, operation: string): MessagingError;
|
|
1161
|
+
/** A response whose SHAPE is wrong — the ingress boundary, not the wire. */
|
|
1162
|
+
declare function invalidResponse(operation: string, detail: string): MessagingError;
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* Display formatting. In the package because every consumer otherwise rebuilds
|
|
1166
|
+
* it slightly differently and the inbox and the thread disagree about what
|
|
1167
|
+
* "yesterday" means.
|
|
1168
|
+
*
|
|
1169
|
+
* Every function takes an explicit `now` so the tests do not depend on the
|
|
1170
|
+
* clock, and none of them touches `Intl` with an implicit locale — a package
|
|
1171
|
+
* that silently formats in the build machine's locale is a bug that only shows
|
|
1172
|
+
* up for users in other timezones.
|
|
1173
|
+
*/
|
|
1174
|
+
|
|
1175
|
+
declare function isSameDay(a: Date, b: Date): boolean;
|
|
1176
|
+
/** Compact stamp for a conversation row: `9:41 AM`, `Yesterday`, `Mar 4`. */
|
|
1177
|
+
declare function formatConversationTime(isoString: string | null, now?: number, locale?: string): string;
|
|
1178
|
+
/** The stamp under a bubble. */
|
|
1179
|
+
declare function formatMessageTime(isoString: string, locale?: string): string;
|
|
1180
|
+
/** The separator between day groups in a thread. */
|
|
1181
|
+
declare function formatDateSeparator(isoString: string, now?: number, locale?: string): string;
|
|
1182
|
+
declare function getInitials(name: string): string;
|
|
1183
|
+
/**
|
|
1184
|
+
* A stable palette index for an avatar with no image. Deterministic on the id,
|
|
1185
|
+
* so the same person is the same color on every device and every reload — a
|
|
1186
|
+
* random color per render is a surprisingly loud bug.
|
|
1187
|
+
*/
|
|
1188
|
+
declare function avatarPaletteIndex(seed: string, buckets?: number): number;
|
|
1189
|
+
/**
|
|
1190
|
+
* Group consecutive messages by the same sender within a window, the way every
|
|
1191
|
+
* good chat UI does — one avatar and one name per burst.
|
|
1192
|
+
*/
|
|
1193
|
+
interface MessageGroup {
|
|
1194
|
+
readonly senderId: UserId;
|
|
1195
|
+
readonly messages: readonly Message[];
|
|
1196
|
+
readonly dateSeparator: string | null;
|
|
1197
|
+
}
|
|
1198
|
+
declare function groupMessages(messages: readonly Message[], args?: {
|
|
1199
|
+
now?: number;
|
|
1200
|
+
windowMs?: number;
|
|
1201
|
+
locale?: string;
|
|
1202
|
+
}): readonly MessageGroup[];
|
|
1203
|
+
/** "Ana is typing…" / "Ana and Bo are typing…" / "3 people are typing…" */
|
|
1204
|
+
declare function formatTypists(names: readonly string[]): string | null;
|
|
1205
|
+
declare function participantNames(participants: readonly UserSummary[], excluding: UserId): readonly string[];
|
|
1206
|
+
/** "Active now" / "Active 5m ago" — presence, said honestly. */
|
|
1207
|
+
declare function formatLastSeen(lastSeenMs: number | null, now?: number): string;
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* THE INGRESS BOUNDARY — where a database row becomes a domain object.
|
|
1211
|
+
*
|
|
1212
|
+
* Everything crossing this line is validated, because the alternative is a
|
|
1213
|
+
* screen that lies. A conversation row whose `participants` aggregate came back
|
|
1214
|
+
* as a string instead of an array used to render an inbox entry named
|
|
1215
|
+
* `undefined` with a working click target; refusing the row here turns a silent
|
|
1216
|
+
* wrong screen into a loud, remediable error (nothing fails silently).
|
|
1217
|
+
*
|
|
1218
|
+
* The rules:
|
|
1219
|
+
* - A missing REQUIRED field is a refusal, never a `?? ""` that renders blank.
|
|
1220
|
+
* - A missing OPTIONAL field is a null, never an invented default.
|
|
1221
|
+
* - `metadata` / `action_data` are host-and-DB-owned JSON: typed over `unknown`
|
|
1222
|
+
* and narrowed by shape, never cast (the data 0.2.1 `Json = unknown` lesson).
|
|
1223
|
+
*/
|
|
1224
|
+
|
|
1225
|
+
declare function projectUserSummary(row: Record<string, unknown>): UserSummary;
|
|
1226
|
+
declare function projectMessageAction(value: unknown): MessageAction | null;
|
|
1227
|
+
declare function projectMessage(row: Record<string, unknown>, fallbackOrganizationId: OrganizationId): Message;
|
|
1228
|
+
declare function projectParticipantRole(value: unknown): ParticipantRole;
|
|
1229
|
+
declare function projectConversationSummary(row: Record<string, unknown>, viewerId: UserId, fallbackOrganizationId: OrganizationId): ConversationSummary;
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* MATRX REFERENCES — a message can name a platform entity, and everything it
|
|
1233
|
+
* names must OPEN. No dead ends.
|
|
1234
|
+
*
|
|
1235
|
+
* Two transports, both supported because both already exist in the wild:
|
|
1236
|
+
*
|
|
1237
|
+
* 1. **Structured**, in `metadata.references` — what this package writes.
|
|
1238
|
+
* 2. **Fenced**, as a ```matrx block in the content — the platform's
|
|
1239
|
+
* reference-fence protocol, which arrives from senders outside messaging.
|
|
1240
|
+
*
|
|
1241
|
+
* Two failures this module exists to prevent:
|
|
1242
|
+
*
|
|
1243
|
+
* - **A raw fence rendered as a code block.** The reader sees JSON. `splitText`
|
|
1244
|
+
* is what lets the renderer draw a card instead.
|
|
1245
|
+
* - **A fence leaking into a preview.** The inbox row and the desktop
|
|
1246
|
+
* notification are plain text; pasting the JSON there is the same defect one
|
|
1247
|
+
* layer down. `summarizeText` is the one collapse, used by both.
|
|
1248
|
+
*/
|
|
1249
|
+
|
|
1250
|
+
type TextSegment = {
|
|
1251
|
+
readonly type: "text";
|
|
1252
|
+
readonly value: string;
|
|
1253
|
+
} | {
|
|
1254
|
+
readonly type: "reference";
|
|
1255
|
+
readonly reference: MatrxReference;
|
|
1256
|
+
};
|
|
1257
|
+
/** Every reference a message carries, from both transports, de-duplicated. */
|
|
1258
|
+
declare function extractReferences(content: string, structured?: readonly MatrxReference[]): readonly MatrxReference[];
|
|
1259
|
+
/**
|
|
1260
|
+
* Split content into renderable segments. The renderer draws text for `text`
|
|
1261
|
+
* and a live card for `reference`; a fence therefore CANNOT reach the screen as
|
|
1262
|
+
* a code block.
|
|
1263
|
+
*/
|
|
1264
|
+
declare function splitText(content: string): readonly TextSegment[];
|
|
1265
|
+
/**
|
|
1266
|
+
* ONE plain-text collapse, used by the inbox preview AND the notification body.
|
|
1267
|
+
* A fence becomes its human label; it never reaches either as raw JSON.
|
|
1268
|
+
*/
|
|
1269
|
+
declare function summarizeText(content: string, maxLength?: number): string;
|
|
1270
|
+
/** Serialize picked references into a fence the platform's other readers accept. */
|
|
1271
|
+
declare function composeFence(references: readonly MatrxReference[]): string;
|
|
1272
|
+
|
|
1273
|
+
export { type ActionChoice, type ActionContext, type ActionHandler, type ActionOutcome, type ActionReceipt, type ActionRegistry, type ActorPresentation, AgentTag, type AiCapability, type AiResult, AlertIcon, type Attachment, Avatar, BotIcon, CheckIcon, ChevronLeftIcon, type ClientMessageId, ClockIcon, CloseIcon, Composer, type Conversation, type ConversationCursor, type ConversationId, ConversationList, type ConversationListProps, ConversationSkeleton, type ConversationSummary, type ConversationThread, type ConversationType, ConversationView, type ConversationViewProps, type DeliveryState, DeliveryTick, DoubleCheckIcon, type DraftMessage, EmptyState, type EngineDiagnostic, type JsonObject, type JsonValue, LinkIcon, MESSAGING_EVENTS, MESSAGING_SCHEMA, type MatrxReference, type Message, type MessageAction, MessageActionChips, MessageBubble, type MessageCursor, type MessageGroup, type MessageId, type MessageKind, type MessagingAgents, type MessagingAi, type MessagingAiOptions, type MessagingEngine, type MessagingEngineOptions, MessagingError, type MessagingErrorCode, type MessagingHost, type MessagingIdentity, MessagingInbox, type MessagingInboxProps, MessagingProvider, type MessagingProviderProps, type MessagingRepository, type MessagingSnapshot, type MessagingStore, type OrganizationId, type Outbox, type OutboxEntry, type OutboxOptions, type OutboxStorage, type Page, PaperclipIcon, type Participant, type ParticipantRole, PlusIcon, type PostgrestFilterLike, type PostgrestLikeResponse, type PostgrestTableLike, RPCS, type ReadCache, type ReadCacheOptions, ReferenceCard, ReplyIcon, type RepositoryOptions, type SchemaLike, SearchIcon, SendIcon, type SessionResolver, SparklesIcon, type SupabaseLike, TABLES, type TextSegment, TypingDots, type UseComposerResult, type UseConversationResult, type UseConversationsResult, type UseMessageActionResult, type UseMessagingAiResult, type UseTypistsResult, type UserId, type UserSummary, UsersIcon, asClientMessageId, asConversationId, asMessageId, asOrganizationId, asUserId, avatarPaletteIndex, composeFence, conversationTopic, createActionRegistry, createMemoryOutboxStorage, createMessagingAi, createMessagingEngine, createMessagingRepository, createMessagingStore, createOutbox, createReadCache, createWebOutboxStorage, extractReferences, formatConversationTime, formatDateSeparator, formatLastSeen, formatMessageTime, formatTypists, getInitials, groupMessages, inboxTopic, invalidResponse, isSameDay, messagingClientId, normalizeMessagingError, optimisticMessage, participantNames, projectConversationSummary, projectMessage, projectMessageAction, projectParticipantRole, projectUserSummary, resolveActor, splitText, summarizeText, useComposer, useConversation, useConversations, useMessageAction, useMessagingAi, useMessagingHost, useMessagingSnapshot, useOnlineUserIds, useRequiredMessagingHost, useTypists };
|