@pmate/chat-sdk 0.12.0-next.64 → 0.12.0-next.65

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/README.md CHANGED
@@ -173,7 +173,12 @@ route entry and startup before the first read; it is not a cache-source indicato
173
173
  `getSnapshot()` and `subscribe(listener)` provide conversation summaries, aggregate
174
174
  unread counts and connection state. Unknown unread counts remain `undefined`.
175
175
  Subscription always emits the current snapshot first, including with
176
- `{fields:["messages","unread"]}`, then meaningful changes only. Object references
176
+ `{fields:["messages","unread"]}`, then meaningful changes only. Messages expose `kind`, `text`, optional `image`,
177
+ `voice`, `quote`, explicit `richContent`, semantic `reactions` and reading/delivery
178
+ state. Apps do not inspect transport bodies or control-event revisions. Group
179
+ causal version numbers and raw message records remain under `advanced`.
180
+ `observeMessages` and `markRead` resolve with `void` after durable submission.
181
+ Object references
177
182
  remain stable for unchanged results. Framework adapters can use
178
183
  `{emitCurrent:false}`; React can import `useChatSnapshot(chat, threadId)` from
179
184
  `@pmate/chat-sdk`. Establishing a subscription never starts a read or marks it read.
@@ -230,7 +235,8 @@ contract for compatibility. New Apps should import from `@pmate/chat-sdk/chat`
230
235
  or the package root. Do not mix the two snapshot contracts.
231
236
 
232
237
  For migration, replace `subscribe(id, () => render(getSnapshot(id).timeline))`
233
- with `subscribe(id, state => render(state.messages))`; replace conversation
238
+ with `subscribe(id, state => render(state.messages))`; replace
239
+ `item.message.body` parsing with `item.kind/text/image/voice/quote`; replace conversation
234
240
  objects and App-supplied recipients with `threadId`; await `getMessages` before
235
241
  opening a cold thread instead of checking `page.complete`. Replace `initialize()`
236
242
  with `start()`; a shared connection is initialized without another connect call.
package/lib/chat.d.ts CHANGED
@@ -1,7 +1,9 @@
1
+ import type { ChatGroup as RuntimeGroup } from "./runtime/groupClient.js";
2
+ import { type ChatMessage } from "./messageView.js";
3
+ export type { ChatMessage } from "./messageView.js";
1
4
  import { type CreateChatOptions } from "./runtime/createChat.js";
2
5
  import type { ChatSession, ChatSummarySnapshot as SessionSummary, ChatThreadSnapshot as SessionThread } from "./runtime/chatSession.js";
3
6
  import type { CachedChatMessage } from "./runtime/cachedMessage.js";
4
- import type { TimelineItem } from "./runtime/messageTimeline.js";
5
7
  import type { ConversationTarget } from "./runtime/conversationDirectory.js";
6
8
  import type { OutgoingContent as RuntimeContent } from "./runtime/outgoingContent.js";
7
9
  import { ChatError } from "./runtime/chatError.js";
@@ -13,8 +15,7 @@ export type OutgoingContent = Exclude<RuntimeContent, {
13
15
  } | {
14
16
  groupUpdate: unknown;
15
17
  }>;
