@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 +11 -3
- package/lib/chat.d.ts +20 -13
- package/lib/chat.js +68 -18
- package/lib/messageView.d.ts +36 -0
- package/lib/messageView.js +97 -0
- 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.
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
}[];
|
|
@@ -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<
|
|
86
|
-
markRead(threadId: string): Promise<
|
|
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<
|
|
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
|
-
|
|
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<
|
|
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.
|
|
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.
|
|
61
|
-
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
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