@pmate/chat-sdk 0.12.0-next.64 → 0.12.0-next.66
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 +8 -2
- package/lib/chat.d.ts +31 -14
- package/lib/chat.js +50 -9
- package/lib/index.d.ts +1 -1
- package/lib/messageView.d.ts +37 -0
- package/lib/messageView.js +97 -0
- package/lib/runtime/messageLaunch.d.ts +2 -0
- package/lib/runtime/messageLaunch.js +5 -1
- package/package.json +1 -1
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.
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
38
|
+
group: ChatGroup | undefined;
|
|
38
39
|
unread: ChatThreadSnapshot["unread"];
|
|
39
40
|
outgoingCount: number;
|
|
40
41
|
}[];
|
|
@@ -47,6 +48,17 @@ export type MessageQuery = {
|
|
|
47
48
|
all?: boolean;
|
|
48
49
|
refresh?: boolean;
|
|
49
50
|
};
|
|
51
|
+
export type ReadersQuery = {
|
|
52
|
+
afterProfileId?: string;
|
|
53
|
+
revision?: number;
|
|
54
|
+
};
|
|
55
|
+
export type GroupChanges = {
|
|
56
|
+
expectedRevision?: string;
|
|
57
|
+
addMembers?: readonly string[];
|
|
58
|
+
removeMembers?: readonly string[];
|
|
59
|
+
title?: string;
|
|
60
|
+
avatar?: string;
|
|
61
|
+
};
|
|
50
62
|
export type SendOptions = {
|
|
51
63
|
waitFor?: "accepted" | "delivered";
|
|
52
64
|
};
|
|
@@ -62,6 +74,12 @@ type SubscribeOptions<T> = {
|
|
|
62
74
|
export declare class Chat {
|
|
63
75
|
readonly advanced: ChatSession<CachedChatMessage>;
|
|
64
76
|
private options;
|
|
77
|
+
private groups;
|
|
78
|
+
private group;
|
|
79
|
+
private present;
|
|
80
|
+
private redactedQuotes;
|
|
81
|
+
private messageLists;
|
|
82
|
+
private messages;
|
|
65
83
|
private views;
|
|
66
84
|
private summary?;
|
|
67
85
|
private targets;
|
|
@@ -83,31 +101,30 @@ export declare class Chat {
|
|
|
83
101
|
subscribe(threadId: string, listener: (snapshot: ChatThreadSnapshot) => void, options?: SubscribeOptions<ChatThreadSnapshot>): () => void;
|
|
84
102
|
private sessionFields;
|
|
85
103
|
private notify;
|
|
86
|
-
observeMessages(threadId: string, ids: string[], event: "visible" | "played"): Promise<
|
|
87
|
-
markRead(threadId: string): Promise<
|
|
88
|
-
getReaders(threadId: string, messageId: string, page?:
|
|
104
|
+
observeMessages(threadId: string, ids: string[], event: "visible" | "played"): Promise<void>;
|
|
105
|
+
markRead(threadId: string): Promise<void>;
|
|
106
|
+
getReaders(threadId: string, messageId: string, page?: ReadersQuery, signal?: AbortSignal): Promise<import("./runtime/messageReaders.js").MessageReadersPage>;
|
|
89
107
|
watchReaders(threadId: string | undefined, ids: string[], receiver?: string): Promise<void>;
|
|
90
108
|
handleReach(event: Parameters<ChatSession<CachedChatMessage>["handleReach"]>[0]): Promise<"handled" | undefined>;
|
|
91
109
|
getVoiceAttachment(threadId: string, messageId: string): Promise<import("./runtime/durableSender.js").VoiceAttachmentLease>;
|
|
92
110
|
getGroup(threadId: string, options?: {
|
|
93
111
|
refresh?: boolean;
|
|
94
|
-
}): Promise<
|
|
112
|
+
}): Promise<Readonly<Omit<RuntimeGroup, "versionNumber">>>;
|
|
95
113
|
createGroup(input: {
|
|
96
114
|
groupId?: string;
|
|
97
115
|
title: string;
|
|
98
116
|
members: readonly string[];
|
|
99
117
|
}): Promise<{
|
|
100
118
|
threadId: string;
|
|
101
|
-
|
|
119
|
+
revision: string;
|
|
102
120
|
teamId: string;
|
|
103
|
-
ownerId: string;
|
|
104
121
|
title: string;
|
|
122
|
+
groupId: string;
|
|
105
123
|
avatar?: string;
|
|
124
|
+
ownerId: string;
|
|
106
125
|
memberIds: readonly string[];
|
|
107
|
-
revision: string;
|
|
108
|
-
versionNumber: number;
|
|
109
126
|
}>;
|
|
110
|
-
updateGroup(threadId: string, changes:
|
|
127
|
+
updateGroup(threadId: string, changes: GroupChanges): Promise<Readonly<Omit<RuntimeGroup, "versionNumber">>>;
|
|
111
128
|
revokeAccess(): Promise<void>;
|
|
112
129
|
close(): Promise<void>;
|
|
113
130
|
}
|
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.
|
|
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,29 @@ 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)); }
|
|
159
|
-
getReaders(threadId, messageId, page = {}, signal) {
|
|
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); }); }
|
|
194
|
+
getReaders(threadId, messageId, page = {}, signal) {
|
|
195
|
+
return this.threadOperation(threadId, async () => {
|
|
196
|
+
if (Object.keys(page).some(key => !["afterProfileId", "revision"].includes(key)))
|
|
197
|
+
throw new ChatError("invalid-input", "Unsupported reader query");
|
|
198
|
+
return this.advanced.getReaders(threadId, messageId, page, signal);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
160
201
|
watchReaders(threadId, ids, receiver) { return this.threadOperation(threadId, () => this.advanced.observeMessages(threadId, ids, "receipts", receiver)); }
|
|
161
202
|
handleReach(event) { return this.operation(() => this.advanced.handleReach(event)); }
|
|
162
203
|
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)); }
|
|
204
|
+
getGroup(threadId, options = {}) { return this.threadOperation(threadId, async () => this.group(await this.advanced.getGroup(threadId, options))); }
|
|
164
205
|
createGroup(input) {
|
|
165
206
|
return this.operation(async () => {
|
|
166
207
|
const groupId = input.groupId ?? crypto.randomUUID(), threadId = await this.getConversation({ groupId });
|
|
167
208
|
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 };
|
|
209
|
+
return { ...this.group(group), threadId };
|
|
169
210
|
});
|
|
170
211
|
}
|
|
171
|
-
updateGroup(threadId, changes) { return this.threadOperation(threadId, () => this.advanced.updateGroup(threadId, changes)); }
|
|
212
|
+
updateGroup(threadId, changes) { return this.threadOperation(threadId, async () => this.group(await this.advanced.updateGroup(threadId, changes))); }
|
|
172
213
|
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; }); }
|
|
214
|
+
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
215
|
}
|
|
175
216
|
export function createChat(options) {
|
|
176
217
|
const configured = { ...options, onError: (error) => options.onError(asChatError(error)) };
|
package/lib/index.d.ts
CHANGED
|
@@ -41,5 +41,5 @@ export * from "./runtime/socialReadClient";
|
|
|
41
41
|
export * from "./ux/imagePaste";
|
|
42
42
|
export * from "./hooks/useImageAttachments";
|
|
43
43
|
export * from "./components/ImageAttachmentTray";
|
|
44
|
-
export { createChat, Chat, ChatError, type ChatThreadSnapshot, type ChatMessage as MessageView, type ChatErrorCode, type ConversationTarget, type OutgoingContent, type CreateChatOptions, type ChatSnapshot, type SentMessage, type MessageQuery } from "./chat.js";
|
|
44
|
+
export { createChat, Chat, ChatError, type ChatThreadSnapshot, type ChatMessage as MessageView, type ChatErrorCode, type ConversationTarget, type OutgoingContent, type CreateChatOptions, type ChatSnapshot, type SentMessage, type MessageQuery, type ReadersQuery, type GroupChanges } from "./chat.js";
|
|
45
45
|
export { useChatSnapshot } from "./hooks/useChatSnapshot.js";
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
timeLabel?: string;
|
|
11
|
+
image?: Readonly<{
|
|
12
|
+
url: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
}>;
|
|
15
|
+
voice?: Readonly<{
|
|
16
|
+
url: string;
|
|
17
|
+
durationMs: number;
|
|
18
|
+
transcript?: string;
|
|
19
|
+
}>;
|
|
20
|
+
quote?: Readonly<MessageQuote & {
|
|
21
|
+
recalled?: boolean;
|
|
22
|
+
}>;
|
|
23
|
+
attachment?: Readonly<{
|
|
24
|
+
name?: string;
|
|
25
|
+
size?: string;
|
|
26
|
+
}>;
|
|
27
|
+
/** Explicit versioned content, interpreted with SDK readMessageContent. */
|
|
28
|
+
richContent?: unknown;
|
|
29
|
+
mentions?: readonly ChatMention[];
|
|
30
|
+
recipientCount?: number;
|
|
31
|
+
system?: Readonly<{
|
|
32
|
+
type: "group-created" | "group-updated" | "contact-updated";
|
|
33
|
+
title?: string;
|
|
34
|
+
}>;
|
|
35
|
+
}>;
|
|
36
|
+
/** Decode a body once; reading and delivery projections reuse its normalized content. */
|
|
37
|
+
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), timeLabel: text(hint.createdAt) || undefined,
|
|
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
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ChatMessage } from "../messageView.js";
|
|
1
2
|
import { type MessageScope } from "./messageStore.js";
|
|
2
3
|
import { type CachedChatMessage } from "./cachedMessage.js";
|
|
3
4
|
import { type TimelineItem } from "./messageTimeline.js";
|
|
@@ -12,6 +13,7 @@ export type MessageLaunchView = {
|
|
|
12
13
|
threadId: string;
|
|
13
14
|
metadata: MessageLaunchMetadata;
|
|
14
15
|
images: Record<string, string>;
|
|
16
|
+
messages: readonly ChatMessage[]; /** @deprecated Advanced compatibility projection. */
|
|
15
17
|
timeline: readonly TimelineItem<CachedChatMessage>[];
|
|
16
18
|
};
|
|
17
19
|
type Options = {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createMessagePresenter } from "../messageView.js";
|
|
1
2
|
import { Dexie } from "dexie";
|
|
2
3
|
import { IndexedDbMessageStore, messageScopeKey, mergeStoredMessage } from "./messageStore.js";
|
|
3
4
|
import { DurableChatSender } from "./durableSender.js";
|
|
@@ -47,6 +48,7 @@ export async function saveMessageLaunch(input, options = {}) {
|
|
|
47
48
|
export function watchMessageLaunch(input, onChange, onError, options = {}) {
|
|
48
49
|
const { scope, openId, threadId } = input, key = messageScopeKey(scope), store = messages(scope, options), { db, rows } = tickets(options);
|
|
49
50
|
let closed = false, generation = 0, ticket, timer;
|
|
51
|
+
const present = createMessagePresenter();
|
|
50
52
|
const channels = [];
|
|
51
53
|
const stop = () => { if (closed)
|
|
52
54
|
return; closed = true; generation++; clearTimeout(timer); tasks.close(); channels.forEach(channel => channel.close()); store.close(); db.close(); };
|
|
@@ -89,7 +91,9 @@ export function watchMessageLaunch(input, onChange, onError, options = {}) {
|
|
|
89
91
|
return;
|
|
90
92
|
}
|
|
91
93
|
const timeline = projectMessageTimeline({ appId: scope.appId, profileId: scope.profileId, threadId, messages: page.messages, outgoing, decode: message => decodeCachedChatMessage(message, scope), reading: {} }).slice(-30);
|
|
92
|
-
|
|
94
|
+
const normalized = timeline.map(present), visibleImages = new Set(normalized.flatMap(message => message.image ? [message.image.url] : []));
|
|
95
|
+
const images = Object.fromEntries(Object.entries(ticket.images).filter(([url]) => visibleImages.has(url)));
|
|
96
|
+
onChange(timeline.length ? { openId, threadId, metadata: ticket.metadata, images, messages: normalized, timeline } : undefined);
|
|
93
97
|
};
|
|
94
98
|
const tasks = new WindowedTasks(16, fail);
|
|
95
99
|
if (typeof BroadcastChannel !== "undefined") {
|
package/package.json
CHANGED