@noverachat/sdk-web 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.
@@ -0,0 +1,561 @@
1
+ /**
2
+ * 코어 소유 메시지 상태 — noverachat_dart `MessageCollection`의 TS 미러.
3
+ *
4
+ * RoomStore(sdk-react)에 있던 이벤트 반영·낙관적 전송·캐시 hydrate→교체
5
+ * 로직의 코어 이관본. 바인딩(React·Flutter)은 이 컬렉션을 감싸는 얇은
6
+ * 접착층이 되고, "캐시는 렌더 힌트, 서버가 항상 이긴다" 같은 불변식은
7
+ * 코어가 강제한다. diff 병합(mergeDiff)은 `applyFresh`→`mergeFresh`로
8
+ * 들어온다.
9
+ */
10
+
11
+ import type {
12
+ DeleteScope,
13
+ MessageIdStr,
14
+ MessageOut,
15
+ ReactionEntry,
16
+ SendFileOptions,
17
+ WsChatReceive,
18
+ WsMessageDeleted,
19
+ WsMessagesCleared,
20
+ WsMessageUpdated,
21
+ WsReactionAdded,
22
+ WsReactionRemoved,
23
+ WsSyncTick,
24
+ } from "../types.js";
25
+ import type { CachePolicy } from "../cache.js";
26
+ import type { Room } from "./room.js";
27
+ import {
28
+ chatMessageFromHistory,
29
+ chatMessageFromLive,
30
+ pendingChatMessage,
31
+ pendingFileChatMessage,
32
+ type ChatMessage,
33
+ } from "../chat-message.js";
34
+ import { EventBus } from "../internal/event-bus.js";
35
+ import { idGreater } from "../internal/compare-id.js";
36
+
37
+ /**
38
+ * 컬렉션 상태가 바뀐 이유. 지금 소비자(얇은 래퍼)는 "바뀌었다"만 알면
39
+ * 되므로 kind만 담는다 — 세밀한 렌더 최적화가 필요해지면 필드를
40
+ * **추가**(비파괴)로 확장한다.
41
+ *
42
+ * - `hydrated` 캐시 스냅샷이 렌더됨 (콜드 스타트 힌트 — 곧 `replaced`가 따라온다)
43
+ * - `replaced` API 응답이 스냅샷/기존 페이지를 통째로 대체함 (서버가 이겼다)
44
+ * - `inserted` 메시지가 추가됨 (라이브 수신·낙관적 버블·스크롤백 페이지)
45
+ * - `updated` 기존 메시지가 제자리 갱신됨 (수정·삭제 플래그·리액션·ack 플립)
46
+ * - `watermarks` 읽음 워터마크가 전진함
47
+ */
48
+ export type CollectionChangeKind =
49
+ | "hydrated"
50
+ | "replaced"
51
+ | "inserted"
52
+ | "updated"
53
+ | "watermarks";
54
+
55
+ /** `MessageCollection`의 `change` 이벤트로 흐르는 변경 통지. */
56
+ export interface CollectionChange {
57
+ kind: CollectionChangeKind;
58
+ }
59
+
60
+ interface CollectionEvents {
61
+ change: CollectionChange;
62
+ }
63
+
64
+ let uploadSeq = 0;
65
+
66
+ /**
67
+ * A `Room`'s message list as owned, live state — the core-side equivalent
68
+ * of Sendbird's `MessageCollection` (without a local DB).
69
+ *
70
+ * Applies the room's events into one `messages` list, drives optimistic
71
+ * sends, and runs the snapshot-cache contract (`load()` hydrates from cache
72
+ * instantly, then wholesale-replaces when the API answers). Subscribe with
73
+ * `on("change", ...)` and re-render `messages` on every event — all state
74
+ * mutations and change emits are synchronous with the WS dispatch.
75
+ *
76
+ * ```ts
77
+ * const col = chat.room("room_123").collection();
78
+ * const unsub = col.on("change", () => render(col.messages));
79
+ * await col.load();
80
+ * col.send("hello");
81
+ * col.markRead(col.messages.at(-1)!.id);
82
+ * // ...
83
+ * col.dispose(); unsub();
84
+ * ```
85
+ */
86
+ export class MessageCollection {
87
+ private readonly bus = new EventBus<CollectionEvents>();
88
+ private readonly unsubs: Array<() => void> = [];
89
+
90
+ private _messages: ChatMessage[] = [];
91
+ private _readWatermarks: Record<string, string> = {};
92
+ private _hasMore = false;
93
+ private oldestCursor: string | null = null;
94
+ private loadingMore = false;
95
+
96
+ /**
97
+ * 캐시 스냅샷으로 렌더된 머리쪽 메시지 수 — API 도착 시 이만큼 걷어내고
98
+ * 통째 교체한다(replaceByApi). 라이브 수신은 리스트 꼬리에 붙으므로
99
+ * 머리 range 제거로 스냅샷만 정확히 걷힌다.
100
+ */
101
+ private hydratedCount = 0;
102
+
103
+ constructor(
104
+ /** The room this collection is bound to. */
105
+ public readonly room: Room,
106
+ private readonly policy: CachePolicy = "replaceByApi",
107
+ ) {
108
+ this.unsubs.push(
109
+ room.on("message", (f) => this.onIncoming(f)),
110
+ room.on("messageUpdated", (f) => this.onUpdated(f)),
111
+ room.on("messageDeleted", (f) => this.onDeleted(f)),
112
+ room.on("messagesCleared", (f) => this.onCleared(f)),
113
+ room.on("reactionAdded", (f) => this.onReactionAdded(f)),
114
+ room.on("reactionRemoved", (f) => this.onReactionRemoved(f)),
115
+ room.on("syncTick", (f) => this.onSyncTick(f)),
116
+ );
117
+ }
118
+
119
+ /** The current messages, oldest first. Treat as read-only. */
120
+ get messages(): readonly ChatMessage[] {
121
+ return this._messages;
122
+ }
123
+
124
+ /** True when older history remains — fire `loadMore()` from the top of
125
+ * the scroll view. */
126
+ get hasMore(): boolean {
127
+ return this._hasMore;
128
+ }
129
+
130
+ /** Per-user read watermarks (`userId` → last read `messageId`), kept live
131
+ * from `sync_tick` frames. Treat as read-only. */
132
+ get readWatermarks(): Readonly<Record<string, string>> {
133
+ return this._readWatermarks;
134
+ }
135
+
136
+ /** State-change notifications. One event per mutation; re-read `messages`
137
+ * (and friends) when it fires. Returns an unsubscribe fn. */
138
+ on(event: "change", fn: (c: CollectionChange) => void): () => void {
139
+ return this.bus.on(event, fn);
140
+ }
141
+
142
+ /** Whether `userId` has read `messageId` according to the latest watermark. */
143
+ isReadBy(userId: string, messageId: string): boolean {
144
+ const w = this._readWatermarks[userId];
145
+ if (!w) return false;
146
+ return !idGreater(messageId, w);
147
+ }
148
+
149
+ /** How many of the given users have read `messageId`. Pass the room's
150
+ * member ids (minus the sender) to get a KakaoTalk-style unread count:
151
+ * `members.length - readCount(...)`. */
152
+ readCount(messageId: string, userIds: Iterable<string>): number {
153
+ let n = 0;
154
+ for (const u of userIds) if (this.isReadBy(u, messageId)) n++;
155
+ return n;
156
+ }
157
+
158
+ // ── loading — cache hydrate → API wholesale replace ─────────────
159
+
160
+ /**
161
+ * Load the most recent page of history.
162
+ *
163
+ * `ClientOptions.cacheStore`가 주입돼 있으면 캐시 스냅샷을 먼저 렌더한
164
+ * 뒤(`hydrated`), API 응답 도착 시 스냅샷을 걷어내고 통째로 교체한다
165
+ * (`replaced`). 스냅샷 렌더 중에는 `hasMore`/스크롤백을 열지 않는다 —
166
+ * 커서는 API 응답의 것만 신뢰한다.
167
+ */
168
+ async load(limit = 50): Promise<void> {
169
+ const pending = this.room.listRecent(limit); // API를 먼저 띄우고
170
+ await this.hydrateFromCache(); // 기다리는 동안 캐시를 그린다
171
+ const page = await pending;
172
+ this.applyFresh(page);
173
+ }
174
+
175
+ /** Scrollback: load the page of messages older than what's on screen and
176
+ * prepend it. No-op while a previous call is in flight or when `hasMore`
177
+ * is false. Returns the number of messages added. */
178
+ async loadMore(limit = 50): Promise<number> {
179
+ const cursor = this.oldestCursor;
180
+ if (this.loadingMore || !this._hasMore || cursor == null) return 0;
181
+ this.loadingMore = true;
182
+ try {
183
+ const page = await this.room.listBefore(cursor, limit);
184
+ this._messages.unshift(...page.items.map(chatMessageFromHistory));
185
+ this.oldestCursor = page.nextCursor;
186
+ this._hasMore = page.hasMore;
187
+ this.emit("inserted");
188
+ return page.items.length;
189
+ } finally {
190
+ this.loadingMore = false;
191
+ }
192
+ }
193
+
194
+ /** 캐시 스냅샷 즉시 렌더 — 빈 리스트일 때만. 실패는 조용히 무시(캐시는
195
+ * 힌트일 뿐). */
196
+ private async hydrateFromCache(): Promise<void> {
197
+ if (this._messages.length > 0 || this.hydratedCount > 0) return;
198
+ const cached = await this.room.cachedRecent();
199
+ if (!cached || cached.items.length === 0 || this._messages.length > 0) {
200
+ return;
201
+ }
202
+ this._messages.unshift(...cached.items.map(chatMessageFromHistory));
203
+ this.hydratedCount = cached.items.length;
204
+ this.emit("hydrated");
205
+ }
206
+
207
+ /**
208
+ * API 최신 페이지를 컬렉션에 반영한다.
209
+ * replaceByApi(기본)는 스냅샷을 걷어내고 통째 교체하고, mergeDiff는
210
+ * 스냅샷의 옛 메시지를 보존·병합한다(`mergeFresh`).
211
+ */
212
+ private applyFresh(page: {
213
+ items: MessageOut[];
214
+ nextCursor: MessageIdStr | null;
215
+ hasMore: boolean;
216
+ historyFrom?: number | null;
217
+ }): void {
218
+ if (this.policy === "mergeDiff" && this.hydratedCount > 0) {
219
+ this.mergeFresh(page);
220
+ return;
221
+ }
222
+ if (this.hydratedCount > 0) {
223
+ this._messages.splice(0, this.hydratedCount); // 스냅샷 통째 폐기 → 서버가 이긴다
224
+ this.hydratedCount = 0;
225
+ }
226
+ this._messages.unshift(...page.items.map(chatMessageFromHistory));
227
+ this.oldestCursor = page.nextCursor;
228
+ this._hasMore = page.hasMore;
229
+ this.emit("replaced");
230
+ }
231
+
232
+ /**
233
+ * diff 병합 — 스냅샷의 옛 메시지를 보존해 fresh 페이지 아래에 잇는다.
234
+ *
235
+ * 판정 규칙(서버 계약, 07-23 확정):
236
+ * - **컷오프분**: `history_from`(히스토리 하한, unix ms)보다 오래된 캐시
237
+ * 메시지는 재입장 컷/대화 지우기로 볼 수 없게 된 것 → 폐기.
238
+ * - **삭제분**: fresh 커버 구간(id ≥ fresh 최저 id)에 있는데 fresh에
239
+ * 없는 캐시 메시지는 그 사이 삭제된 것(타이머 만료 포함) → 폐기.
240
+ * - **갭 폴백**: 캐시의 최신 메시지가 fresh와 겹치지 않으면 누락 구간이
241
+ * 있을 수 있으므로 병합하지 않고 통째 교체.
242
+ *
243
+ * 겹치는 구간은 항상 fresh가 정답(수정 반영) — 보존되는 건 fresh 범위
244
+ * **밖**의 옛 메시지뿐이라 "서버가 항상 이긴다" 불변식은 유지된다.
245
+ */
246
+ private mergeFresh(page: {
247
+ items: MessageOut[];
248
+ nextCursor: MessageIdStr | null;
249
+ hasMore: boolean;
250
+ historyFrom?: number | null;
251
+ }): void {
252
+ const cachedHead = this._messages.slice(0, this.hydratedCount);
253
+ this._messages.splice(0, this.hydratedCount);
254
+ this.hydratedCount = 0;
255
+
256
+ const freshIds = new Set(page.items.map((m) => m.messageId));
257
+ const freshOldest = page.items.length > 0 ? page.items[0]!.messageId : null;
258
+ const hf = page.historyFrom ?? null;
259
+
260
+ // 갭 폴백: 캐시 최신이 fresh에 없으면 연속성 보장 불가 → 통째 교체.
261
+ // fresh가 방 전체(hasMore=false)인데 범위 밖 캐시가 남는 경우도 유령이므로 교체.
262
+ const overlaps =
263
+ cachedHead.length > 0 &&
264
+ freshOldest != null &&
265
+ freshIds.has(cachedHead.at(-1)!.id);
266
+ let preserved: ChatMessage[] = [];
267
+ if (overlaps && page.hasMore) {
268
+ preserved = cachedHead.filter((m) => {
269
+ if (freshIds.has(m.id)) return false; // 겹침 → fresh가 대체
270
+ if (!idGreater(freshOldest, m.id)) return false; // fresh 구간 내 부재 → 삭제분
271
+ if (hf != null && m.createdAtMs != null && m.createdAtMs < hf) {
272
+ return false; // 컷오프분
273
+ }
274
+ return true;
275
+ });
276
+ }
277
+
278
+ this._messages.unshift(...page.items.map(chatMessageFromHistory));
279
+ this._messages.unshift(...preserved);
280
+ // 보존분이 있으면 스크롤백 커서를 보존분의 최저 id로 — 중복 로드 방지.
281
+ this.oldestCursor =
282
+ preserved.length > 0 ? preserved[0]!.id : page.nextCursor;
283
+ this._hasMore = page.hasMore;
284
+ this.emit("replaced");
285
+ }
286
+
287
+ // ── optimistic sends ────────────────────────────────────────────
288
+
289
+ /**
290
+ * Send `text` optimistically: a `sending` bubble appears immediately (one
291
+ * microtask later — `Room.send` is async), then flips to `sent` (with the
292
+ * real id) on ack, or `failed` on error. Fire-and-forget: a WS-not-open
293
+ * failure surfaces as a `failed` bubble rather than a rejected promise.
294
+ */
295
+ send(
296
+ text: string,
297
+ opts?: {
298
+ replyToId?: string;
299
+ customType?: string;
300
+ customMeta?: Record<string, unknown>;
301
+ },
302
+ ): void {
303
+ void (async () => {
304
+ let tempId: string;
305
+ let ackPromise: Promise<string>;
306
+ try {
307
+ const sent = await this.room.send({
308
+ content: text,
309
+ ...(opts?.replyToId ? { replyToId: opts.replyToId } : {}),
310
+ ...(opts?.customType ? { customType: opts.customType } : {}),
311
+ ...(opts?.customMeta ? { customMeta: opts.customMeta } : {}),
312
+ });
313
+ tempId = sent.tempId;
314
+ ackPromise = sent.messageId;
315
+ } catch {
316
+ // WS not open — surface a failed bubble rather than throwing into
317
+ // the void (send() is fire-and-forget).
318
+ const localId = `failed_${Date.now()}_${uploadSeq++}`;
319
+ this._messages.push({
320
+ ...pendingChatMessage({ tempId: localId, content: text, ...opts }),
321
+ status: "failed",
322
+ });
323
+ this.emit("inserted");
324
+ return;
325
+ }
326
+ this._messages.push(pendingChatMessage({ tempId, content: text, ...opts }));
327
+ this.emit("inserted");
328
+ this.bindAck(tempId, ackPromise);
329
+ })();
330
+ }
331
+
332
+ /** Send a file optimistically. A FILE bubble with `uploadProgress`
333
+ * appears immediately; progress advances during the S3 upload, then the
334
+ * bubble flips to `sent` on ack (or `failed`). Never throws — failures
335
+ * surface as the bubble's status. */
336
+ async sendFile(file: File, opts?: SendFileOptions): Promise<void> {
337
+ // Local temp id for the optimistic bubble — the real WS temp id only
338
+ // exists after the upload commits (sendFile resolves then).
339
+ const localId = `upload_${Date.now()}_${uploadSeq++}`;
340
+ this._messages.push(
341
+ pendingFileChatMessage({
342
+ tempId: localId,
343
+ name: opts?.name ?? file.name,
344
+ mime: file.type,
345
+ size: file.size,
346
+ }),
347
+ );
348
+ this.emit("inserted");
349
+
350
+ try {
351
+ const sent = await this.room.sendFile(file, {
352
+ ...(opts ?? {}),
353
+ onProgress: (p) => {
354
+ this.mutateByTempId(localId, (m) => ({
355
+ ...m,
356
+ uploadProgress: p.ratio,
357
+ }));
358
+ opts?.onProgress?.(p);
359
+ },
360
+ });
361
+ // Upload committed — bind the bubble to the real file id and ack.
362
+ this.mutateByTempId(localId, (m) => ({
363
+ ...m,
364
+ fileId: sent.fileId,
365
+ file: m.file ? { ...m.file, id: sent.fileId } : m.file,
366
+ uploadProgress: null,
367
+ }));
368
+ const realId = await sent.messageId;
369
+ this.mutateByTempId(localId, (m) => ({
370
+ ...m,
371
+ id: realId,
372
+ status: "sent",
373
+ }));
374
+ } catch {
375
+ this.mutateByTempId(localId, (m) => ({
376
+ ...m,
377
+ status: "failed",
378
+ uploadProgress: null,
379
+ }));
380
+ }
381
+ }
382
+
383
+ /** Edit a message's content (server-side; peers get `message_updated`).
384
+ * The local copy updates optimistically. */
385
+ async edit(messageId: string, content: string): Promise<void> {
386
+ await this.room.edit(messageId, { content });
387
+ this.mutateById(messageId, (m) => ({
388
+ ...m,
389
+ content,
390
+ editedCount: m.editedCount + 1,
391
+ }));
392
+ }
393
+
394
+ /** Delete a message. `scope: "ALL"` (default) removes it for everyone;
395
+ * `"MY"` hides it only for the caller. Local copy flips to deleted. */
396
+ async delete(messageId: string, scope: DeleteScope = "ALL"): Promise<void> {
397
+ await this.room.delete(messageId, scope);
398
+ this.mutateById(messageId, (m) => ({ ...m, isDeleted: true }));
399
+ }
400
+
401
+ /** Add/remove the caller's reaction. Pass `myUserId` so the toggle can
402
+ * tell whether the caller already reacted with `key`. The authoritative
403
+ * state arrives back via the reaction WS events. */
404
+ async toggleReaction(
405
+ messageId: string,
406
+ key: string,
407
+ myUserId: string,
408
+ ): Promise<void> {
409
+ const m = this._messages.find((x) => x.id === messageId);
410
+ const mine =
411
+ m?.reactions.some(
412
+ (r) => r.key === key && r.userIds.includes(myUserId),
413
+ ) ?? false;
414
+ if (mine) {
415
+ await this.room.unreact(messageId, key);
416
+ } else {
417
+ await this.room.react(messageId, key);
418
+ }
419
+ }
420
+
421
+ /** Mark `messageId` read (debounced inside the SDK). */
422
+ markRead(messageId: string): void {
423
+ this.room.markRead(messageId);
424
+ }
425
+
426
+ /** Force-send the pending read watermark now (e.g. on unmount / tab
427
+ * backgrounding). */
428
+ flushMarkRead(): void {
429
+ this.room.flushMarkRead();
430
+ }
431
+
432
+ /** Unsubscribe from the room and flush the pending read watermark. The
433
+ * collection must not be used afterwards. */
434
+ dispose(): void {
435
+ this.room.flushMarkRead();
436
+ for (const u of this.unsubs.splice(0)) u();
437
+ this.bus.removeAll();
438
+ }
439
+
440
+ // ── event handlers ──────────────────────────────────────────────
441
+
442
+ private onIncoming(f: WsChatReceive): void {
443
+ this._messages.push(chatMessageFromLive(f));
444
+ this.emit("inserted");
445
+ }
446
+
447
+ private onUpdated(f: WsMessageUpdated): void {
448
+ this.mutateById(f.message_id, (m) => ({
449
+ ...m,
450
+ content: f.content ?? m.content,
451
+ editedCount: m.editedCount + 1,
452
+ }));
453
+ }
454
+
455
+ private onDeleted(f: WsMessageDeleted): void {
456
+ this.mutateById(f.message_id, (m) => ({ ...m, isDeleted: true }));
457
+ }
458
+
459
+ private onCleared(f: WsMessagesCleared): void {
460
+ // Operator wipe up to (and including) up_to_message_id — flip local
461
+ // copies. A null cutoff means the whole room was wiped.
462
+ const upTo = f.up_to_message_id;
463
+ let changed = false;
464
+ this._messages = this._messages.map((m) => {
465
+ if (!m.isDeleted && (upTo == null || !idGreater(m.id, upTo))) {
466
+ changed = true;
467
+ return { ...m, isDeleted: true };
468
+ }
469
+ return m;
470
+ });
471
+ if (changed) this.emit("updated");
472
+ }
473
+
474
+ private onReactionAdded(f: WsReactionAdded): void {
475
+ this.mutateById(f.message_id, (m) => {
476
+ const next: ReactionEntry[] = m.reactions.map((r) =>
477
+ r.key === f.reaction_key
478
+ ? {
479
+ key: r.key,
480
+ userIds: [...new Set([...r.userIds, f.user_id])],
481
+ updatedAt: f.updated_at,
482
+ }
483
+ : r,
484
+ );
485
+ if (!next.some((r) => r.key === f.reaction_key)) {
486
+ next.push({
487
+ key: f.reaction_key,
488
+ userIds: [f.user_id],
489
+ updatedAt: f.updated_at,
490
+ });
491
+ }
492
+ return { ...m, reactions: next };
493
+ });
494
+ }
495
+
496
+ private onReactionRemoved(f: WsReactionRemoved): void {
497
+ this.mutateById(f.message_id, (m) => {
498
+ const next: ReactionEntry[] = [];
499
+ for (const r of m.reactions) {
500
+ if (r.key !== f.reaction_key) {
501
+ next.push(r);
502
+ continue;
503
+ }
504
+ const users = r.userIds.filter((u) => u !== f.user_id);
505
+ if (users.length > 0) {
506
+ next.push({ key: r.key, userIds: users, updatedAt: f.updated_at });
507
+ }
508
+ }
509
+ return { ...m, reactions: next };
510
+ });
511
+ }
512
+
513
+ private onSyncTick(f: WsSyncTick): void {
514
+ let changed = false;
515
+ for (const [userId, mid] of Object.entries(f.read_states)) {
516
+ const cur = this._readWatermarks[userId];
517
+ if (!cur || idGreater(mid, cur)) {
518
+ this._readWatermarks[userId] = mid;
519
+ changed = true;
520
+ }
521
+ }
522
+ if (changed) this.emit("watermarks");
523
+ }
524
+
525
+ // ── helpers ─────────────────────────────────────────────────────
526
+
527
+ private bindAck(tempId: string, messageId: Promise<string>): void {
528
+ messageId
529
+ .then((realId) => {
530
+ this.mutateByTempId(tempId, (m) => ({
531
+ ...m,
532
+ id: realId,
533
+ status: "sent",
534
+ }));
535
+ })
536
+ .catch(() => {
537
+ this.mutateByTempId(tempId, (m) => ({ ...m, status: "failed" }));
538
+ });
539
+ }
540
+
541
+ private mutateById(id: string, f: (m: ChatMessage) => ChatMessage): void {
542
+ const i = this._messages.findIndex((m) => m.id === id);
543
+ if (i === -1) return;
544
+ this._messages[i] = f(this._messages[i]!);
545
+ this.emit("updated");
546
+ }
547
+
548
+ private mutateByTempId(
549
+ tempId: string,
550
+ f: (m: ChatMessage) => ChatMessage,
551
+ ): void {
552
+ const i = this._messages.findIndex((m) => m.tempId === tempId);
553
+ if (i === -1) return;
554
+ this._messages[i] = f(this._messages[i]!);
555
+ this.emit("updated");
556
+ }
557
+
558
+ private emit(kind: CollectionChangeKind): void {
559
+ this.bus.emit("change", { kind });
560
+ }
561
+ }