@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 CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  NoveraChat용 React 훅/바인딩 — [`@noverachat/sdk-web`](../sdk-web)(헤드리스 `NoveraChat` / `Room` 코어) 위의 얇은 레이어. 채팅 로직을 재구현하지 않고 채팅 UI도 제공하지 않는다 — 이 패키지는 글루만 담당한다: 라이프사이클을 관리하는 프로바이더와, SDK 이벤트를 React 상태로 옮기는 훅들.
6
6
 
7
+ > 팁: 클라이언트 옵션에 [`cacheStore`](../sdk-web/docs/core/caching.ko.md)만 주입하면 `useMessages`가 콜드 스타트에서 마지막 히스토리를 즉시 렌더한다(스냅샷 캐시) — 화면 코드 변경 없음.
8
+
7
9
  ## 설치
8
10
 
9
11
  ```sh
package/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  React hooks/bindings for NoveraChat — a thin layer over [`@noverachat/sdk-web`](../sdk-web) (the headless `NoveraChat` / `Room` core). It does NOT reimplement chat logic and it does NOT ship chat UI — this package is only the glue: a lifecycle-managed provider and hooks that turn the SDK's events into React state.
6
6
 
7
+ > Tip: inject a [`cacheStore`](../sdk-web/docs/core/caching.md) in the client options and `useMessages` renders the last known history instantly on cold start (snapshot cache) — zero screen-code changes.
8
+
7
9
  ## Install
8
10
 
9
11
  ```sh
