@noverachat/sdk-react 0.3.0 → 0.4.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/dist/index.js CHANGED
@@ -2,119 +2,12 @@
2
2
  export * from "@noverachat/sdk-web";
3
3
 
4
4
  // src/chat-message.ts
5
- function fileInlineFromWire(w) {
6
- return {
7
- id: w.id,
8
- kind: w.kind,
9
- mime: w.mime,
10
- size: w.size,
11
- name: w.name ?? null,
12
- width: w.width ?? null,
13
- height: w.height ?? null,
14
- durationSec: w.duration_sec ?? null,
15
- thumbnailStatus: w.thumbnail_status,
16
- forwardBlocked: Boolean(w.forward_blocked)
17
- };
18
- }
19
- function chatMessageFromHistory(m) {
20
- return {
21
- id: m.messageId,
22
- senderId: m.senderId ?? null,
23
- content: m.content ?? null,
24
- createdAtMs: m.createdAtMs,
25
- status: "sent",
26
- messageType: m.messageType,
27
- sender: m.sender ?? null,
28
- customType: m.customType ?? null,
29
- fileId: m.fileId ?? null,
30
- file: m.file ?? null,
31
- files: m.files ?? null,
32
- replyToId: m.replyToId ?? null,
33
- reactions: m.reactions ?? [],
34
- customMeta: m.customMeta ?? null,
35
- editedCount: m.editedCount ?? 0,
36
- uploadProgress: null,
37
- tempId: null,
38
- isDeleted: m.deletedAt != null
39
- };
40
- }
41
- function chatMessageFromLive(f) {
42
- const data = f.data ?? null;
43
- const customMeta = data && typeof data === "object" && "_customMeta" in data ? data._customMeta : null;
44
- return {
45
- id: f.message_id,
46
- senderId: f.sender_id,
47
- content: f.content ?? null,
48
- createdAtMs: f.created_at,
49
- status: "sent",
50
- messageType: f.message_type,
51
- sender: null,
52
- customType: f.custom_type ?? null,
53
- fileId: f.file_id ?? null,
54
- file: f.file ? fileInlineFromWire(f.file) : null,
55
- files: f.files ? f.files.map(fileInlineFromWire) : null,
56
- replyToId: f.reply_to_id ?? null,
57
- reactions: [],
58
- customMeta,
59
- editedCount: 0,
60
- uploadProgress: null,
61
- tempId: null,
62
- isDeleted: false
63
- };
64
- }
65
- function pendingChatMessage(p) {
66
- return {
67
- id: p.tempId,
68
- senderId: null,
69
- content: p.content,
70
- createdAtMs: Date.now(),
71
- status: "sending",
72
- messageType: "TEXT",
73
- sender: null,
74
- customType: p.customType ?? null,
75
- fileId: null,
76
- file: null,
77
- files: null,
78
- replyToId: p.replyToId ?? null,
79
- reactions: [],
80
- customMeta: p.customMeta ?? null,
81
- editedCount: 0,
82
- uploadProgress: null,
83
- tempId: p.tempId,
84
- isDeleted: false
85
- };
86
- }
87
- function pendingFileChatMessage(p) {
88
- const mime = p.mime ?? "application/octet-stream";
89
- return {
90
- id: p.tempId,
91
- senderId: null,
92
- content: null,
93
- createdAtMs: Date.now(),
94
- status: "sending",
95
- messageType: "FILE",
96
- sender: null,
97
- customType: null,
98
- fileId: null,
99
- file: {
100
- id: "",
101
- kind: mime.startsWith("image/") ? "image" : mime.startsWith("video/") ? "video" : "file",
102
- mime,
103
- size: p.size ?? 0,
104
- name: p.name,
105
- thumbnailStatus: "pending",
106
- forwardBlocked: false
107
- },
108
- files: null,
109
- replyToId: null,
110
- reactions: [],
111
- customMeta: null,
112
- editedCount: 0,
113
- uploadProgress: 0,
114
- tempId: p.tempId,
115
- isDeleted: false
116
- };
117
- }
5
+ import {
6
+ chatMessageFromHistory,
7
+ chatMessageFromLive,
8
+ pendingChatMessage,
9
+ pendingFileChatMessage
10
+ } from "@noverachat/sdk-web";
118
11
 
