@noverachat/sdk-react 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +2 -0
- package/README.md +2 -0
- package/dist/index.cjs +52 -370
- package/dist/index.d.cts +31 -95
- package/dist/index.d.ts +31 -95
- package/dist/index.js +52 -365
- package/package.json +2 -2
- package/src/chat-message.ts +10 -208
- package/src/room-list-store.ts +4 -4
- package/src/room-store.ts +57 -293
package/src/room-store.ts
CHANGED
|
@@ -1,26 +1,12 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
DeleteScope,
|
|
3
|
+
MessageCollection,
|
|
3
4
|
NoveraChat,
|
|
4
|
-
ReactionEntry,
|
|
5
5
|
Room,
|
|
6
6
|
SendFileOptions,
|
|
7
|
-
WsChatReceive,
|
|
8
|
-
WsMessageDeleted,
|
|
9
|
-
WsMessagesCleared,
|
|
10
|
-
WsMessageUpdated,
|
|
11
|
-
WsReactionAdded,
|
|
12
|
-
WsReactionRemoved,
|
|
13
|
-
WsSyncTick,
|
|
14
7
|
} from "@noverachat/sdk-web";
|
|
15
8
|
|
|
16
|
-
import {
|
|
17
|
-
chatMessageFromHistory,
|
|
18
|
-
chatMessageFromLive,
|
|
19
|
-
pendingChatMessage,
|
|
20
|
-
pendingFileChatMessage,
|
|
21
|
-
type ChatMessage,
|
|
22
|
-
} from "./chat-message.js";
|
|
23
|
-
import { idGreater } from "./internal/compare-id.js";
|
|
9
|
+
import { type ChatMessage, chatMessageFromHistory } from "./chat-message.js";
|
|
24
10
|
|
|
25
11
|
/** Immutable snapshot handed to React via `useSyncExternalStore`. A new
|
|
26
12
|
* object (new array/record identities) is produced on every change. */
|
|
@@ -34,22 +20,21 @@ export interface RoomSnapshot {
|
|
|
34
20
|
readWatermarks: Readonly<Record<string, string>>;
|
|
35
21
|
}
|
|
36
22
|
|
|
37
|
-
let uploadSeq = 0;
|
|
38
|
-
|
|
39
23
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
24
|
+
* React-side glue over the core `MessageCollection` — the message state
|
|
25
|
+
* (event folding, optimistic sends, cache hydrate→replace) is owned by the
|
|
26
|
+
* core; this store bridges the collection's change events into
|
|
27
|
+
* `useSyncExternalStore`-shaped immutable snapshots. Framework-agnostic on
|
|
28
|
+
* purpose so it can also be used outside hooks (tests, non-React glue).
|
|
43
29
|
*
|
|
44
|
-
* Lifecycle: construct → `attach()` (
|
|
45
|
-
*
|
|
46
|
-
* three for you.
|
|
30
|
+
* Lifecycle: construct → `attach()` (idempotent) → `dispose()` (flush read
|
|
31
|
+
* watermark + detach page listeners). `useMessages` does all three for you.
|
|
47
32
|
*
|
|
48
33
|
* Holds `(chat, roomId)` rather than a `Room` instance and re-resolves the
|
|
49
34
|
* live facade on every use — `chat.disconnect()` drops its Room facades, so
|
|
50
35
|
* a remounted provider (React StrictMode simulates this) would otherwise
|
|
51
|
-
* leave the store
|
|
52
|
-
* dispatches.
|
|
36
|
+
* leave the store bound to an orphaned Room that no longer receives
|
|
37
|
+
* dispatches. The backing collection is recreated when the facade changes.
|
|
53
38
|
*
|
|
54
39
|
* ```ts
|
|
55
40
|
* const store = new RoomStore(chat, "room_123");
|
|
@@ -63,13 +48,9 @@ export class RoomStore {
|
|
|
63
48
|
private readonly chat: NoveraChat;
|
|
64
49
|
readonly roomId: string;
|
|
65
50
|
private readonly listeners = new Set<() => void>();
|
|
66
|
-
private readonly unsubs: Array<() => void> = [];
|
|
67
51
|
|
|
68
|
-
private
|
|
69
|
-
private
|
|
70
|
-
private hasMore = false;
|
|
71
|
-
private oldestCursor: string | null = null;
|
|
72
|
-
private loadingMore = false;
|
|
52
|
+
private col: MessageCollection | null = null;
|
|
53
|
+
private colUnsub: (() => void) | null = null;
|
|
73
54
|
private historyRequested = false;
|
|
74
55
|
private snapshot: RoomSnapshot;
|
|
75
56
|
private attached = false;
|
|
@@ -91,22 +72,26 @@ export class RoomStore {
|
|
|
91
72
|
return this.chat.room(this.roomId);
|
|
92
73
|
}
|
|
93
74
|
|
|
94
|
-
/**
|
|
95
|
-
*
|
|
75
|
+
/** The backing collection, bound to the CURRENT facade. Recreated when
|
|
76
|
+
* the facade was dropped (post-`chat.disconnect()`) — the old one would
|
|
77
|
+
* never receive dispatches again. */
|
|
78
|
+
private collection(): MessageCollection {
|
|
79
|
+
const facade = this.room;
|
|
80
|
+
if (this.col && this.col.room === facade) return this.col;
|
|
81
|
+
this.colUnsub?.();
|
|
82
|
+
this.col = facade.collection();
|
|
83
|
+
this.colUnsub = this.col.on("change", () => this.rebuild());
|
|
84
|
+
this.rebuild();
|
|
85
|
+
return this.col;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Subscribe page lifecycle + ensure the collection is live. Safe to call
|
|
89
|
+
* repeatedly (no-op while attached) — matches React 18/19 StrictMode's
|
|
96
90
|
* mount → cleanup → mount effect cycle. */
|
|
97
91
|
attach(): void {
|
|
98
92
|
if (this.attached) return;
|
|
99
93
|
this.attached = true;
|
|
100
|
-
|
|
101
|
-
this.unsubs.push(
|
|
102
|
-
room.on("message", (f) => this.onIncoming(f)),
|
|
103
|
-
room.on("messageUpdated", (f) => this.onUpdated(f)),
|
|
104
|
-
room.on("messageDeleted", (f) => this.onDeleted(f)),
|
|
105
|
-
room.on("messagesCleared", (f) => this.onCleared(f)),
|
|
106
|
-
room.on("reactionAdded", (f) => this.onReactionAdded(f)),
|
|
107
|
-
room.on("reactionRemoved", (f) => this.onReactionRemoved(f)),
|
|
108
|
-
room.on("syncTick", (f) => this.onSyncTick(f)),
|
|
109
|
-
);
|
|
94
|
+
this.collection();
|
|
110
95
|
// Flush the pending read watermark when the tab goes to the background,
|
|
111
96
|
// so the last-read position isn't lost to the debounce window.
|
|
112
97
|
if (typeof document !== "undefined") {
|
|
@@ -117,13 +102,13 @@ export class RoomStore {
|
|
|
117
102
|
}
|
|
118
103
|
}
|
|
119
104
|
|
|
120
|
-
/**
|
|
121
|
-
*
|
|
105
|
+
/** Detach page listeners and flush the pending read watermark. The
|
|
106
|
+
* collection (and its state) survives, so a later `attach()` resumes
|
|
107
|
+
* cleanly — StrictMode's mount → cleanup → mount cycle keeps messages. */
|
|
122
108
|
dispose(): void {
|
|
123
109
|
if (!this.attached) return;
|
|
124
110
|
this.attached = false;
|
|
125
111
|
this.room.flushMarkRead();
|
|
126
|
-
for (const u of this.unsubs.splice(0)) u();
|
|
127
112
|
if (typeof document !== "undefined") {
|
|
128
113
|
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
|
129
114
|
}
|
|
@@ -142,11 +127,13 @@ export class RoomStore {
|
|
|
142
127
|
|
|
143
128
|
readonly getSnapshot = (): RoomSnapshot => this.snapshot;
|
|
144
129
|
|
|
145
|
-
private
|
|
130
|
+
private rebuild(): void {
|
|
131
|
+
const col = this.col;
|
|
132
|
+
if (!col) return;
|
|
146
133
|
this.snapshot = {
|
|
147
|
-
messages: [...
|
|
148
|
-
hasMore:
|
|
149
|
-
readWatermarks: { ...
|
|
134
|
+
messages: [...col.messages],
|
|
135
|
+
hasMore: col.hasMore,
|
|
136
|
+
readWatermarks: { ...col.readWatermarks },
|
|
150
137
|
};
|
|
151
138
|
for (const fn of this.listeners) fn();
|
|
152
139
|
}
|
|
@@ -155,52 +142,33 @@ export class RoomStore {
|
|
|
155
142
|
|
|
156
143
|
/** Whether `userId` has read `messageId` according to the latest watermark. */
|
|
157
144
|
isReadBy(userId: string, messageId: string): boolean {
|
|
158
|
-
|
|
159
|
-
if (!w) return false;
|
|
160
|
-
return !idGreater(messageId, w);
|
|
145
|
+
return this.collection().isReadBy(userId, messageId);
|
|
161
146
|
}
|
|
162
147
|
|
|
163
148
|
/** How many of the given users have read `messageId`. Pass the room's
|
|
164
149
|
* member ids (minus the sender) to get a KakaoTalk-style unread count:
|
|
165
150
|
* `members.length - readCount(...)`. */
|
|
166
151
|
readCount(messageId: string, userIds: Iterable<string>): number {
|
|
167
|
-
|
|
168
|
-
for (const u of userIds) if (this.isReadBy(u, messageId)) n++;
|
|
169
|
-
return n;
|
|
152
|
+
return this.collection().readCount(messageId, userIds);
|
|
170
153
|
}
|
|
171
154
|
|
|
172
155
|
// ── history ─────────────────────────────────────────────────────
|
|
173
156
|
|
|
174
|
-
/** Load the most recent page of history
|
|
175
|
-
*
|
|
176
|
-
*
|
|
157
|
+
/** Load the most recent page of history. No-op after the first call
|
|
158
|
+
* (StrictMode double-mount safe) — use `loadMore` for scrollback.
|
|
159
|
+
* 캐시(`ClientOptions.cacheStore`)가 켜져 있으면 스냅샷 즉시 렌더 →
|
|
160
|
+
* API 도착 시 통째 교체가 컬렉션 안에서 일어난다. */
|
|
177
161
|
async loadHistory(limit = 50): Promise<void> {
|
|
178
162
|
if (this.historyRequested) return;
|
|
179
163
|
this.historyRequested = true;
|
|
180
|
-
|
|
181
|
-
this.messages.unshift(...page.items.map(chatMessageFromHistory));
|
|
182
|
-
this.oldestCursor = page.nextCursor;
|
|
183
|
-
this.hasMore = page.hasMore;
|
|
184
|
-
this.emit();
|
|
164
|
+
await this.collection().load(limit);
|
|
185
165
|
}
|
|
186
166
|
|
|
187
167
|
/** Scrollback: load the page of messages older than what's on screen and
|
|
188
168
|
* prepend it. No-op while a previous call is in flight or when `hasMore`
|
|
189
169
|
* is false. Returns the number of messages added. */
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (this.loadingMore || !this.hasMore || cursor == null) return 0;
|
|
193
|
-
this.loadingMore = true;
|
|
194
|
-
try {
|
|
195
|
-
const page = await this.room.listBefore(cursor, limit);
|
|
196
|
-
this.messages.unshift(...page.items.map(chatMessageFromHistory));
|
|
197
|
-
this.oldestCursor = page.nextCursor;
|
|
198
|
-
this.hasMore = page.hasMore;
|
|
199
|
-
this.emit();
|
|
200
|
-
return page.items.length;
|
|
201
|
-
} finally {
|
|
202
|
-
this.loadingMore = false;
|
|
203
|
-
}
|
|
170
|
+
loadMore(limit = 50): Promise<number> {
|
|
171
|
+
return this.collection().loadMore(limit);
|
|
204
172
|
}
|
|
205
173
|
|
|
206
174
|
// ── outgoing ────────────────────────────────────────────────────
|
|
@@ -218,123 +186,37 @@ export class RoomStore {
|
|
|
218
186
|
customMeta?: Record<string, unknown>;
|
|
219
187
|
},
|
|
220
188
|
): void {
|
|
221
|
-
|
|
222
|
-
let tempId: string;
|
|
223
|
-
let ackPromise: Promise<string>;
|
|
224
|
-
try {
|
|
225
|
-
const sent = await this.room.send({
|
|
226
|
-
content: text,
|
|
227
|
-
...(opts?.replyToId ? { replyToId: opts.replyToId } : {}),
|
|
228
|
-
...(opts?.customType ? { customType: opts.customType } : {}),
|
|
229
|
-
...(opts?.customMeta ? { customMeta: opts.customMeta } : {}),
|
|
230
|
-
});
|
|
231
|
-
tempId = sent.tempId;
|
|
232
|
-
ackPromise = sent.messageId;
|
|
233
|
-
} catch {
|
|
234
|
-
// WS not open — surface a failed bubble rather than throwing into
|
|
235
|
-
// the void (send() is fire-and-forget).
|
|
236
|
-
const localId = `failed_${Date.now()}_${uploadSeq++}`;
|
|
237
|
-
this.messages.push({
|
|
238
|
-
...pendingChatMessage({ tempId: localId, content: text, ...opts }),
|
|
239
|
-
status: "failed",
|
|
240
|
-
});
|
|
241
|
-
this.emit();
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
this.messages.push(
|
|
245
|
-
pendingChatMessage({ tempId, content: text, ...opts }),
|
|
246
|
-
);
|
|
247
|
-
this.emit();
|
|
248
|
-
this.bindAck(tempId, ackPromise);
|
|
249
|
-
})();
|
|
189
|
+
this.collection().send(text, opts);
|
|
250
190
|
}
|
|
251
191
|
|
|
252
192
|
/** Send a file optimistically. A FILE bubble with `uploadProgress`
|
|
253
193
|
* appears immediately; progress advances during the S3 upload, then the
|
|
254
194
|
* bubble flips to `sent` on ack (or `failed`). */
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
// exists after the upload commits (sendFile resolves then).
|
|
258
|
-
const localId = `upload_${Date.now()}_${uploadSeq++}`;
|
|
259
|
-
this.messages.push(
|
|
260
|
-
pendingFileChatMessage({
|
|
261
|
-
tempId: localId,
|
|
262
|
-
name: opts?.name ?? file.name,
|
|
263
|
-
mime: file.type,
|
|
264
|
-
size: file.size,
|
|
265
|
-
}),
|
|
266
|
-
);
|
|
267
|
-
this.emit();
|
|
268
|
-
|
|
269
|
-
try {
|
|
270
|
-
const sent = await this.room.sendFile(file, {
|
|
271
|
-
...(opts ?? {}),
|
|
272
|
-
onProgress: (p) => {
|
|
273
|
-
this.mutateByTempId(localId, (m) => ({
|
|
274
|
-
...m,
|
|
275
|
-
uploadProgress: p.ratio,
|
|
276
|
-
}));
|
|
277
|
-
opts?.onProgress?.(p);
|
|
278
|
-
},
|
|
279
|
-
});
|
|
280
|
-
// Upload committed — bind the bubble to the real file id and ack.
|
|
281
|
-
this.mutateByTempId(localId, (m) => ({
|
|
282
|
-
...m,
|
|
283
|
-
fileId: sent.fileId,
|
|
284
|
-
file: m.file ? { ...m.file, id: sent.fileId } : m.file,
|
|
285
|
-
uploadProgress: null,
|
|
286
|
-
}));
|
|
287
|
-
const realId = await sent.messageId;
|
|
288
|
-
this.mutateByTempId(localId, (m) => ({
|
|
289
|
-
...m,
|
|
290
|
-
id: realId,
|
|
291
|
-
status: "sent",
|
|
292
|
-
}));
|
|
293
|
-
} catch {
|
|
294
|
-
this.mutateByTempId(localId, (m) => ({
|
|
295
|
-
...m,
|
|
296
|
-
status: "failed",
|
|
297
|
-
uploadProgress: null,
|
|
298
|
-
}));
|
|
299
|
-
}
|
|
195
|
+
sendFile(file: File, opts?: SendFileOptions): Promise<void> {
|
|
196
|
+
return this.collection().sendFile(file, opts);
|
|
300
197
|
}
|
|
301
198
|
|
|
302
199
|
/** Edit a message's content (server-side; peers get `message_updated`).
|
|
303
200
|
* The local copy updates optimistically. */
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
this.mutateById(messageId, (m) => ({
|
|
307
|
-
...m,
|
|
308
|
-
content,
|
|
309
|
-
editedCount: m.editedCount + 1,
|
|
310
|
-
}));
|
|
201
|
+
edit(messageId: string, content: string): Promise<void> {
|
|
202
|
+
return this.collection().edit(messageId, content);
|
|
311
203
|
}
|
|
312
204
|
|
|
313
205
|
/** Delete a message. `scope: "ALL"` (default) removes it for everyone;
|
|
314
206
|
* `"MY"` hides it only for the caller. Local copy flips to deleted. */
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
this.mutateById(messageId, (m) => ({ ...m, isDeleted: true }));
|
|
207
|
+
delete(messageId: string, scope: DeleteScope = "ALL"): Promise<void> {
|
|
208
|
+
return this.collection().delete(messageId, scope);
|
|
318
209
|
}
|
|
319
210
|
|
|
320
211
|
/** Add/remove the caller's reaction. Pass `myUserId` so the toggle can
|
|
321
212
|
* tell whether the caller already reacted with `key`. The authoritative
|
|
322
213
|
* state arrives back via the reaction WS events. */
|
|
323
|
-
|
|
214
|
+
toggleReaction(
|
|
324
215
|
messageId: string,
|
|
325
216
|
key: string,
|
|
326
217
|
myUserId: string,
|
|
327
218
|
): Promise<void> {
|
|
328
|
-
|
|
329
|
-
const mine =
|
|
330
|
-
m?.reactions.some(
|
|
331
|
-
(r) => r.key === key && r.userIds.includes(myUserId),
|
|
332
|
-
) ?? false;
|
|
333
|
-
if (mine) {
|
|
334
|
-
await this.room.unreact(messageId, key);
|
|
335
|
-
} else {
|
|
336
|
-
await this.room.react(messageId, key);
|
|
337
|
-
}
|
|
219
|
+
return this.collection().toggleReaction(messageId, key, myUserId);
|
|
338
220
|
}
|
|
339
221
|
|
|
340
222
|
/** Search this room's messages (server-side, content substring match).
|
|
@@ -346,92 +228,7 @@ export class RoomStore {
|
|
|
346
228
|
|
|
347
229
|
/** Mark `messageId` read (debounced inside the SDK). */
|
|
348
230
|
markRead(messageId: string): void {
|
|
349
|
-
this.
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// ── event handlers ──────────────────────────────────────────────
|
|
353
|
-
|
|
354
|
-
private onIncoming(f: WsChatReceive): void {
|
|
355
|
-
this.messages.push(chatMessageFromLive(f));
|
|
356
|
-
this.emit();
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
private onUpdated(f: WsMessageUpdated): void {
|
|
360
|
-
this.mutateById(f.message_id, (m) => ({
|
|
361
|
-
...m,
|
|
362
|
-
content: f.content ?? m.content,
|
|
363
|
-
editedCount: m.editedCount + 1,
|
|
364
|
-
}));
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
private onDeleted(f: WsMessageDeleted): void {
|
|
368
|
-
this.mutateById(f.message_id, (m) => ({ ...m, isDeleted: true }));
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
private onCleared(f: WsMessagesCleared): void {
|
|
372
|
-
// Operator wipe up to (and including) up_to_message_id — flip local
|
|
373
|
-
// copies. A null cutoff means the whole room was wiped.
|
|
374
|
-
const upTo = f.up_to_message_id;
|
|
375
|
-
let changed = false;
|
|
376
|
-
this.messages = this.messages.map((m) => {
|
|
377
|
-
if (!m.isDeleted && (upTo == null || !idGreater(m.id, upTo))) {
|
|
378
|
-
changed = true;
|
|
379
|
-
return { ...m, isDeleted: true };
|
|
380
|
-
}
|
|
381
|
-
return m;
|
|
382
|
-
});
|
|
383
|
-
if (changed) this.emit();
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
private onReactionAdded(f: WsReactionAdded): void {
|
|
387
|
-
this.mutateById(f.message_id, (m) => {
|
|
388
|
-
const next: ReactionEntry[] = m.reactions.map((r) =>
|
|
389
|
-
r.key === f.reaction_key
|
|
390
|
-
? {
|
|
391
|
-
key: r.key,
|
|
392
|
-
userIds: [...new Set([...r.userIds, f.user_id])],
|
|
393
|
-
updatedAt: f.updated_at,
|
|
394
|
-
}
|
|
395
|
-
: r,
|
|
396
|
-
);
|
|
397
|
-
if (!next.some((r) => r.key === f.reaction_key)) {
|
|
398
|
-
next.push({
|
|
399
|
-
key: f.reaction_key,
|
|
400
|
-
userIds: [f.user_id],
|
|
401
|
-
updatedAt: f.updated_at,
|
|
402
|
-
});
|
|
403
|
-
}
|
|
404
|
-
return { ...m, reactions: next };
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
private onReactionRemoved(f: WsReactionRemoved): void {
|
|
409
|
-
this.mutateById(f.message_id, (m) => {
|
|
410
|
-
const next: ReactionEntry[] = [];
|
|
411
|
-
for (const r of m.reactions) {
|
|
412
|
-
if (r.key !== f.reaction_key) {
|
|
413
|
-
next.push(r);
|
|
414
|
-
continue;
|
|
415
|
-
}
|
|
416
|
-
const users = r.userIds.filter((u) => u !== f.user_id);
|
|
417
|
-
if (users.length > 0) {
|
|
418
|
-
next.push({ key: r.key, userIds: users, updatedAt: f.updated_at });
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
return { ...m, reactions: next };
|
|
422
|
-
});
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
private onSyncTick(f: WsSyncTick): void {
|
|
426
|
-
let changed = false;
|
|
427
|
-
for (const [userId, mid] of Object.entries(f.read_states)) {
|
|
428
|
-
const cur = this.readWatermarks[userId];
|
|
429
|
-
if (!cur || idGreater(mid, cur)) {
|
|
430
|
-
this.readWatermarks[userId] = mid;
|
|
431
|
-
changed = true;
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
if (changed) this.emit();
|
|
231
|
+
this.collection().markRead(messageId);
|
|
435
232
|
}
|
|
436
233
|
|
|
437
234
|
private onVisibilityChange = (): void => {
|
|
@@ -439,37 +236,4 @@ export class RoomStore {
|
|
|
439
236
|
this.room.flushMarkRead();
|
|
440
237
|
}
|
|
441
238
|
};
|
|
442
|
-
|
|
443
|
-
// ── helpers ─────────────────────────────────────────────────────
|
|
444
|
-
|
|
445
|
-
private bindAck(tempId: string, messageId: Promise<string>): void {
|
|
446
|
-
messageId
|
|
447
|
-
.then((realId) => {
|
|
448
|
-
this.mutateByTempId(tempId, (m) => ({
|
|
449
|
-
...m,
|
|
450
|
-
id: realId,
|
|
451
|
-
status: "sent",
|
|
452
|
-
}));
|
|
453
|
-
})
|
|
454
|
-
.catch(() => {
|
|
455
|
-
this.mutateByTempId(tempId, (m) => ({ ...m, status: "failed" }));
|
|
456
|
-
});
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
private mutateById(id: string, f: (m: ChatMessage) => ChatMessage): void {
|
|
460
|
-
const i = this.messages.findIndex((m) => m.id === id);
|
|
461
|
-
if (i === -1) return;
|
|
462
|
-
this.messages[i] = f(this.messages[i]!);
|
|
463
|
-
this.emit();
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
private mutateByTempId(
|
|
467
|
-
tempId: string,
|
|
468
|
-
f: (m: ChatMessage) => ChatMessage,
|
|
469
|
-
): void {
|
|
470
|
-
const i = this.messages.findIndex((m) => m.tempId === tempId);
|
|
471
|
-
if (i === -1) return;
|
|
472
|
-
this.messages[i] = f(this.messages[i]!);
|
|
473
|
-
this.emit();
|
|
474
|
-
}
|
|
475
239
|
}
|