@pmate/chat-sdk 0.12.0-next.63 → 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.
@@ -215,7 +220,9 @@ selects receipt counts for the current viewport; clear with `watchReaders(undefi
215
220
  `ChatError` exposes `code`, `retryable`, optional HTTP `status`, and the original
216
221
  `cause`. Recovery categories are `unavailable`, `access-denied`,
217
222
  `storage-unavailable`, `invalid-input`, `conflict`, `closed`, and `busy`.
218
- Permission failures never turn into trusted offline data. Dispose and recreate
223
+ Permission failures withdraw the current scoped session and retire its caches;
224
+ failed cleanup remains in the error cause. They never turn into trusted offline
225
+ data. Dispose and recreate
219
226
  the SDK when account, profile, team or token changes.
220
227
 
221
228
  ### Advanced integration and migration
@@ -228,7 +235,8 @@ contract for compatibility. New Apps should import from `@pmate/chat-sdk/chat`
228
235
  or the package root. Do not mix the two snapshot contracts.
229
236
 
230
237
  For migration, replace `subscribe(id, () => render(getSnapshot(id).timeline))`
231
- 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
232
240
  objects and App-supplied recipients with `threadId`; await `getMessages` before
233
241
  opening a cold thread instead of checking `page.complete`. Replace `initialize()`
234
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;
@@ -70,6 +77,7 @@ export declare class Chat {
70
77
  private closed;
71
78
  constructor(advanced: ChatSession<CachedChatMessage>, options: CreateChatOptions);
72
79
  private operation;
80
+ private deny;
73
81
  private threadOperation;
74
82
  start(): Promise<void>;
75
83
  getConversation(target: ConversationTarget): Promise<string>;
@@ -82,31 +90,30 @@ export declare class Chat {
82
90
  subscribe(threadId: string, listener: (snapshot: ChatThreadSnapshot) => void, options?: SubscribeOptions<ChatThreadSnapshot>): () => void;
83
91
  private sessionFields;
84
92
  private notify;
85
- observeMessages(threadId: string, ids: string[], event: "visible" | "played"): Promise<import("./runtime/readIndex.js").ReadIndexState>;
86
- 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>;
87
95
  getReaders(threadId: string, messageId: string, page?: Parameters<ChatSession<CachedChatMessage>["getReaders"]>[2], signal?: AbortSignal): Promise<import("./runtime/messageReaders.js").MessageReadersPage>;
88
96
  watchReaders(threadId: string | undefined, ids: string[], receiver?: string): Promise<void>;
89
97
  handleReach(event: Parameters<ChatSession<CachedChatMessage>["handleReach"]>[0]): Promise<"handled" | undefined>;
90
98
  getVoiceAttachment(threadId: string, messageId: string): Promise<import("./runtime/durableSender.js").VoiceAttachmentLease>;
91
99
  getGroup(threadId: string, options?: {
92
100
  refresh?: boolean;
93
- }): Promise<import("./chat.js").ChatGroup>;
101
+ }): Promise<Readonly<Omit<RuntimeGroup, "versionNumber">>>;
94
102
  createGroup(input: {
95
103
  groupId?: string;
96
104
  title: string;
97
105
  members: readonly string[];
98
106
  }): Promise<{
99
107
  threadId: string;
100
- groupId: string;
108
+ revision: string;
101
109
  teamId: string;
102
- ownerId: string;
103
110
  title: string;
111
+ groupId: string;
104
112
  avatar?: string;
113
+ ownerId: string;
105
114
  memberIds: readonly string[];
106
- revision: string;
107
- versionNumber: number;
108
115
  }>;
109
- 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">>>;
110
117
  revokeAccess(): Promise<void>;
111
118
  close(): Promise<void>;
112
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();
@@ -22,6 +57,18 @@ export class Chat {
22
57
  catch (error) {
23
58
  throw asChatError(error);
24
59
  } }
60
+ async deny(threadId, failure) {
61
+ this.errors.set(threadId, failure);
62
+ this.targets.delete(threadId);
63
+ const retiring = this.advanced.revokeAccess();
64
+ this.notify(threadId);
65
+ try {
66
+ await retiring;
67
+ }
68
+ catch (error) {
69
+ failure.cause = new AggregateError([failure.cause, error], "Access withdrawn; cache cleanup incomplete");
70
+ }
71
+ }
25
72
  threadOperation(threadId, run) {
26
73
  return this.operation(async () => {
27
74
  const denied = threadId ? this.errors.get(threadId) : undefined;
@@ -32,11 +79,8 @@ export class Chat {
32
79
  }
33
80
  catch (error) {
34
81
  const failure = asChatError(error);
35
- if (threadId && failure.code === "access-denied") {
36
- this.errors.set(threadId, failure);
37
- this.targets.delete(threadId);
38
- this.notify(threadId);
39
- }
82
+ if (threadId && failure.code === "access-denied")
83
+ await this.deny(threadId, failure);
40
84
  throw failure;
41
85
  }
42
86
  });
@@ -57,17 +101,19 @@ export class Chat {
57
101
  getSnapshot(threadId) {
58
102
  if (threadId === undefined) {
59
103
  const source = this.advanced.getSnapshot(), previous = this.summary;
60
- const threads = source.threads.map(thread => ({ threadId: thread.threadId, lastMessage: thread.lastItem, group: thread.group, unread: thread.unread, outgoingCount: thread.outgoingCount }));
61
- if (previous && previous.connection === source.connection && previous.badgeCount === source.badgeCount && previous.unreadMessageCount === source.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; }))
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 }));
105
+ const unreadMessageCount = source.unreadMessageCount === undefined ? undefined : threads.reduce((sum, thread) => sum + (thread.unread?.unreadMessageCount ?? 0), 0);
106
+ const badgeCount = source.badgeCount === undefined ? undefined : threads.reduce((sum, thread) => sum + (thread.unread?.badgeCount ?? 0), 0);
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; }))
62
108
  return previous;
63
- return this.summary = { threads, connection: source.connection, badgeCount: source.badgeCount, unreadMessageCount: source.unreadMessageCount };
109
+ return this.summary = { threads, connection: source.connection, badgeCount, unreadMessageCount };
64
110
  }
65
111
  const source = this.advanced.getSnapshot(threadId), previous = this.views.get(threadId);
66
112
  const error = this.closed ? undefined : this.errors.get(threadId), denied = error?.code === "access-denied";
67
113
  const target = this.closed || !this.advanced.active || denied ? undefined : this.advanced.getConversationTarget(threadId) ?? this.targets.get(threadId);
68
114
  const conversation = previous?.conversation?.kind === target?.kind && previous?.conversation?.targetId === target?.targetId ? previous?.conversation : target ? { kind: target.kind, targetId: target.targetId } : undefined;
69
115
  // Partial migrated rows never masquerade as a trusted recent window.
70
- 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 };
71
117
  if (previous && Object.keys(next).every(key => next[key] === previous[key]))
72
118
  return previous;
73
119
  this.views.set(threadId, next);
@@ -87,8 +133,12 @@ export class Chat {
87
133
  catch (error) {
88
134
  const failure = asChatError(error);
89
135
  if (!this.closed) {
90
- this.errors.set(threadId, failure);
91
- this.notify(threadId);
136
+ if (failure.code === "access-denied")
137
+ await this.deny(threadId, failure);
138
+ else {
139
+ this.errors.set(threadId, failure);
140
+ this.notify(threadId);
141
+ }
92
142
  }
93
143
  throw failure;
94
144
  }
@@ -138,24 +188,24 @@ export class Chat {
138
188
  sessionFields(fields) {
139
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;
140
190
  }
141
- notify(threadId) { this.listeners.get(threadId)?.forEach(listener => listener()); }
142
- observeMessages(threadId, ids, event) { return this.threadOperation(threadId, () => this.advanced.observeMessages(threadId, ids, event)); }
143
- markRead(threadId) { return this.threadOperation(threadId, () => this.advanced.markRead(threadId)); }
191
+ notify(threadId) { this.listeners.get(threadId)?.forEach(listener => listener()); this.listeners.get("")?.forEach(listener => listener()); }
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); }); }
144
194
  getReaders(threadId, messageId, page = {}, signal) { return this.threadOperation(threadId, () => this.advanced.getReaders(threadId, messageId, page, signal)); }
145
195
  watchReaders(threadId, ids, receiver) { return this.threadOperation(threadId, () => this.advanced.observeMessages(threadId, ids, "receipts", receiver)); }
146
196
  handleReach(event) { return this.operation(() => this.advanced.handleReach(event)); }
147
197
  getVoiceAttachment(threadId, messageId) { return this.threadOperation(threadId, () => this.advanced.getVoiceAttachment(threadId, messageId)); }
148
- 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))); }
149
199
  createGroup(input) {
150
200
  return this.operation(async () => {
151
201
  const groupId = input.groupId ?? crypto.randomUUID(), threadId = await this.getConversation({ groupId });
152
202
  const group = await this.advanced.createGroup({ id: threadId, kind: "group", targetId: groupId, teamId: this.options.teamId }, { title: input.title, members: input.members });
153
- return { ...group, threadId };
203
+ return { ...this.group(group), threadId };
154
204
  });
155
205
  }
156
- 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))); }
157
207
  revokeAccess() { return this.operation(() => this.advanced.revokeAccess()); }
158
- 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(); }); }
159
209
  }
160
210
  export function createChat(options) {
161
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.63",
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",