16
- export type { ChatGroup } from "./runtime/groupClient.js";
17
- export type ChatMessage = Readonly<TimelineItem<CachedChatMessage>>;
18
+ export type ChatGroup = Readonly<Omit<RuntimeGroup, "versionNumber">>;
18
19
  export type ConversationInfo = Readonly<{
19
20
  kind: "dm" | "group";
20
21
  targetId: string;
@@ -26,7 +27,7 @@ export type ChatThreadSnapshot = Readonly<{
26
27
  hasEarlier: boolean;
27
28
  conversation?: ConversationInfo;
28
29
  unread: SessionThread<CachedChatMessage>["unread"];
29
- group: SessionThread<CachedChatMessage>["group"];
30
+ group: ChatGroup | undefined;
30
31
  readCounts: SessionThread<CachedChatMessage>["readCounts"];
31
32
  error?: ChatError;
32
33
  }>;
@@ -34,7 +35,7 @@ export type ChatSnapshot = Readonly<{
34
35
  threads: readonly {
35
36
  threadId: string;
36
37
  lastMessage?: ChatMessage;
37
- group: SessionThread<CachedChatMessage>["group"];
38
+ group: ChatGroup | undefined;
38
39
  unread: ChatThreadSnapshot["unread"];
39
40
  outgoingCount: number;
40
41
  }[];
@@ -62,6 +63,12 @@ type SubscribeOptions<T> = {
62
63
  export declare class Chat {
63
64
  readonly advanced: ChatSession<CachedChatMessage>;
64
65
  private options;
66
+ private groups;
67
+ private group;
68
+ private present;
69
+ private redactedQuotes;
70
+ private messageLists;
71
+ private messages;
65
72
  private views;
66
73
  private summary?;
67
74
  private targets;
@@ -83,31 +90,30 @@ export declare class Chat {
83
90
  subscribe(threadId: string, listener: (snapshot: ChatThreadSnapshot) => void, options?: SubscribeOptions<ChatThreadSnapshot>): () => void;
84
91
  private sessionFields;
85
92
  private notify;
86
- observeMessages(threadId: string, ids: string[], event: "visible" | "played"): Promise<import("./runtime/readIndex.js").ReadIndexState>;
87
- markRead(threadId: string): Promise<import("./runtime/readIndex.js").ReadIndexState>;
93
+ observeMessages(threadId: string, ids: string[], event: "visible" | "played"): Promise<void>;
94
+ markRead(threadId: string): Promise<void>;
88
95
  getReaders(threadId: string, messageId: string, page?: Parameters<ChatSession<CachedChatMessage>["getReaders"]>[2], signal?: AbortSignal): Promise<import("./runtime/messageReaders.js").MessageReadersPage>;
89
96
  watchReaders(threadId: string | undefined, ids: string[], receiver?: string): Promise<void>;
90
97
  handleReach(event: Parameters<ChatSession<CachedChatMessage>["handleReach"]>[0]): Promise<"handled" | undefined>;
91
98
  getVoiceAttachment(threadId: string, messageId: string): Promise<import("./runtime/durableSender.js").VoiceAttachmentLease>;
92
99
  getGroup(threadId: string, options?: {
93
100
  refresh?: boolean;
94
- }): Promise<import("./chat.js").ChatGroup>;
101
+ }): Promise<Readonly<Omit<RuntimeGroup, "versionNumber">>>;
95
102
  createGroup(input: {
96
103
  groupId?: string;
97
104
  title: string;
98
105
  members: readonly string[];
99
106
  }): Promise<{
100
107
  threadId: string;
101
- groupId: string;
108
+ revision: string;
102
109
  teamId: string;
103
- ownerId: string;
104
110
  title: string;
111
+ groupId: string;
105
112
  avatar?: string;
113
+ ownerId: string;
106
114
  memberIds: readonly string[];
107
- revision: string;
108
- versionNumber: number;
109
115
  }>;
110
- updateGroup(threadId: string, changes: Parameters<ChatSession<CachedChatMessage>["updateGroup"]>[1]): Promise<import("./chat.js").ChatGroup>;
116
+ updateGroup(threadId: string, changes: Parameters<ChatSession<CachedChatMessage>["updateGroup"]>[1]): Promise<Readonly<Omit<RuntimeGroup, "versionNumber">>>;
111
117
  revokeAccess(): Promise<void>;
112
118
  close(): Promise<void>;
113
119
  }
package/lib/chat.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createMessagePresenter } from "./messageView.js";
1
2
  import { createChat as createSession } from "./runtime/createChat.js";
2
3
  import { asChatError, ChatError } from "./runtime/chatError.js";
3
4
  export { ChatError } from "./runtime/chatError.js";
@@ -6,6 +7,40 @@ const emptyMessages = Object.freeze([]);
6
7
  export class Chat {
7
8
  advanced;
8
9
  options;
10
+ groups = new WeakMap();
11
+ group(value) {
12
+ if (!value)
13
+ return undefined;
14
+ let projected = this.groups.get(value);
15
+ if (!projected) {
16
+ const { versionNumber: _, ...visible } = value;
17
+ projected = visible;
18
+ this.groups.set(value, projected);
19
+ }
20
+ return projected;
21
+ }
22
+ present = createMessagePresenter();
23
+ redactedQuotes = new WeakMap();
24
+ messageLists = new WeakMap();
25
+ messages(items) {
26
+ let values = this.messageLists.get(items);
27
+ if (!values) {
28
+ const recalled = new Set(items.filter(item => item.recalled).map(item => item.id));
29
+ values = items.map(item => {
30
+ const value = this.present(item);
31
+ if (!value.quote?.messageId || !recalled.has(value.quote.messageId))
32
+ return value;
33
+ let hidden = this.redactedQuotes.get(value);
34
+ if (!hidden) {
35
+ hidden = { ...value, quote: { ...value.quote, content: "", recalled: true } };
36
+ this.redactedQuotes.set(value, hidden);
37
+ }
38
+ return hidden;
39
+ });
40
+ this.messageLists.set(items, values);
41
+ }
42
+ return values;
43
+ }
9
44
  views = new Map();
10
45
  summary;
11
46
  targets = new Map();
@@ -66,7 +101,7 @@ export class Chat {
66
101
  getSnapshot(threadId) {
67
102
  if (threadId === undefined) {
68
103
  const source = this.advanced.getSnapshot(), previous = this.summary;
69
- const threads = source.threads.filter(thread => this.errors.get(thread.threadId)?.code !== "access-denied").map(thread => ({ threadId: thread.threadId, lastMessage: thread.lastItem, group: thread.group, unread: thread.unread, outgoingCount: thread.outgoingCount }));
104
+ const threads = source.threads.filter(thread => this.errors.get(thread.threadId)?.code !== "access-denied").map(thread => ({ threadId: thread.threadId, lastMessage: this.messages(this.advanced.getSnapshot(thread.threadId).timeline).at(-1), group: this.group(thread.group), unread: thread.unread, outgoingCount: thread.outgoingCount }));
70
105
  const unreadMessageCount = source.unreadMessageCount === undefined ? undefined : threads.reduce((sum, thread) => sum + (thread.unread?.unreadMessageCount ?? 0), 0);
71
106
  const badgeCount = source.badgeCount === undefined ? undefined : threads.reduce((sum, thread) => sum + (thread.unread?.badgeCount ?? 0), 0);
72
107
  if (previous && previous.connection === source.connection && previous.badgeCount === badgeCount && previous.unreadMessageCount === unreadMessageCount && previous.threads.length === threads.length && threads.every((thread, i) => { const old = previous.threads[i]; return thread.threadId === old.threadId && thread.lastMessage === old.lastMessage && thread.group === old.group && thread.unread === old.unread && thread.outgoingCount === old.outgoingCount; }))
@@ -78,7 +113,7 @@ export class Chat {
78
113
  const target = this.closed || !this.advanced.active || denied ? undefined : this.advanced.getConversationTarget(threadId) ?? this.targets.get(threadId);
79
114
  const conversation = previous?.conversation?.kind === target?.kind && previous?.conversation?.targetId === target?.targetId ? previous?.conversation : target ? { kind: target.kind, targetId: target.targetId } : undefined;
80
115
  // Partial migrated rows never masquerade as a trusted recent window.
81
- const next = { threadId, conversation, messages: !denied && source.complete ? source.timeline : emptyMessages, ready: !denied && source.complete, hasEarlier: !denied && source.hasEarlier, unread: denied ? undefined : source.unread, group: denied ? undefined : source.group, readCounts: denied ? undefined : source.readCounts, error };
116
+ const next = { threadId, conversation, messages: !denied && source.complete ? this.messages(source.timeline) : emptyMessages, ready: !denied && source.complete, hasEarlier: !denied && source.hasEarlier, unread: denied ? undefined : source.unread, group: denied ? undefined : this.group(source.group), readCounts: denied ? undefined : source.readCounts, error };
82
117
  if (previous && Object.keys(next).every(key => next[key] === previous[key]))
83
118
  return previous;
84
119
  this.views.set(threadId, next);
@@ -154,23 +189,23 @@ export class Chat {
154
189
  return fields ? [...new Set(fields.flatMap(field => field === "messages" ? ["timeline", "complete"] : field === "ready" ? ["complete"] : field === "conversation" ? ["messages", "group"] : field === "error" || field === "threadId" ? [] : [field]))] : undefined;
155
190
  }
156
191
  notify(threadId) { this.listeners.get(threadId)?.forEach(listener => listener()); this.listeners.get("")?.forEach(listener => listener()); }
157
- observeMessages(threadId, ids, event) { return this.threadOperation(threadId, () => this.advanced.observeMessages(threadId, ids, event)); }
158
- markRead(threadId) { return this.threadOperation(threadId, () => this.advanced.markRead(threadId)); }
192
+ observeMessages(threadId, ids, event) { return this.threadOperation(threadId, async () => { await this.advanced.observeMessages(threadId, ids, event); }); }
193
+ markRead(threadId) { return this.threadOperation(threadId, async () => { await this.advanced.markRead(threadId); }); }
159
194
  getReaders(threadId, messageId, page = {}, signal) { return this.threadOperation(threadId, () => this.advanced.getReaders(threadId, messageId, page, signal)); }
160
195
  watchReaders(threadId, ids, receiver) { return this.threadOperation(threadId, () => this.advanced.observeMessages(threadId, ids, "receipts", receiver)); }
161
196
  handleReach(event) { return this.operation(() => this.advanced.handleReach(event)); }
162
197
  getVoiceAttachment(threadId, messageId) { return this.threadOperation(threadId, () => this.advanced.getVoiceAttachment(threadId, messageId)); }
163
- getGroup(threadId, options = {}) { return this.threadOperation(threadId, () => this.advanced.getGroup(threadId, options)); }
198
+ getGroup(threadId, options = {}) { return this.threadOperation(threadId, async () => this.group(await this.advanced.getGroup(threadId, options))); }
164
199
  createGroup(input) {
165
200
  return this.operation(async () => {
166
201
  const groupId = input.groupId ?? crypto.randomUUID(), threadId = await this.getConversation({ groupId });
167
202
  const group = await this.advanced.createGroup({ id: threadId, kind: "group", targetId: groupId, teamId: this.options.teamId }, { title: input.title, members: input.members });
168
- return { ...group, threadId };
203
+ return { ...this.group(group), threadId };
169
204
  });
170
205
  }
171
- updateGroup(threadId, changes) { return this.threadOperation(threadId, () => this.advanced.updateGroup(threadId, changes)); }
206
+ updateGroup(threadId, changes) { return this.threadOperation(threadId, async () => this.group(await this.advanced.updateGroup(threadId, changes))); }
172
207
  revokeAccess() { return this.operation(() => this.advanced.revokeAccess()); }
173
- close() { this.closed = true; return this.operation(() => this.advanced.close()).finally(() => { this.errors.clear(); this.targets.clear(); this.listeners.clear(); this.views.clear(); this.summary = undefined; }); }
208
+ close() { this.closed = true; return this.operation(() => this.advanced.close()).finally(() => { this.errors.clear(); this.targets.clear(); this.listeners.clear(); this.views.clear(); this.summary = undefined; this.present = createMessagePresenter(); this.messageLists = new WeakMap(); this.groups = new WeakMap(); this.redactedQuotes = new WeakMap(); }); }
174
209
  }
175
210
  export function createChat(options) {
176
211
  const configured = { ...options, onError: (error) => options.onError(asChatError(error)) };
@@ -0,0 +1,36 @@
1
+ import type { TimelineItem } from "./runtime/messageTimeline.js";
2
+ import type { CachedChatMessage } from "./runtime/cachedMessage.js";
3
+ import type { MessageQuote } from "./runtime/outgoingContent.js";
4
+ import type { ChatMention } from "./mentions/model.js";
5
+ export type ChatMessage = Readonly<Omit<TimelineItem<CachedChatMessage>, "message" | "reactionEvents"> & {
6
+ kind: "text" | "image" | "voice" | "emoji" | "quote" | "invitation" | "system" | "attachment";
7
+ text: string;
8
+ senderName?: string;
9
+ senderAvatar?: string;
10
+ image?: Readonly<{
11
+ url: string;
12
+ name?: string;
13
+ }>;
14
+ voice?: Readonly<{
15
+ url: string;
16
+ durationMs: number;
17
+ transcript?: string;
18
+ }>;
19
+ quote?: Readonly<MessageQuote & {
20
+ recalled?: boolean;
21
+ }>;
22
+ attachment?: Readonly<{
23
+ name?: string;
24
+ size?: string;
25
+ }>;
26
+ /** Explicit versioned content, interpreted with SDK readMessageContent. */
27
+ richContent?: unknown;
28
+ mentions?: readonly ChatMention[];
29
+ recipientCount?: number;
30
+ system?: Readonly<{
31
+ type: "group-created" | "group-updated" | "contact-updated";
32
+ title?: string;
33
+ }>;
34
+ }>;
35
+ /** Decode a body once; reading and delivery projections reuse its normalized content. */
36
+ export declare function createMessagePresenter(): (item: TimelineItem<CachedChatMessage>) => ChatMessage;
@@ -0,0 +1,97 @@
1
+ const object = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
2
+ const text = (value) => typeof value === "string" ? value : "";
3
+ function media(value) {
4
+ if (typeof value !== "string" || !value)
5
+ return undefined;
6
+ try {
7
+ const url = new URL(value);
8
+ return ["https:", "http:"].includes(url.protocol) && !url.username && !url.password ? value : undefined;
9
+ }
10
+ catch {
11
+ return undefined;
12
+ }
13
+ }
14
+ function quote(value) {
15
+ const record = object(value);
16
+ if (typeof record.senderName !== "string" || typeof record.content !== "string" || record.content.length > 6000)
17
+ return;
18
+ return { senderName: record.senderName.slice(0, 100), content: record.content, ...(typeof record.messageId === "string" ? { messageId: record.messageId } : {}) };
19
+ }
20
+ function legacyDuration(value) {
21
+ if (typeof value !== "string" || !/^\d+:\d{2}$/.test(value))
22
+ return 0;
23
+ const [minutes, seconds] = value.split(":").map(Number);
24
+ return (minutes * 60 + seconds) * 1000;
25
+ }
26
+ function content(message) {
27
+ const body = object(message.body), outer = object(body.content);
28
+ const envelope = body.kind === "quote" && outer.content !== undefined ? outer : body;
29
+ const payload = object(envelope.content);
30
+ const legacy = object(message.legacyDisplay);
31
+ const validLegacy = legacy.id === message.id && legacy.threadId === message.threadId && legacy.orgId === message.teamId && legacy.from === message.from && typeof legacy.content === "string";
32
+ const hint = validLegacy ? legacy : {};
33
+ const contentKind = envelope.contentKind, voice = object(payload.voice);
34
+ const voiceHint = hint.kind === "voice";
35
+ const voiceUrl = media(voice.url) ?? (voiceHint ? media(hint.audioUrl) : undefined) ?? "";
36
+ const durationMs = typeof voice.durationMs === "number" && Number.isFinite(voice.durationMs) ? voice.durationMs : voiceHint ? legacyDuration(hint.duration) : 0;
37
+ const hasVoice = voiceHint || Object.keys(voice).length > 0 && (voiceUrl.length > 0 || durationMs > 0) || contentKind === "voice";
38
+ const imageUrl = media(payload.url) ?? (hint.kind === "image" ? media(hint.imageUrl) : undefined);
39
+ const messageQuote = quote(payload.quote) ?? quote(hint.quote);
40
+ const rawText = text(payload.text) || text(object(body.payload).message);
41
+ const result = { kind: "text", text: validLegacy ? text(hint.content) : rawText, senderName: text(hint.senderName) || undefined, senderAvatar: media(hint.senderAvatar),
42
+ recipientCount: message.threadKind === "dm" ? 1 : validLegacy ? typeof hint.recipientCount === "number" ? hint.recipientCount : undefined : Array.isArray(message.recipients) ? new Set(message.recipients.filter(id => id !== message.from)).size : undefined };
43
+ if (validLegacy && ["emoji", "system", "attachment", "quote"].includes(String(hint.kind)))
44
+ result.kind = hint.kind;
45
+ if (body.kind === "contact") {
46
+ result.kind = ["friendRequest", "requestFriend"].includes(String(body.action)) ? "invitation" : "system";
47
+ if (result.kind === "system")
48
+ result.system = { type: "contact-updated" };
49
+ }
50
+ else if (body.kind === "group") {
51
+ result.kind = "system";
52
+ result.system = { type: body.action === "groupCreate" ? "group-created" : "group-updated", title: text(object(body.payload).title) || undefined };
53
+ }
54
+ else if (hasVoice) {
55
+ result.kind = "voice";
56
+ result.voice = { url: voiceUrl, durationMs, ...(text(voice.transcript) || text(hint.transcript) ? { transcript: text(voice.transcript) || text(hint.transcript) } : {}) };
57
+ }
58
+ else if ((contentKind === "image" || hint.kind === "image") && imageUrl) {
59
+ result.kind = "image";
60
+ result.image = { url: imageUrl, name: text(payload.name) || text(hint.imageAlt) || undefined };
61
+ }
62
+ else if (contentKind === "emoji") {
63
+ result.kind = "emoji";
64
+ result.text = text(payload.code) || result.text;
65
+ }
66
+ if (messageQuote) {
67
+ result.quote = messageQuote;
68
+ if (result.kind === "text")
69
+ result.kind = "quote";
70
+ }
71
+ if (result.kind === "attachment")
72
+ result.attachment = { name: text(hint.attachmentName) || undefined, size: text(hint.attachmentSize) || undefined };
73
+ if (payload.format !== undefined)
74
+ result.richContent = payload;
75
+ else if (hint.richContent !== undefined)
76
+ result.richContent = hint.richContent;
77
+ if (Array.isArray(payload.mentions))
78
+ result.mentions = payload.mentions;
79
+ return result;
80
+ }
81
+ /** Decode a body once; reading and delivery projections reuse its normalized content. */
82
+ export function createMessagePresenter() {
83
+ const bodies = new WeakMap(), items = new WeakMap();
84
+ return (item) => {
85
+ const previous = items.get(item);
86
+ if (previous)
87
+ return previous;
88
+ let value = { kind: "system", text: "" };
89
+ if (!item.recalled && item.message) {
90
+ value = bodies.get(item.message) ?? content(item.message);
91
+ bodies.set(item.message, value);
92
+ }
93
+ const result = { id: item.id, threadId: item.threadId, timestamp: item.timestamp, from: item.from, mine: item.mine, status: item.status, recalled: item.recalled, unread: item.unread, attentionEligible: item.attentionEligible, reading: item.reading, reactions: item.reactions, ...value };
94
+ items.set(item, result);
95
+ return result;
96
+ };
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.64",
3
+ "version": "0.12.0-next.65",
4
4
  "description": "Local-first chat SDK, message subscriptions, reading state, and shared chat UI primitives",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",