119
12
  // src/context.tsx
120
13
  import {
@@ -590,29 +483,13 @@ var RoomSettingsStore = class {
590
483
  }
591
484
  };
592
485
 
593
- // src/internal/compare-id.ts
594
- function idGreater(a, b) {
595
- if (!a || a === "0") return false;
596
- if (!b || b === "0") return true;
597
- try {
598
- return BigInt(a) > BigInt(b);
599
- } catch {
600
- return a > b;
601
- }
602
- }
603
-
604
486
  // src/room-store.ts
605
- var uploadSeq = 0;
606
487
  var RoomStore = class {
607
488
  chat;
608
489
  roomId;
609
490
  listeners = /* @__PURE__ */ new Set();
610
- unsubs = [];
611
- messages = [];
612
- readWatermarks = {};
613
- hasMore = false;
614
- oldestCursor = null;
615
- loadingMore = false;
491
+ col = null;
492
+ colUnsub = null;
616
493
  historyRequested = false;
617
494
  snapshot;
618
495
  attached = false;
@@ -631,22 +508,25 @@ var RoomStore = class {
631
508
  get room() {
632
509
  return this.chat.room(this.roomId);
633
510
  }
634
- /** Subscribe to WS events + page lifecycle. Safe to call repeatedly
635
- * (no-op while attached) — matches React 18/19 StrictMode's
511
+ /** The backing collection, bound to the CURRENT facade. Recreated when
512
+ * the facade was dropped (post-`chat.disconnect()`)the old one would
513
+ * never receive dispatches again. */
514
+ collection() {
515
+ const facade = this.room;
516
+ if (this.col && this.col.room === facade) return this.col;
517
+ this.colUnsub?.();
518
+ this.col = facade.collection();
519
+ this.colUnsub = this.col.on("change", () => this.rebuild());
520
+ this.rebuild();
521
+ return this.col;
522
+ }
523
+ /** Subscribe page lifecycle + ensure the collection is live. Safe to call
524
+ * repeatedly (no-op while attached) — matches React 18/19 StrictMode's
636
525
  * mount → cleanup → mount effect cycle. */
637
526
  attach() {
638
527
  if (this.attached) return;
639
528
  this.attached = true;
640
- const room = this.room;
641
- this.unsubs.push(
642
- room.on("message", (f) => this.onIncoming(f)),
643
- room.on("messageUpdated", (f) => this.onUpdated(f)),
644
- room.on("messageDeleted", (f) => this.onDeleted(f)),
645
- room.on("messagesCleared", (f) => this.onCleared(f)),
646
- room.on("reactionAdded", (f) => this.onReactionAdded(f)),
647
- room.on("reactionRemoved", (f) => this.onReactionRemoved(f)),
648
- room.on("syncTick", (f) => this.onSyncTick(f))
649
- );
529
+ this.collection();
650
530
  if (typeof document !== "undefined") {
651
531
  document.addEventListener("visibilitychange", this.onVisibilityChange);
652
532
  }
@@ -654,13 +534,13 @@ var RoomStore = class {
654
534
  window.addEventListener("pagehide", this.onPageHide);
655
535
  }
656
536
  }
657
- /** Unsubscribe everything and flush the pending read watermark. The store
658
- * keeps its message state, so a later `attach()` resumes cleanly. */
537
+ /** Detach page listeners and flush the pending read watermark. The
538
+ * collection (and its state) survives, so a later `attach()` resumes
539
+ * cleanly — StrictMode's mount → cleanup → mount cycle keeps messages. */
659
540
  dispose() {
660
541
  if (!this.attached) return;
661
542
  this.attached = false;
662
543
  this.room.flushMarkRead();
663
- for (const u of this.unsubs.splice(0)) u();
664
544
  if (typeof document !== "undefined") {
665
545
  document.removeEventListener("visibilitychange", this.onVisibilityChange);
666
546
  }
@@ -675,59 +555,42 @@ var RoomStore = class {
675
555
  return () => this.listeners.delete(fn);
676
556
  };
677
557
  getSnapshot = () => this.snapshot;
678
- emit() {
558
+ rebuild() {
559
+ const col = this.col;
560
+ if (!col) return;
679
561
  this.snapshot = {
680
- messages: [...this.messages],
681
- hasMore: this.hasMore,
682
- readWatermarks: { ...this.readWatermarks }
562
+ messages: [...col.messages],
563
+ hasMore: col.hasMore,
564
+ readWatermarks: { ...col.readWatermarks }
683
565
  };
684
566
  for (const fn of this.listeners) fn();
685
567
  }
686
568
  // ── read receipts ───────────────────────────────────────────────
687
569
  /** Whether `userId` has read `messageId` according to the latest watermark. */
688
570
  isReadBy(userId, messageId) {
689
- const w = this.readWatermarks[userId];
690
- if (!w) return false;
691
- return !idGreater(messageId, w);
571
+ return this.collection().isReadBy(userId, messageId);
692
572
  }
693
573
  /** How many of the given users have read `messageId`. Pass the room's
694
574
  * member ids (minus the sender) to get a KakaoTalk-style unread count:
695
575
  * `members.length - readCount(...)`. */
696
576
  readCount(messageId, userIds) {
697
- let n = 0;
698
- for (const u of userIds) if (this.isReadBy(u, messageId)) n++;
699
- return n;
577
+ return this.collection().readCount(messageId, userIds);
700
578
  }
701
579
  // ── history ─────────────────────────────────────────────────────
702
- /** Load the most recent page of history and prepend it. No-op after the
703
- * first call (StrictMode double-mount safe) — use `loadMore` for
704
- * scrollback. */
580
+ /** Load the most recent page of history. No-op after the first call
581
+ * (StrictMode double-mount safe) — use `loadMore` for scrollback.
582
+ * 캐시(`ClientOptions.cacheStore`)가 켜져 있으면 스냅샷 즉시 렌더 →
583
+ * API 도착 시 통째 교체가 컬렉션 안에서 일어난다. */
705
584
  async loadHistory(limit = 50) {
706
585
  if (this.historyRequested) return;
707
586
  this.historyRequested = true;
708
- const page = await this.room.listRecent(limit);
709
- this.messages.unshift(...page.items.map(chatMessageFromHistory));
710
- this.oldestCursor = page.nextCursor;
711
- this.hasMore = page.hasMore;
712
- this.emit();
587
+ await this.collection().load(limit);
713
588
  }
714
589
  /** Scrollback: load the page of messages older than what's on screen and
715
590
  * prepend it. No-op while a previous call is in flight or when `hasMore`
716
591
  * is false. Returns the number of messages added. */
717
- async loadMore(limit = 50) {
718
- const cursor = this.oldestCursor;
719
- if (this.loadingMore || !this.hasMore || cursor == null) return 0;
720
- this.loadingMore = true;
721
- try {
722
- const page = await this.room.listBefore(cursor, limit);
723
- this.messages.unshift(...page.items.map(chatMessageFromHistory));
724
- this.oldestCursor = page.nextCursor;
725
- this.hasMore = page.hasMore;
726
- this.emit();
727
- return page.items.length;
728
- } finally {
729
- this.loadingMore = false;
730
- }
592
+ loadMore(limit = 50) {
593
+ return this.collection().loadMore(limit);
731
594
  }
732
595
  // ── outgoing ────────────────────────────────────────────────────
733
596
  /** Send `text` optimistically: a `sending` bubble appears immediately,
@@ -736,108 +599,29 @@ var RoomStore = class {
736
599
  * Optional extras ride along: `replyToId` for replies, `customType` /
737
600
  * `customMeta` for app-defined conventions (e.g. message priority). */
738
601
  send(text, opts) {
739
- void (async () => {
740
- let tempId;
741
- let ackPromise;
742
- try {
743
- const sent = await this.room.send({
744
- content: text,
745
- ...opts?.replyToId ? { replyToId: opts.replyToId } : {},
746
- ...opts?.customType ? { customType: opts.customType } : {},
747
- ...opts?.customMeta ? { customMeta: opts.customMeta } : {}
748
- });
749
- tempId = sent.tempId;
750
- ackPromise = sent.messageId;
751
- } catch {
752
- const localId = `failed_${Date.now()}_${uploadSeq++}`;
753
- this.messages.push({
754
- ...pendingChatMessage({ tempId: localId, content: text, ...opts }),
755
- status: "failed"
756
- });
757
- this.emit();
758
- return;
759
- }
760
- this.messages.push(
761
- pendingChatMessage({ tempId, content: text, ...opts })
762
- );
763
- this.emit();
764
- this.bindAck(tempId, ackPromise);
765
- })();
602
+ this.collection().send(text, opts);
766
603
  }
767
604
  /** Send a file optimistically. A FILE bubble with `uploadProgress`
768
605
  * appears immediately; progress advances during the S3 upload, then the
769
606
  * bubble flips to `sent` on ack (or `failed`). */
770
- async sendFile(file, opts) {
771
- const localId = `upload_${Date.now()}_${uploadSeq++}`;
772
- this.messages.push(
773
- pendingFileChatMessage({
774
- tempId: localId,
775
- name: opts?.name ?? file.name,
776
- mime: file.type,
777
- size: file.size
778
- })
779
- );
780
- this.emit();
781
- try {
782
- const sent = await this.room.sendFile(file, {
783
- ...opts ?? {},
784
- onProgress: (p) => {
785
- this.mutateByTempId(localId, (m) => ({
786
- ...m,
787
- uploadProgress: p.ratio
788
- }));
789
- opts?.onProgress?.(p);
790
- }
791
- });
792
- this.mutateByTempId(localId, (m) => ({
793
- ...m,
794
- fileId: sent.fileId,
795
- file: m.file ? { ...m.file, id: sent.fileId } : m.file,
796
- uploadProgress: null
797
- }));
798
- const realId = await sent.messageId;
799
- this.mutateByTempId(localId, (m) => ({
800
- ...m,
801
- id: realId,
802
- status: "sent"
803
- }));
804
- } catch {
805
- this.mutateByTempId(localId, (m) => ({
806
- ...m,
807
- status: "failed",
808
- uploadProgress: null
809
- }));
810
- }
607
+ sendFile(file, opts) {
608
+ return this.collection().sendFile(file, opts);
811
609
  }
812
610
  /** Edit a message's content (server-side; peers get `message_updated`).
813
611
  * The local copy updates optimistically. */
814
- async edit(messageId, content) {
815
- await this.room.edit(messageId, { content });
816
- this.mutateById(messageId, (m) => ({
817
- ...m,
818
- content,
819
- editedCount: m.editedCount + 1
820
- }));
612
+ edit(messageId, content) {
613
+ return this.collection().edit(messageId, content);
821
614
  }
822
615
  /** Delete a message. `scope: "ALL"` (default) removes it for everyone;
823
616
  * `"MY"` hides it only for the caller. Local copy flips to deleted. */
824
- async delete(messageId, scope = "ALL") {
825
- await this.room.delete(messageId, scope);
826
- this.mutateById(messageId, (m) => ({ ...m, isDeleted: true }));
617
+ delete(messageId, scope = "ALL") {
618
+ return this.collection().delete(messageId, scope);
827
619
  }
828
620
  /** Add/remove the caller's reaction. Pass `myUserId` so the toggle can
829
621
  * tell whether the caller already reacted with `key`. The authoritative
830
622
  * state arrives back via the reaction WS events. */
831
- async toggleReaction(messageId, key, myUserId) {
832
- const m = this.messages.find((x) => x.id === messageId);
833
- const mine = m?.reactions.some(
834
- (r) => r.key === key && r.userIds.includes(myUserId)
835
- ) ?? false;
836
- if (mine) {
837
- await this.room.unreact(messageId, key);
838
- } else {
839
- await this.room.react(messageId, key);
840
- }
623
+ toggleReaction(messageId, key, myUserId) {
624
+ return this.collection().toggleReaction(messageId, key, myUserId);
841
625
  }
842
626
  /** Search this room's messages (server-side, content substring match).
843
627
  * Returns view models without touching the message list. */
@@ -847,110 +631,13 @@ var RoomStore = class {
847
631
  }
848
632
  /** Mark `messageId` read (debounced inside the SDK). */
849
633
  markRead(messageId) {
850
- this.room.markRead(messageId);
851
- }
852
- // ── event handlers ──────────────────────────────────────────────
853
- onIncoming(f) {
854
- this.messages.push(chatMessageFromLive(f));
855
- this.emit();
856
- }
857
- onUpdated(f) {
858
- this.mutateById(f.message_id, (m) => ({
859
- ...m,
860
- content: f.content ?? m.content,
861
- editedCount: m.editedCount + 1
862
- }));
863
- }
864
- onDeleted(f) {
865
- this.mutateById(f.message_id, (m) => ({ ...m, isDeleted: true }));
866
- }
867
- onCleared(f) {
868
- const upTo = f.up_to_message_id;
869
- let changed = false;
870
- this.messages = this.messages.map((m) => {
871
- if (!m.isDeleted && (upTo == null || !idGreater(m.id, upTo))) {
872
- changed = true;
873
- return { ...m, isDeleted: true };
874
- }
875
- return m;
876
- });
877
- if (changed) this.emit();
878
- }
879
- onReactionAdded(f) {
880
- this.mutateById(f.message_id, (m) => {
881
- const next = m.reactions.map(
882
- (r) => r.key === f.reaction_key ? {
883
- key: r.key,
884
- userIds: [.../* @__PURE__ */ new Set([...r.userIds, f.user_id])],
885
- updatedAt: f.updated_at
886
- } : r
887
- );
888
- if (!next.some((r) => r.key === f.reaction_key)) {
889
- next.push({
890
- key: f.reaction_key,
891
- userIds: [f.user_id],
892
- updatedAt: f.updated_at
893
- });
894
- }
895
- return { ...m, reactions: next };
896
- });
897
- }
898
- onReactionRemoved(f) {
899
- this.mutateById(f.message_id, (m) => {
900
- const next = [];
901
- for (const r of m.reactions) {
902
- if (r.key !== f.reaction_key) {
903
- next.push(r);
904
- continue;
905
- }
906
- const users = r.userIds.filter((u) => u !== f.user_id);
907
- if (users.length > 0) {
908
- next.push({ key: r.key, userIds: users, updatedAt: f.updated_at });
909
- }
910
- }
911
- return { ...m, reactions: next };
912
- });
913
- }
914
- onSyncTick(f) {
915
- let changed = false;
916
- for (const [userId, mid] of Object.entries(f.read_states)) {
917
- const cur = this.readWatermarks[userId];
918
- if (!cur || idGreater(mid, cur)) {
919
- this.readWatermarks[userId] = mid;
920
- changed = true;
921
- }
922
- }
923
- if (changed) this.emit();
634
+ this.collection().markRead(messageId);
924
635
  }
925
636
  onVisibilityChange = () => {
926
637
  if (typeof document !== "undefined" && document.visibilityState === "hidden") {
927
638
  this.room.flushMarkRead();
928
639
  }
929
640
  };
930
- // ── helpers ─────────────────────────────────────────────────────
931
- bindAck(tempId, messageId) {
932
- messageId.then((realId) => {
933
- this.mutateByTempId(tempId, (m) => ({
934
- ...m,
935
- id: realId,
936
- status: "sent"
937
- }));
938
- }).catch(() => {
939
- this.mutateByTempId(tempId, (m) => ({ ...m, status: "failed" }));
940
- });
941
- }
942
- mutateById(id, f) {
943
- const i = this.messages.findIndex((m) => m.id === id);
944
- if (i === -1) return;
945
- this.messages[i] = f(this.messages[i]);
946
- this.emit();
947
- }
948
- mutateByTempId(tempId, f) {
949
- const i = this.messages.findIndex((m) => m.tempId === tempId);
950
- if (i === -1) return;
951
- this.messages[i] = f(this.messages[i]);
952
- this.emit();
953
- }
954
641
  };
955
642
 
956
643
  // src/use-messages.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noverachat/sdk-react",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "React hooks/bindings for NoveraChat — @noverachat/sdk-web 위에 얹는 얇은 래퍼",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,7 +24,7 @@
24
24
  "react": ">=18"
25
25
  },
26
26
  "dependencies": {
27
- "@noverachat/sdk-web": "0.3.0"
27
+ "@noverachat/sdk-web": "0.4.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/react": "^19.0.0",