@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/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
- * Turns a `Room`'s events into an externally-subscribable message list and
41
- * drives optimistic sends. Framework-agnostic on purpose so it can also be
42
- * used outside hooks (tests, non-React glue).
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()` (subscribe WS events; idempotent)
45
- * `dispose()` (flush read watermark + unsubscribe). `useMessages` does all
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 subscribed to an orphaned Room that no longer receives
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 messages: ChatMessage[] = [];
69
- private readWatermarks: Record<string, string> = {};
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
- /** Subscribe to WS events + page lifecycle. Safe to call repeatedly
95
- * (no-op while attached) — matches React 18/19 StrictMode's
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
- const room = this.room; // resolve once — all subs must share one facade
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
- /** Unsubscribe everything and flush the pending read watermark. The store
121
- * keeps its message state, so a later `attach()` resumes cleanly. */
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 emit(): void {
130
+ private rebuild(): void {
131
+ const col = this.col;
132
+ if (!col) return;
146
133
  this.snapshot = {
147
- messages: [...this.messages],
148
- hasMore: this.hasMore,
149
- readWatermarks: { ...this.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
- const w = this.readWatermarks[userId];
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
- let n = 0;
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 and prepend it. No-op after the
175
- * first call (StrictMode double-mount safe) — use `loadMore` for
176
- * scrollback. */
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
- const page = await this.room.listRecent(limit);
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
- async loadMore(limit = 50): Promise<number> {
191
- const cursor = this.oldestCursor;
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
- void (async () => {
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
- async sendFile(file: File, opts?: SendFileOptions): Promise<void> {
256
- // Local temp id for the optimistic bubble — the real WS temp id only
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
- async edit(messageId: string, content: string): Promise<void> {
305
- await this.room.edit(messageId, { content });
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
- async delete(messageId: string, scope: DeleteScope = "ALL"): Promise<void> {
316
- await this.room.delete(messageId, scope);
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
- async toggleReaction(
214
+ toggleReaction(
324
215
  messageId: string,
325
216
  key: string,
326
217
  myUserId: string,
327
218
  ): Promise<void> {
328
- const m = this.messages.find((x) => x.id === messageId);
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.room.markRead(messageId);
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
  }