package/dist/index.cjs CHANGED
@@ -27,8 +27,8 @@ __export(index_exports, {
27
27
  RoomListStore: () => RoomListStore,
28
28
  RoomSettingsStore: () => RoomSettingsStore,
29
29
  RoomStore: () => RoomStore,
30
- chatMessageFromHistory: () => chatMessageFromHistory,
31
- chatMessageFromLive: () => chatMessageFromLive,
30
+ chatMessageFromHistory: () => import_sdk_web.chatMessageFromHistory,
31
+ chatMessageFromLive: () => import_sdk_web.chatMessageFromLive,
32
32
  useMemberList: () => useMemberList,
33
33
  useMessages: () => useMessages,
34
34
  useNoveraChat: () => useNoveraChat,
@@ -43,127 +43,15 @@ module.exports = __toCommonJS(index_exports);
43
43
  __reExport(index_exports, require("@noverachat/sdk-web"), module.exports);
44
44
 
45
45
  // src/chat-message.ts
46
- function fileInlineFromWire(w) {
47
- return {
48
- id: w.id,
49
- kind: w.kind,
50
- mime: w.mime,
51
- size: w.size,
52
- name: w.name ?? null,
53
- width: w.width ?? null,
54
- height: w.height ?? null,
55
- durationSec: w.duration_sec ?? null,
56
- thumbnailStatus: w.thumbnail_status,
57
- forwardBlocked: Boolean(w.forward_blocked)
58
- };
59
- }
60
- function chatMessageFromHistory(m) {
61
- return {
62
- id: m.messageId,
63
- senderId: m.senderId ?? null,
64
- content: m.content ?? null,
65
- createdAtMs: m.createdAtMs,
66
- status: "sent",
67
- messageType: m.messageType,
68
- sender: m.sender ?? null,
69
- customType: m.customType ?? null,
70
- fileId: m.fileId ?? null,
71
- file: m.file ?? null,
72
- files: m.files ?? null,
73
- replyToId: m.replyToId ?? null,
74
- reactions: m.reactions ?? [],
75
- customMeta: m.customMeta ?? null,
76
- editedCount: m.editedCount ?? 0,
77
- uploadProgress: null,
78
- tempId: null,
79
- isDeleted: m.deletedAt != null
80
- };
81
- }
82
- function chatMessageFromLive(f) {
83
- const data = f.data ?? null;
84
- const customMeta = data && typeof data === "object" && "_customMeta" in data ? data._customMeta : null;
85
- return {
86
- id: f.message_id,
87
- senderId: f.sender_id,
88
- content: f.content ?? null,
89
- createdAtMs: f.created_at,
90
- status: "sent",
91
- messageType: f.message_type,
92
- sender: null,
93
- customType: f.custom_type ?? null,
94
- fileId: f.file_id ?? null,
95
- file: f.file ? fileInlineFromWire(f.file) : null,
96
- files: f.files ? f.files.map(fileInlineFromWire) : null,
97
- replyToId: f.reply_to_id ?? null,
98
- reactions: [],
99
- customMeta,
100
- editedCount: 0,
101
- uploadProgress: null,
102
- tempId: null,
103
- isDeleted: false
104
- };
105
- }
106
- function pendingChatMessage(p) {
107
- return {
108
- id: p.tempId,
109
- senderId: null,
110
- content: p.content,
111
- createdAtMs: Date.now(),
112
- status: "sending",
113
- messageType: "TEXT",
114
- sender: null,
115
- customType: p.customType ?? null,
116
- fileId: null,
117
- file: null,
118
- files: null,
119
- replyToId: p.replyToId ?? null,
120
- reactions: [],
121
- customMeta: p.customMeta ?? null,
122
- editedCount: 0,
123
- uploadProgress: null,
124
- tempId: p.tempId,
125
- isDeleted: false
126
- };
127
- }
128
- function pendingFileChatMessage(p) {
129
- const mime = p.mime ?? "application/octet-stream";
130
- return {
131
- id: p.tempId,
132
- senderId: null,
133
- content: null,
134
- createdAtMs: Date.now(),
135
- status: "sending",
136
- messageType: "FILE",
137
- sender: null,
138
- customType: null,
139
- fileId: null,
140
- file: {
141
- id: "",
142
- kind: mime.startsWith("image/") ? "image" : mime.startsWith("video/") ? "video" : "file",
143
- mime,
144
- size: p.size ?? 0,
145
- name: p.name,
146
- thumbnailStatus: "pending",
147
- forwardBlocked: false
148
- },
149
- files: null,
150
- replyToId: null,
151
- reactions: [],
152
- customMeta: null,
153
- editedCount: 0,
154
- uploadProgress: 0,
155
- tempId: p.tempId,
156
- isDeleted: false
157
- };
158
- }
46
+ var import_sdk_web = require("@noverachat/sdk-web");
159
47
 
160
48
  // src/context.tsx
161
49
  var import_react = require("react");
162
- var import_sdk_web = require("@noverachat/sdk-web");
50
+ var import_sdk_web2 = require("@noverachat/sdk-web");
163
51
  var import_jsx_runtime = require("react/jsx-runtime");
164
52
  var NoveraChatContext = (0, import_react.createContext)(null);
165
53
  function NoveraChatProvider(props) {
166
- const [chat] = (0, import_react.useState)(() => new import_sdk_web.NoveraChat(props.options));
54
+ const [chat] = (0, import_react.useState)(() => new import_sdk_web2.NoveraChat(props.options));
167
55
  (0, import_react.useEffect)(() => {
168
56
  void chat.connect();
169
57
  return () => chat.disconnect();
@@ -357,7 +245,7 @@ function itemFromUnread(r) {
357
245
  lastMessagePreview: r.lastMessagePreview ?? null,
358
246
  memberCount: r.memberCount ?? 0,
359
247
  members,
360
- lastMessageAtMs: null,
248
+ lastMessageAtMs: r.lastMessageAt ?? null,
361
249
  anyMemberOnline: members.some((m) => m.isOnline === true)
362
250
  };
363
251
  }
@@ -626,29 +514,13 @@ var RoomSettingsStore = class {
626
514
  }
627
515
  };
628
516
 
629
- // src/internal/compare-id.ts
630
- function idGreater(a, b) {
631
- if (!a || a === "0") return false;
632
- if (!b || b === "0") return true;
633
- try {
634
- return BigInt(a) > BigInt(b);
635
- } catch {
636
- return a > b;
637
- }
638
- }
639
-
640
517
  // src/room-store.ts
641
- var uploadSeq = 0;
642
518
  var RoomStore = class {
643
519
  chat;
644
520
  roomId;
645
521
  listeners = /* @__PURE__ */ new Set();
646
- unsubs = [];
647
- messages = [];
648
- readWatermarks = {};
649
- hasMore = false;
650
- oldestCursor = null;
651
- loadingMore = false;
522
+ col = null;
523
+ colUnsub = null;
652
524
  historyRequested = false;
653
525
  snapshot;
654
526
  attached = false;
@@ -667,22 +539,25 @@ var RoomStore = class {
667
539
  get room() {
668
540
  return this.chat.room(this.roomId);
669
541
  }
670
- /** Subscribe to WS events + page lifecycle. Safe to call repeatedly
671
- * (no-op while attached) — matches React 18/19 StrictMode's
542
+ /** The backing collection, bound to the CURRENT facade. Recreated when
543
+ * the facade was dropped (post-`chat.disconnect()`)the old one would
544
+ * never receive dispatches again. */
545
+ collection() {
546
+ const facade = this.room;
547
+ if (this.col && this.col.room === facade) return this.col;
548
+ this.colUnsub?.();
549
+ this.col = facade.collection();
550
+ this.colUnsub = this.col.on("change", () => this.rebuild());
551
+ this.rebuild();
552
+ return this.col;
553
+ }
554
+ /** Subscribe page lifecycle + ensure the collection is live. Safe to call
555
+ * repeatedly (no-op while attached) — matches React 18/19 StrictMode's
672
556
  * mount → cleanup → mount effect cycle. */
673
557
  attach() {
674
558
  if (this.attached) return;
675
559
  this.attached = true;
676
- const room = this.room;
677
- this.unsubs.push(
678
- room.on("message", (f) => this.onIncoming(f)),
679
- room.on("messageUpdated", (f) => this.onUpdated(f)),
680
- room.on("messageDeleted", (f) => this.onDeleted(f)),
681
- room.on("messagesCleared", (f) => this.onCleared(f)),
682
- room.on("reactionAdded", (f) => this.onReactionAdded(f)),
683
- room.on("reactionRemoved", (f) => this.onReactionRemoved(f)),
684
- room.on("syncTick", (f) => this.onSyncTick(f))
685
- );
560
+ this.collection();
686
561
  if (typeof document !== "undefined") {
687
562
  document.addEventListener("visibilitychange", this.onVisibilityChange);
688
563
  }
@@ -690,13 +565,13 @@ var RoomStore = class {
690
565
  window.addEventListener("pagehide", this.onPageHide);
691
566
  }
692
567
  }
693
- /** Unsubscribe everything and flush the pending read watermark. The store
694
- * keeps its message state, so a later `attach()` resumes cleanly. */
568
+ /** Detach page listeners and flush the pending read watermark. The
569
+ * collection (and its state) survives, so a later `attach()` resumes
570
+ * cleanly — StrictMode's mount → cleanup → mount cycle keeps messages. */
695
571
  dispose() {
696
572
  if (!this.attached) return;
697
573
  this.attached = false;
698
574
  this.room.flushMarkRead();
699
- for (const u of this.unsubs.splice(0)) u();
700
575
  if (typeof document !== "undefined") {
701
576
  document.removeEventListener("visibilitychange", this.onVisibilityChange);
702
577
  }
@@ -711,59 +586,42 @@ var RoomStore = class {
711
586
  return () => this.listeners.delete(fn);
712
587
  };
713
588
  getSnapshot = () => this.snapshot;
714
- emit() {
589
+ rebuild() {
590
+ const col = this.col;
591
+ if (!col) return;
715
592
  this.snapshot = {
716
- messages: [...this.messages],
717
- hasMore: this.hasMore,
718
- readWatermarks: { ...this.readWatermarks }
593
+ messages: [...col.messages],
594
+ hasMore: col.hasMore,
595
+ readWatermarks: { ...col.readWatermarks }
719
596
  };
720
597
  for (const fn of this.listeners) fn();
721
598
  }
722
599
  // ── read receipts ───────────────────────────────────────────────
723
600
  /** Whether `userId` has read `messageId` according to the latest watermark. */
724
601
  isReadBy(userId, messageId) {
725
- const w = this.readWatermarks[userId];
726
- if (!w) return false;
727
- return !idGreater(messageId, w);
602
+ return this.collection().isReadBy(userId, messageId);
728
603
  }
729
604
  /** How many of the given users have read `messageId`. Pass the room's
730
605
  * member ids (minus the sender) to get a KakaoTalk-style unread count:
731
606
  * `members.length - readCount(...)`. */
732
607
  readCount(messageId, userIds) {
733
- let n = 0;
734
- for (const u of userIds) if (this.isReadBy(u, messageId)) n++;
735
- return n;
608
+ return this.collection().readCount(messageId, userIds);
736
609
  }
737
610
  // ── history ─────────────────────────────────────────────────────
738
- /** Load the most recent page of history and prepend it. No-op after the
739
- * first call (StrictMode double-mount safe) — use `loadMore` for
740
- * scrollback. */
611
+ /** Load the most recent page of history. No-op after the first call
612
+ * (StrictMode double-mount safe) — use `loadMore` for scrollback.
613
+ * 캐시(`ClientOptions.cacheStore`)가 켜져 있으면 스냅샷 즉시 렌더 →
614
+ * API 도착 시 통째 교체가 컬렉션 안에서 일어난다. */
741
615
  async loadHistory(limit = 50) {
742
616
  if (this.historyRequested) return;
743
617
  this.historyRequested = true;
744
- const page = await this.room.listRecent(limit);
745
- this.messages.unshift(...page.items.map(chatMessageFromHistory));
746
- this.oldestCursor = page.nextCursor;
747
- this.hasMore = page.hasMore;
748
- this.emit();
618
+ await this.collection().load(limit);
749
619
  }
750
620
  /** Scrollback: load the page of messages older than what's on screen and
751
621
  * prepend it. No-op while a previous call is in flight or when `hasMore`
752
622
  * is false. Returns the number of messages added. */
753
- async loadMore(limit = 50) {
754
- const cursor = this.oldestCursor;
755
- if (this.loadingMore || !this.hasMore || cursor == null) return 0;
756
- this.loadingMore = true;
757
- try {
758
- const page = await this.room.listBefore(cursor, limit);
759
- this.messages.unshift(...page.items.map(chatMessageFromHistory));
760
- this.oldestCursor = page.nextCursor;
761
- this.hasMore = page.hasMore;
762
- this.emit();
763
- return page.items.length;
764
- } finally {
765
- this.loadingMore = false;
766
- }
623
+ loadMore(limit = 50) {
624
+ return this.collection().loadMore(limit);
767
625
  }
768
626
  // ── outgoing ────────────────────────────────────────────────────
769
627
  /** Send `text` optimistically: a `sending` bubble appears immediately,
@@ -772,221 +630,45 @@ var RoomStore = class {
772
630
  * Optional extras ride along: `replyToId` for replies, `customType` /
773
631
  * `customMeta` for app-defined conventions (e.g. message priority). */
774
632
  send(text, opts) {
775
- void (async () => {
776
- let tempId;
777
- let ackPromise;
778
- try {
779
- const sent = await this.room.send({
780
- content: text,
781
- ...opts?.replyToId ? { replyToId: opts.replyToId } : {},
782
- ...opts?.customType ? { customType: opts.customType } : {},
783
- ...opts?.customMeta ? { customMeta: opts.customMeta } : {}
784
- });
785
- tempId = sent.tempId;
786
- ackPromise = sent.messageId;
787
- } catch {
788
- const localId = `failed_${Date.now()}_${uploadSeq++}`;
789
- this.messages.push({
790
- ...pendingChatMessage({ tempId: localId, content: text, ...opts }),
791
- status: "failed"
792
- });
793
- this.emit();
794
- return;
795
- }
796
- this.messages.push(
797
- pendingChatMessage({ tempId, content: text, ...opts })
798
- );
799
- this.emit();
800
- this.bindAck(tempId, ackPromise);
801
- })();
633
+ this.collection().send(text, opts);
802
634
  }
803
635
  /** Send a file optimistically. A FILE bubble with `uploadProgress`
804
636
  * appears immediately; progress advances during the S3 upload, then the
805
637
  * bubble flips to `sent` on ack (or `failed`). */
806
- async sendFile(file, opts) {
807
- const localId = `upload_${Date.now()}_${uploadSeq++}`;
808
- this.messages.push(
809
- pendingFileChatMessage({
810
- tempId: localId,
811
- name: opts?.name ?? file.name,
812
- mime: file.type,
813
- size: file.size
814
- })
815
- );
816
- this.emit();
817
- try {
818
- const sent = await this.room.sendFile(file, {
819
- ...opts ?? {},
820
- onProgress: (p) => {
821
- this.mutateByTempId(localId, (m) => ({
822
- ...m,
823
- uploadProgress: p.ratio
824
- }));
825
- opts?.onProgress?.(p);
826
- }
827
- });
828
- this.mutateByTempId(localId, (m) => ({
829
- ...m,
830
- fileId: sent.fileId,
831
- file: m.file ? { ...m.file, id: sent.fileId } : m.file,
832
- uploadProgress: null
833
- }));
834
- const realId = await sent.messageId;
835
- this.mutateByTempId(localId, (m) => ({
836
- ...m,
837
- id: realId,
838
- status: "sent"
839
- }));
840
- } catch {
841
- this.mutateByTempId(localId, (m) => ({
842
- ...m,
843
- status: "failed",
844
- uploadProgress: null
845
- }));
846
- }
638
+ sendFile(file, opts) {
639
+ return this.collection().sendFile(file, opts);
847
640
  }
848
641
  /** Edit a message's content (server-side; peers get `message_updated`).
849
642
  * The local copy updates optimistically. */
850
- async edit(messageId, content) {
851
- await this.room.edit(messageId, { content });
852
- this.mutateById(messageId, (m) => ({
853
- ...m,
854
- content,
855
- editedCount: m.editedCount + 1
856
- }));
643
+ edit(messageId, content) {
644
+ return this.collection().edit(messageId, content);
857
645
  }
858
646
  /** Delete a message. `scope: "ALL"` (default) removes it for everyone;
859
647
  * `"MY"` hides it only for the caller. Local copy flips to deleted. */
860
- async delete(messageId, scope = "ALL") {
861
- await this.room.delete(messageId, scope);
862
- this.mutateById(messageId, (m) => ({ ...m, isDeleted: true }));
648
+ delete(messageId, scope = "ALL") {
649
+ return this.collection().delete(messageId, scope);
863
650
  }
864
651
  /** Add/remove the caller's reaction. Pass `myUserId` so the toggle can
865
652
  * tell whether the caller already reacted with `key`. The authoritative
866
653
  * state arrives back via the reaction WS events. */
867
- async toggleReaction(messageId, key, myUserId) {
868
- const m = this.messages.find((x) => x.id === messageId);
869
- const mine = m?.reactions.some(
870
- (r) => r.key === key && r.userIds.includes(myUserId)
871
- ) ?? false;
872
- if (mine) {
873
- await this.room.unreact(messageId, key);
874
- } else {
875
- await this.room.react(messageId, key);
876
- }
654
+ toggleReaction(messageId, key, myUserId) {
655
+ return this.collection().toggleReaction(messageId, key, myUserId);
877
656
  }
878
657
  /** Search this room's messages (server-side, content substring match).
879
658
  * Returns view models without touching the message list. */
880
659
  async search(q, limit = 30) {
881
660
  const page = await this.room.search(q, { limit });
882
- return page.items.map(chatMessageFromHistory);
661
+ return page.items.map(import_sdk_web.chatMessageFromHistory);
883
662
  }
884
663
  /** Mark `messageId` read (debounced inside the SDK). */
885
664
  markRead(messageId) {
886
- this.room.markRead(messageId);
887
- }
888
- // ── event handlers ──────────────────────────────────────────────
889
- onIncoming(f) {
890
- this.messages.push(chatMessageFromLive(f));
891
- this.emit();
892
- }
893
- onUpdated(f) {
894
- this.mutateById(f.message_id, (m) => ({
895
- ...m,
896
- content: f.content ?? m.content,
897
- editedCount: m.editedCount + 1
898
- }));
899
- }
900
- onDeleted(f) {
901
- this.mutateById(f.message_id, (m) => ({ ...m, isDeleted: true }));
902
- }
903
- onCleared(f) {
904
- const upTo = f.up_to_message_id;
905
- let changed = false;
906
- this.messages = this.messages.map((m) => {
907
- if (!m.isDeleted && (upTo == null || !idGreater(m.id, upTo))) {
908
- changed = true;
909
- return { ...m, isDeleted: true };
910
- }
911
- return m;
912
- });
913
- if (changed) this.emit();
914
- }
915
- onReactionAdded(f) {
916
- this.mutateById(f.message_id, (m) => {
917
- const next = m.reactions.map(
918
- (r) => r.key === f.reaction_key ? {
919
- key: r.key,
920
- userIds: [.../* @__PURE__ */ new Set([...r.userIds, f.user_id])],
921
- updatedAt: f.updated_at
922
- } : r
923
- );
924
- if (!next.some((r) => r.key === f.reaction_key)) {
925
- next.push({
926
- key: f.reaction_key,
927
- userIds: [f.user_id],
928
- updatedAt: f.updated_at
929
- });
930
- }
931
- return { ...m, reactions: next };
932
- });
933
- }
934
- onReactionRemoved(f) {
935
- this.mutateById(f.message_id, (m) => {
936
- const next = [];
937
- for (const r of m.reactions) {
938
- if (r.key !== f.reaction_key) {
939
- next.push(r);
940
- continue;
941
- }
942
- const users = r.userIds.filter((u) => u !== f.user_id);
943
- if (users.length > 0) {
944
- next.push({ key: r.key, userIds: users, updatedAt: f.updated_at });
945
- }
946
- }
947
- return { ...m, reactions: next };
948
- });
949
- }
950
- onSyncTick(f) {
951
- let changed = false;
952
- for (const [userId, mid] of Object.entries(f.read_states)) {
953
- const cur = this.readWatermarks[userId];
954
- if (!cur || idGreater(mid, cur)) {
955
- this.readWatermarks[userId] = mid;
956
- changed = true;
957
- }
958
- }
959
- if (changed) this.emit();
665
+ this.collection().markRead(messageId);
960
666
  }
961
667
  onVisibilityChange = () => {
962
668
  if (typeof document !== "undefined" && document.visibilityState === "hidden") {
963
669
  this.room.flushMarkRead();
964
670
  }
965
671
  };
966
- // ── helpers ─────────────────────────────────────────────────────
967
- bindAck(tempId, messageId) {
968
- messageId.then((realId) => {
969
- this.mutateByTempId(tempId, (m) => ({
970
- ...m,
971
- id: realId,
972
- status: "sent"
973
- }));
974
- }).catch(() => {
975
- this.mutateByTempId(tempId, (m) => ({ ...m, status: "failed" }));
976
- });
977
- }
978
- mutateById(id, f) {
979
- const i = this.messages.findIndex((m) => m.id === id);
980
- if (i === -1) return;
981
- this.messages[i] = f(this.messages[i]);
982
- this.emit();
983
- }
984
- mutateByTempId(tempId, f) {
985
- const i = this.messages.findIndex((m) => m.tempId === tempId);
986
- if (i === -1) return;
987
- this.messages[i] = f(this.messages[i]);
988
- this.emit();
989
- }
990
672
  };
991
673
 
992
674
  // src/use-messages.ts