@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.
package/dist/index.cjs CHANGED
@@ -20,10 +20,16 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ CacheKeys: () => CacheKeys,
23
24
  ErrorCode: () => ErrorCode,
25
+ MessageCollection: () => MessageCollection,
24
26
  NoveraChat: () => NoveraChat,
25
27
  NoveraChatError: () => NoveraChatError,
26
- Room: () => Room
28
+ Room: () => Room,
29
+ chatMessageFromHistory: () => chatMessageFromHistory,
30
+ chatMessageFromLive: () => chatMessageFromLive,
31
+ pendingChatMessage: () => pendingChatMessage,
32
+ pendingFileChatMessage: () => pendingFileChatMessage
27
33
  });
28
34
  module.exports = __toCommonJS(index_exports);
29
35
 
@@ -52,6 +58,7 @@ function parseUnreadRoomItem(j) {
52
58
  lastMessageId: j["last_message_id"],
53
59
  lastReadMessageId: j["last_read_message_id"],
54
60
  lastMessagePreview: j["last_message_preview"],
61
+ lastMessageAt: j["last_message_at"],
55
62
  memberCount: j["member_count"],
56
63
  membersPreview: j["members_preview"] == null ? void 0 : j["members_preview"].map((e) => parseMemberPreview(e))
57
64
  };
@@ -176,6 +183,27 @@ function parseFileCreateResponse(j) {
176
183
  expiresAtMs: j["expires_at_ms"]
177
184
  };
178
185
  }
186
+ function parseRoomOut(j) {
187
+ return {
188
+ appId: j["app_id"],
189
+ roomId: j["room_id"],
190
+ roomType: j["room_type"],
191
+ customType: j["custom_type"],
192
+ name: j["name"],
193
+ coverImageUrl: j["cover_image_url"],
194
+ isDistinct: j["is_distinct"],
195
+ isPublic: j["is_public"],
196
+ isFrozen: j["is_frozen"],
197
+ joinPolicy: j["join_policy"],
198
+ timerSeconds: j["timer_seconds"],
199
+ memberCount: j["member_count"],
200
+ lastMessageId: j["last_message_id"],
201
+ lastMessageAt: j["last_message_at"],
202
+ lastMessagePreview: j["last_message_preview"],
203
+ currentAnnouncement: j["current_announcement"] == null ? null : parseAnnouncementOut(j["current_announcement"]),
204
+ createdAt: j["created_at"]
205
+ };
206
+ }
179
207
  function parseBlockOut(j) {
180
208
  return {
181
209
  blockedUserId: j["blocked_user_id"],
@@ -245,6 +273,16 @@ function parseReactionEntry(j) {
245
273
  };
246
274
  }
247
275
 
276
+ // src/cache.ts
277
+ var CacheKeys = {
278
+ /** 저장 포맷 스키마 버전. 저장되는 JSON 모양이 바뀌면 올린다. */
279
+ version: "v1",
280
+ /** 방 목록 스냅샷 (`GET /users/me/unread` 원문). */
281
+ rooms: "nc.v1.rooms",
282
+ /** 방 히스토리 최신 페이지 스냅샷 (`GET /rooms/{id}/messages` 원문). */
283
+ msgs: (roomId) => `nc.v1.msgs.${roomId}`
284
+ };
285
+
248
286
  // src/errors.ts
249
287
  var ErrorCode = {
250
288
  INTERNAL: "NC-GEN-001",
@@ -539,6 +577,527 @@ function tempId() {
539
577
  return "tmp_" + bytes.map((b) => b.toString(16).padStart(2, "0")).join("");
540
578
  }
541
579
 
580
+ // src/chat-message.ts
581
+ function fileInlineFromWire(w) {
582
+ return {
583
+ id: w.id,
584
+ kind: w.kind,
585
+ mime: w.mime,
586
+ size: w.size,
587
+ name: w.name ?? null,
588
+ width: w.width ?? null,
589
+ height: w.height ?? null,
590
+ durationSec: w.duration_sec ?? null,
591
+ thumbnailStatus: w.thumbnail_status,
592
+ forwardBlocked: Boolean(w.forward_blocked)
593
+ };
594
+ }
595
+ function chatMessageFromHistory(m) {
596
+ return {
597
+ id: m.messageId,
598
+ senderId: m.senderId ?? null,
599
+ content: m.content ?? null,
600
+ createdAtMs: m.createdAtMs,
601
+ status: "sent",
602
+ messageType: m.messageType,
603
+ sender: m.sender ?? null,
604
+ customType: m.customType ?? null,
605
+ fileId: m.fileId ?? null,
606
+ file: m.file ?? null,
607
+ files: m.files ?? null,
608
+ replyToId: m.replyToId ?? null,
609
+ reactions: m.reactions ?? [],
610
+ customMeta: m.customMeta ?? null,
611
+ editedCount: m.editedCount ?? 0,
612
+ uploadProgress: null,
613
+ tempId: null,
614
+ isDeleted: m.deletedAt != null
615
+ };
616
+ }
617
+ function chatMessageFromLive(f) {
618
+ const data = f.data ?? null;
619
+ const customMeta = data && typeof data === "object" && "_customMeta" in data ? data._customMeta : null;
620
+ return {
621
+ id: f.message_id,
622
+ senderId: f.sender_id,
623
+ content: f.content ?? null,
624
+ createdAtMs: f.created_at,
625
+ status: "sent",
626
+ messageType: f.message_type,
627
+ sender: null,
628
+ customType: f.custom_type ?? null,
629
+ fileId: f.file_id ?? null,
630
+ file: f.file ? fileInlineFromWire(f.file) : null,
631
+ files: f.files ? f.files.map(fileInlineFromWire) : null,
632
+ replyToId: f.reply_to_id ?? null,
633
+ reactions: [],
634
+ customMeta,
635
+ editedCount: 0,
636
+ uploadProgress: null,
637
+ tempId: null,
638
+ isDeleted: false
639
+ };
640
+ }
641
+ function pendingChatMessage(p) {
642
+ return {
643
+ id: p.tempId,
644
+ senderId: null,
645
+ content: p.content,
646
+ createdAtMs: Date.now(),
647
+ status: "sending",
648
+ messageType: "TEXT",
649
+ sender: null,
650
+ customType: p.customType ?? null,
651
+ fileId: null,
652
+ file: null,
653
+ files: null,
654
+ replyToId: p.replyToId ?? null,
655
+ reactions: [],
656
+ customMeta: p.customMeta ?? null,
657
+ editedCount: 0,
658
+ uploadProgress: null,
659
+ tempId: p.tempId,
660
+ isDeleted: false
661
+ };
662
+ }
663
+ function pendingFileChatMessage(p) {
664
+ const mime = p.mime ?? "application/octet-stream";
665
+ return {
666
+ id: p.tempId,
667
+ senderId: null,
668
+ content: null,
669
+ createdAtMs: Date.now(),
670
+ status: "sending",
671
+ messageType: "FILE",
672
+ sender: null,
673
+ customType: null,
674
+ fileId: null,
675
+ file: {
676
+ id: "",
677
+ kind: mime.startsWith("image/") ? "image" : mime.startsWith("video/") ? "video" : "file",
678
+ mime,
679
+ size: p.size ?? 0,
680
+ name: p.name,
681
+ thumbnailStatus: "pending",
682
+ forwardBlocked: false
683
+ },
684
+ files: null,
685
+ replyToId: null,
686
+ reactions: [],
687
+ customMeta: null,
688
+ editedCount: 0,
689
+ uploadProgress: 0,
690
+ tempId: p.tempId,
691
+ isDeleted: false
692
+ };
693
+ }
694
+
695
+ // src/internal/compare-id.ts
696
+ function idGreater(a, b) {
697
+ if (!a || a === "0") return false;
698
+ if (!b || b === "0") return true;
699
+ try {
700
+ return BigInt(a) > BigInt(b);
701
+ } catch {
702
+ return a > b;
703
+ }
704
+ }
705
+
706
+ // src/features/message-collection.ts
707
+ var uploadSeq = 0;
708
+ var MessageCollection = class {
709
+ constructor(room, policy = "replaceByApi") {
710
+ this.room = room;
711
+ this.policy = policy;
712
+ this.unsubs.push(
713
+ room.on("message", (f) => this.onIncoming(f)),
714
+ room.on("messageUpdated", (f) => this.onUpdated(f)),
715
+ room.on("messageDeleted", (f) => this.onDeleted(f)),
716
+ room.on("messagesCleared", (f) => this.onCleared(f)),
717
+ room.on("reactionAdded", (f) => this.onReactionAdded(f)),
718
+ room.on("reactionRemoved", (f) => this.onReactionRemoved(f)),
719
+ room.on("syncTick", (f) => this.onSyncTick(f))
720
+ );
721
+ }
722
+ room;
723
+ policy;
724
+ bus = new EventBus();
725
+ unsubs = [];
726
+ _messages = [];
727
+ _readWatermarks = {};
728
+ _hasMore = false;
729
+ oldestCursor = null;
730
+ loadingMore = false;
731
+ /**
732
+ * 캐시 스냅샷으로 렌더된 머리쪽 메시지 수 — API 도착 시 이만큼 걷어내고
733
+ * 통째 교체한다(replaceByApi). 라이브 수신은 리스트 꼬리에 붙으므로
734
+ * 머리 range 제거로 스냅샷만 정확히 걷힌다.
735
+ */
736
+ hydratedCount = 0;
737
+ /** The current messages, oldest first. Treat as read-only. */
738
+ get messages() {
739
+ return this._messages;
740
+ }
741
+ /** True when older history remains — fire `loadMore()` from the top of
742
+ * the scroll view. */
743
+ get hasMore() {
744
+ return this._hasMore;
745
+ }
746
+ /** Per-user read watermarks (`userId` → last read `messageId`), kept live
747
+ * from `sync_tick` frames. Treat as read-only. */
748
+ get readWatermarks() {
749
+ return this._readWatermarks;
750
+ }
751
+ /** State-change notifications. One event per mutation; re-read `messages`
752
+ * (and friends) when it fires. Returns an unsubscribe fn. */
753
+ on(event, fn) {
754
+ return this.bus.on(event, fn);
755
+ }
756
+ /** Whether `userId` has read `messageId` according to the latest watermark. */
757
+ isReadBy(userId, messageId) {
758
+ const w = this._readWatermarks[userId];
759
+ if (!w) return false;
760
+ return !idGreater(messageId, w);
761
+ }
762
+ /** How many of the given users have read `messageId`. Pass the room's
763
+ * member ids (minus the sender) to get a KakaoTalk-style unread count:
764
+ * `members.length - readCount(...)`. */
765
+ readCount(messageId, userIds) {
766
+ let n = 0;
767
+ for (const u of userIds) if (this.isReadBy(u, messageId)) n++;
768
+ return n;
769
+ }
770
+ // ── loading — cache hydrate → API wholesale replace ─────────────
771
+ /**
772
+ * Load the most recent page of history.
773
+ *
774
+ * `ClientOptions.cacheStore`가 주입돼 있으면 캐시 스냅샷을 먼저 렌더한
775
+ * 뒤(`hydrated`), API 응답 도착 시 스냅샷을 걷어내고 통째로 교체한다
776
+ * (`replaced`). 스냅샷 렌더 중에는 `hasMore`/스크롤백을 열지 않는다 —
777
+ * 커서는 API 응답의 것만 신뢰한다.
778
+ */
779
+ async load(limit = 50) {
780
+ const pending = this.room.listRecent(limit);
781
+ await this.hydrateFromCache();
782
+ const page = await pending;
783
+ this.applyFresh(page);
784
+ }
785
+ /** Scrollback: load the page of messages older than what's on screen and
786
+ * prepend it. No-op while a previous call is in flight or when `hasMore`
787
+ * is false. Returns the number of messages added. */
788
+ async loadMore(limit = 50) {
789
+ const cursor = this.oldestCursor;
790
+ if (this.loadingMore || !this._hasMore || cursor == null) return 0;
791
+ this.loadingMore = true;
792
+ try {
793
+ const page = await this.room.listBefore(cursor, limit);
794
+ this._messages.unshift(...page.items.map(chatMessageFromHistory));
795
+ this.oldestCursor = page.nextCursor;
796
+ this._hasMore = page.hasMore;
797
+ this.emit("inserted");
798
+ return page.items.length;
799
+ } finally {
800
+ this.loadingMore = false;
801
+ }
802
+ }
803
+ /** 캐시 스냅샷 즉시 렌더 — 빈 리스트일 때만. 실패는 조용히 무시(캐시는
804
+ * 힌트일 뿐). */
805
+ async hydrateFromCache() {
806
+ if (this._messages.length > 0 || this.hydratedCount > 0) return;
807
+ const cached = await this.room.cachedRecent();
808
+ if (!cached || cached.items.length === 0 || this._messages.length > 0) {
809
+ return;
810
+ }
811
+ this._messages.unshift(...cached.items.map(chatMessageFromHistory));
812
+ this.hydratedCount = cached.items.length;
813
+ this.emit("hydrated");
814
+ }
815
+ /**
816
+ * API 최신 페이지를 컬렉션에 반영한다.
817
+ * replaceByApi(기본)는 스냅샷을 걷어내고 통째 교체하고, mergeDiff는
818
+ * 스냅샷의 옛 메시지를 보존·병합한다(`mergeFresh`).
819
+ */
820
+ applyFresh(page) {
821
+ if (this.policy === "mergeDiff" && this.hydratedCount > 0) {
822
+ this.mergeFresh(page);
823
+ return;
824
+ }
825
+ if (this.hydratedCount > 0) {
826
+ this._messages.splice(0, this.hydratedCount);
827
+ this.hydratedCount = 0;
828
+ }
829
+ this._messages.unshift(...page.items.map(chatMessageFromHistory));
830
+ this.oldestCursor = page.nextCursor;
831
+ this._hasMore = page.hasMore;
832
+ this.emit("replaced");
833
+ }
834
+ /**
835
+ * diff 병합 — 스냅샷의 옛 메시지를 보존해 fresh 페이지 아래에 잇는다.
836
+ *
837
+ * 판정 규칙(서버 계약, 07-23 확정):
838
+ * - **컷오프분**: `history_from`(히스토리 하한, unix ms)보다 오래된 캐시
839
+ * 메시지는 재입장 컷/대화 지우기로 볼 수 없게 된 것 → 폐기.
840
+ * - **삭제분**: fresh 커버 구간(id ≥ fresh 최저 id)에 있는데 fresh에
841
+ * 없는 캐시 메시지는 그 사이 삭제된 것(타이머 만료 포함) → 폐기.
842
+ * - **갭 폴백**: 캐시의 최신 메시지가 fresh와 겹치지 않으면 누락 구간이
843
+ * 있을 수 있으므로 병합하지 않고 통째 교체.
844
+ *
845
+ * 겹치는 구간은 항상 fresh가 정답(수정 반영) — 보존되는 건 fresh 범위
846
+ * **밖**의 옛 메시지뿐이라 "서버가 항상 이긴다" 불변식은 유지된다.
847
+ */
848
+ mergeFresh(page) {
849
+ const cachedHead = this._messages.slice(0, this.hydratedCount);
850
+ this._messages.splice(0, this.hydratedCount);
851
+ this.hydratedCount = 0;
852
+ const freshIds = new Set(page.items.map((m) => m.messageId));
853
+ const freshOldest = page.items.length > 0 ? page.items[0].messageId : null;
854
+ const hf = page.historyFrom ?? null;
855
+ const overlaps = cachedHead.length > 0 && freshOldest != null && freshIds.has(cachedHead.at(-1).id);
856
+ let preserved = [];
857
+ if (overlaps && page.hasMore) {
858
+ preserved = cachedHead.filter((m) => {
859
+ if (freshIds.has(m.id)) return false;
860
+ if (!idGreater(freshOldest, m.id)) return false;
861
+ if (hf != null && m.createdAtMs != null && m.createdAtMs < hf) {
862
+ return false;
863
+ }
864
+ return true;
865
+ });
866
+ }
867
+ this._messages.unshift(...page.items.map(chatMessageFromHistory));
868
+ this._messages.unshift(...preserved);
869
+ this.oldestCursor = preserved.length > 0 ? preserved[0].id : page.nextCursor;
870
+ this._hasMore = page.hasMore;
871
+ this.emit("replaced");
872
+ }
873
+ // ── optimistic sends ────────────────────────────────────────────
874
+ /**
875
+ * Send `text` optimistically: a `sending` bubble appears immediately (one
876
+ * microtask later — `Room.send` is async), then flips to `sent` (with the
877
+ * real id) on ack, or `failed` on error. Fire-and-forget: a WS-not-open
878
+ * failure surfaces as a `failed` bubble rather than a rejected promise.
879
+ */
880
+ send(text, opts) {
881
+ void (async () => {
882
+ let tempId2;
883
+ let ackPromise;
884
+ try {
885
+ const sent = await this.room.send({
886
+ content: text,
887
+ ...opts?.replyToId ? { replyToId: opts.replyToId } : {},
888
+ ...opts?.customType ? { customType: opts.customType } : {},
889
+ ...opts?.customMeta ? { customMeta: opts.customMeta } : {}
890
+ });
891
+ tempId2 = sent.tempId;
892
+ ackPromise = sent.messageId;
893
+ } catch {
894
+ const localId = `failed_${Date.now()}_${uploadSeq++}`;
895
+ this._messages.push({
896
+ ...pendingChatMessage({ tempId: localId, content: text, ...opts }),
897
+ status: "failed"
898
+ });
899
+ this.emit("inserted");
900
+ return;
901
+ }
902
+ this._messages.push(pendingChatMessage({ tempId: tempId2, content: text, ...opts }));
903
+ this.emit("inserted");
904
+ this.bindAck(tempId2, ackPromise);
905
+ })();
906
+ }
907
+ /** Send a file optimistically. A FILE bubble with `uploadProgress`
908
+ * appears immediately; progress advances during the S3 upload, then the
909
+ * bubble flips to `sent` on ack (or `failed`). Never throws — failures
910
+ * surface as the bubble's status. */
911
+ async sendFile(file, opts) {
912
+ const localId = `upload_${Date.now()}_${uploadSeq++}`;
913
+ this._messages.push(
914
+ pendingFileChatMessage({
915
+ tempId: localId,
916
+ name: opts?.name ?? file.name,
917
+ mime: file.type,
918
+ size: file.size
919
+ })
920
+ );
921
+ this.emit("inserted");
922
+ try {
923
+ const sent = await this.room.sendFile(file, {
924
+ ...opts ?? {},
925
+ onProgress: (p) => {
926
+ this.mutateByTempId(localId, (m) => ({
927
+ ...m,
928
+ uploadProgress: p.ratio
929
+ }));
930
+ opts?.onProgress?.(p);
931
+ }
932
+ });
933
+ this.mutateByTempId(localId, (m) => ({
934
+ ...m,
935
+ fileId: sent.fileId,
936
+ file: m.file ? { ...m.file, id: sent.fileId } : m.file,
937
+ uploadProgress: null
938
+ }));
939
+ const realId = await sent.messageId;
940
+ this.mutateByTempId(localId, (m) => ({
941
+ ...m,
942
+ id: realId,
943
+ status: "sent"
944
+ }));
945
+ } catch {
946
+ this.mutateByTempId(localId, (m) => ({
947
+ ...m,
948
+ status: "failed",
949
+ uploadProgress: null
950
+ }));
951
+ }
952
+ }
953
+ /** Edit a message's content (server-side; peers get `message_updated`).
954
+ * The local copy updates optimistically. */
955
+ async edit(messageId, content) {
956
+ await this.room.edit(messageId, { content });
957
+ this.mutateById(messageId, (m) => ({
958
+ ...m,
959
+ content,
960
+ editedCount: m.editedCount + 1
961
+ }));
962
+ }
963
+ /** Delete a message. `scope: "ALL"` (default) removes it for everyone;
964
+ * `"MY"` hides it only for the caller. Local copy flips to deleted. */
965
+ async delete(messageId, scope = "ALL") {
966
+ await this.room.delete(messageId, scope);
967
+ this.mutateById(messageId, (m) => ({ ...m, isDeleted: true }));
968
+ }
969
+ /** Add/remove the caller's reaction. Pass `myUserId` so the toggle can
970
+ * tell whether the caller already reacted with `key`. The authoritative
971
+ * state arrives back via the reaction WS events. */
972
+ async toggleReaction(messageId, key, myUserId) {
973
+ const m = this._messages.find((x) => x.id === messageId);
974
+ const mine = m?.reactions.some(
975
+ (r) => r.key === key && r.userIds.includes(myUserId)
976
+ ) ?? false;
977
+ if (mine) {
978
+ await this.room.unreact(messageId, key);
979
+ } else {
980
+ await this.room.react(messageId, key);
981
+ }
982
+ }
983
+ /** Mark `messageId` read (debounced inside the SDK). */
984
+ markRead(messageId) {
985
+ this.room.markRead(messageId);
986
+ }
987
+ /** Force-send the pending read watermark now (e.g. on unmount / tab
988
+ * backgrounding). */
989
+ flushMarkRead() {
990
+ this.room.flushMarkRead();
991
+ }
992
+ /** Unsubscribe from the room and flush the pending read watermark. The
993
+ * collection must not be used afterwards. */
994
+ dispose() {
995
+ this.room.flushMarkRead();
996
+ for (const u of this.unsubs.splice(0)) u();
997
+ this.bus.removeAll();
998
+ }
999
+ // ── event handlers ──────────────────────────────────────────────
1000
+ onIncoming(f) {
1001
+ this._messages.push(chatMessageFromLive(f));
1002
+ this.emit("inserted");
1003
+ }
1004
+ onUpdated(f) {
1005
+ this.mutateById(f.message_id, (m) => ({
1006
+ ...m,
1007
+ content: f.content ?? m.content,
1008
+ editedCount: m.editedCount + 1
1009
+ }));
1010
+ }
1011
+ onDeleted(f) {
1012
+ this.mutateById(f.message_id, (m) => ({ ...m, isDeleted: true }));
1013
+ }
1014
+ onCleared(f) {
1015
+ const upTo = f.up_to_message_id;
1016
+ let changed = false;
1017
+ this._messages = this._messages.map((m) => {
1018
+ if (!m.isDeleted && (upTo == null || !idGreater(m.id, upTo))) {
1019
+ changed = true;
1020
+ return { ...m, isDeleted: true };
1021
+ }
1022
+ return m;
1023
+ });
1024
+ if (changed) this.emit("updated");
1025
+ }
1026
+ onReactionAdded(f) {
1027
+ this.mutateById(f.message_id, (m) => {
1028
+ const next = m.reactions.map(
1029
+ (r) => r.key === f.reaction_key ? {
1030
+ key: r.key,
1031
+ userIds: [.../* @__PURE__ */ new Set([...r.userIds, f.user_id])],
1032
+ updatedAt: f.updated_at
1033
+ } : r
1034
+ );
1035
+ if (!next.some((r) => r.key === f.reaction_key)) {
1036
+ next.push({
1037
+ key: f.reaction_key,
1038
+ userIds: [f.user_id],
1039
+ updatedAt: f.updated_at
1040
+ });
1041
+ }
1042
+ return { ...m, reactions: next };
1043
+ });
1044
+ }
1045
+ onReactionRemoved(f) {
1046
+ this.mutateById(f.message_id, (m) => {
1047
+ const next = [];
1048
+ for (const r of m.reactions) {
1049
+ if (r.key !== f.reaction_key) {
1050
+ next.push(r);
1051
+ continue;
1052
+ }
1053
+ const users = r.userIds.filter((u) => u !== f.user_id);
1054
+ if (users.length > 0) {
1055
+ next.push({ key: r.key, userIds: users, updatedAt: f.updated_at });
1056
+ }
1057
+ }
1058
+ return { ...m, reactions: next };
1059
+ });
1060
+ }
1061
+ onSyncTick(f) {
1062
+ let changed = false;
1063
+ for (const [userId, mid] of Object.entries(f.read_states)) {
1064
+ const cur = this._readWatermarks[userId];
1065
+ if (!cur || idGreater(mid, cur)) {
1066
+ this._readWatermarks[userId] = mid;
1067
+ changed = true;
1068
+ }
1069
+ }
1070
+ if (changed) this.emit("watermarks");
1071
+ }
1072
+ // ── helpers ─────────────────────────────────────────────────────
1073
+ bindAck(tempId2, messageId) {
1074
+ messageId.then((realId) => {
1075
+ this.mutateByTempId(tempId2, (m) => ({
1076
+ ...m,
1077
+ id: realId,
1078
+ status: "sent"
1079
+ }));
1080
+ }).catch(() => {
1081
+ this.mutateByTempId(tempId2, (m) => ({ ...m, status: "failed" }));
1082
+ });
1083
+ }
1084
+ mutateById(id, f) {
1085
+ const i = this._messages.findIndex((m) => m.id === id);
1086
+ if (i === -1) return;
1087
+ this._messages[i] = f(this._messages[i]);
1088
+ this.emit("updated");
1089
+ }
1090
+ mutateByTempId(tempId2, f) {
1091
+ const i = this._messages.findIndex((m) => m.tempId === tempId2);
1092
+ if (i === -1) return;
1093
+ this._messages[i] = f(this._messages[i]);
1094
+ this.emit("updated");
1095
+ }
1096
+ emit(kind) {
1097
+ this.bus.emit("change", { kind });
1098
+ }
1099
+ };
1100
+
542
1101
  // src/features/room.ts
543
1102
  function toFileInline(f) {
544
1103
  return {
@@ -568,11 +1127,14 @@ function toMessageOut(j) {
568
1127
  };
569
1128
  }
570
1129
  var Room = class _Room {
571
- constructor(roomId, http, ws, readDebounceMs) {
1130
+ constructor(roomId, http, ws, readDebounceMs, opts) {
572
1131
  this.roomId = roomId;
573
1132
  this.http = http;
574
1133
  this.ws = ws;
575
1134
  this.readDebounceMs = readDebounceMs;
1135
+ this.cacheStore = opts?.cacheStore ?? null;
1136
+ this.cachePolicy = opts?.cachePolicy ?? "replaceByApi";
1137
+ this.logger = opts?.logger;
576
1138
  }
577
1139
  roomId;
578
1140
  http;
@@ -616,6 +1178,27 @@ var Room = class _Room {
616
1178
  * fine in practice (small strings, cleared on `chat.disconnect()`).
617
1179
  */
618
1180
  mySentTempIds = /* @__PURE__ */ new Set();
1181
+ // ── 스냅샷 캐시 협력자 (클라이언트 옵션에서 흘러듦) ──────────────
1182
+ cacheStore;
1183
+ cachePolicy;
1184
+ logger;
1185
+ /**
1186
+ * 이 방의 메시지 상태를 소유하는 `MessageCollection`을 만든다.
1187
+ *
1188
+ * 컬렉션이 이벤트 반영·낙관적 전송·캐시 hydrate→교체를 전부 담당하므로,
1189
+ * 리스트 화면은 컬렉션 하나만 물면 된다. 여러 개 만들 수 있고 각자
1190
+ * `dispose()`로 정리한다.
1191
+ *
1192
+ * ```ts
1193
+ * const col = chat.room("room_123").collection();
1194
+ * const unsub = col.on("change", () => render(col.messages));
1195
+ * await col.load();
1196
+ * col.send("hello");
1197
+ * ```
1198
+ */
1199
+ collection(policy) {
1200
+ return new MessageCollection(this, policy ?? this.cachePolicy);
1201
+ }
619
1202
  _sendPendingMarkRead() {
620
1203
  const mid = this.pendingReadMid;
621
1204
  if (!mid) return;
@@ -1072,11 +1655,70 @@ var Room = class _Room {
1072
1655
  */
1073
1656
  async listRecent(limit = 50) {
1074
1657
  const raw = await this.http.request("GET", `/api/v1/rooms/${this.roomId}/messages`, void 0, { limit });
1075
- return {
1658
+ const page = {
1076
1659
  items: raw.items.map(toMessageOut),
1660
+ // 파싱 성공한 응답만 캐시에 넣는다
1077
1661
  nextCursor: raw.next_cursor,
1078
- hasMore: raw.has_more
1662
+ hasMore: raw.has_more,
1663
+ historyFrom: raw.history_from ?? null
1079
1664
  };
1665
+ void this.cacheWriteRecent(raw);
1666
+ return page;
1667
+ }
1668
+ /** 이 방의 상세 정보 (`GET /rooms/{id}`) — 이름·멤버 수·삭제 타이머 등. */
1669
+ async detail() {
1670
+ const raw = await this.http.request(
1671
+ "GET",
1672
+ `/api/v1/rooms/${this.roomId}`
1673
+ );
1674
+ return parseRoomOut(raw);
1675
+ }
1676
+ /**
1677
+ * 히스토리 최신 페이지의 캐시 스냅샷 — 마지막 `listRecent()` 성공 응답의
1678
+ * 원문. 콜드 스타트 렌더 힌트 용도다: 있으면 즉시 그리고, `listRecent()`가
1679
+ * 도착하면 통째로 교체할 것. 캐시가 없거나(`cacheStore` 미주입 포함)
1680
+ * 파싱이 실패하면 `null`을 돌려주고, 깨진 스냅샷은 지운다.
1681
+ *
1682
+ * 스냅샷의 `nextCursor`/`hasMore`는 시점이 지난 값이므로 스크롤백
1683
+ * 커서로 쓰지 말 것 — API 응답이 도착한 뒤의 값만 신뢰한다.
1684
+ */
1685
+ async cachedRecent() {
1686
+ const store = this.cacheStore;
1687
+ if (!store) return null;
1688
+ const key = CacheKeys.msgs(this.roomId);
1689
+ try {
1690
+ const raw = await store.read(key);
1691
+ if (raw == null) return null;
1692
+ const j = JSON.parse(raw);
1693
+ return {
1694
+ items: j.items.map(toMessageOut),
1695
+ nextCursor: j.next_cursor,
1696
+ hasMore: j.has_more,
1697
+ historyFrom: j.history_from ?? null
1698
+ };
1699
+ } catch (err) {
1700
+ this.logger?.("warn", `cache_read_failed key=${key}`, err);
1701
+ try {
1702
+ await store.remove(key);
1703
+ } catch {
1704
+ }
1705
+ return null;
1706
+ }
1707
+ }
1708
+ /**
1709
+ * 최신 페이지 스냅샷 저장. 삭제 타이머 방도 저장한다 — 서버가 만료를
1710
+ * 일반 `message_deleted` 프레임으로 브로드캐스트하고 히스토리에서도
1711
+ * 즉시 제외하므로, 만료분은 라이브 이벤트/`history_from` diff로 정리된다.
1712
+ */
1713
+ async cacheWriteRecent(raw) {
1714
+ const store = this.cacheStore;
1715
+ if (!store) return;
1716
+ const key = CacheKeys.msgs(this.roomId);
1717
+ try {
1718
+ await store.write(key, JSON.stringify(raw));
1719
+ } catch (err) {
1720
+ this.logger?.("warn", `cache_write_failed key=${key}`, err);
1721
+ }
1080
1722
  }
1081
1723
  /**
1082
1724
  * Scrollback. Fetches the page of `limit` messages immediately preceding
@@ -1095,7 +1737,7 @@ var Room = class _Room {
1095
1737
  hasMore: raw.has_more
1096
1738
  };
1097
1739
  }
1098
- /** Phase D #2 — 방 내 메시지 검색.
1740
+ /** 방 내 메시지 검색.
1099
1741
  *
1100
1742
  * ``content`` 필드에 대한 대소문자 무시 부분 문자열 매칭 (ILIKE). 삭제된
1101
1743
  * 메시지는 제외. 결과는 최신순 (message_id DESC) 으로 반환되고, ``before``
@@ -1451,7 +2093,7 @@ var Room = class _Room {
1451
2093
  };
1452
2094
 
1453
2095
  // src/client.ts
1454
- function idGreater(a, b) {
2096
+ function idGreater2(a, b) {
1455
2097
  if (!a) return false;
1456
2098
  if (!b) return true;
1457
2099
  try {
@@ -1537,7 +2179,11 @@ var NoveraChat = class {
1537
2179
  room(roomId) {
1538
2180
  let r = this.rooms.get(roomId);
1539
2181
  if (!r) {
1540
- r = new Room(roomId, this.http, this.ws, this.readDebounceMs);
2182
+ r = new Room(roomId, this.http, this.ws, this.readDebounceMs, {
2183
+ ...this.opts.cacheStore ? { cacheStore: this.opts.cacheStore } : {},
2184
+ ...this.opts.cachePolicy ? { cachePolicy: this.opts.cachePolicy } : {},
2185
+ ...this.logger ? { logger: this.logger } : {}
2186
+ });
1541
2187
  this.rooms.set(roomId, r);
1542
2188
  }
1543
2189
  return r;
@@ -1582,7 +2228,7 @@ var NoveraChat = class {
1582
2228
  const sid = typeof messageId === "number" ? String(messageId) : messageId;
1583
2229
  if (!sid || sid === "0") return;
1584
2230
  const cur = this.lastMessageIdByRoom.get(roomId);
1585
- if (idGreater(sid, cur)) this.lastMessageIdByRoom.set(roomId, sid);
2231
+ if (idGreater2(sid, cur)) this.lastMessageIdByRoom.set(roomId, sid);
1586
2232
  }
1587
2233
  /** Server-side aggregate unread state: total count + per-room breakdown
1588
2234
  * for the authenticated user. Single REST call; cheap enough to poll
@@ -1784,7 +2430,41 @@ var NoveraChat = class {
1784
2430
  }
1785
2431
  async unreadSummary() {
1786
2432
  const raw = await this.http.request("GET", "/api/v1/users/me/unread");
1787
- return parseUnreadSummary(raw);
2433
+ const out = parseUnreadSummary(raw);
2434
+ void this.cacheWriteRooms(raw);
2435
+ return out;
2436
+ }
2437
+ /**
2438
+ * 방 목록의 캐시 스냅샷 — 마지막 `unreadSummary()` 성공 응답의 원문.
2439
+ *
2440
+ * 콜드 스타트 렌더 힌트 용도다: 있으면 즉시 그리고, `unreadSummary()`가
2441
+ * 도착하면 통째로 교체할 것. 캐시가 없거나(`cacheStore` 미주입 포함)
2442
+ * 파싱이 실패하면 `null`을 돌려주고, 깨진 스냅샷은 지운다.
2443
+ */
2444
+ async cachedUnreadSummary() {
2445
+ const store = this.opts.cacheStore;
2446
+ if (!store) return null;
2447
+ try {
2448
+ const raw = await store.read(CacheKeys.rooms);
2449
+ if (raw == null) return null;
2450
+ return parseUnreadSummary(JSON.parse(raw));
2451
+ } catch (err) {
2452
+ this.logger?.("warn", `cache_read_failed key=${CacheKeys.rooms}`, err);
2453
+ try {
2454
+ await store.remove(CacheKeys.rooms);
2455
+ } catch {
2456
+ }
2457
+ return null;
2458
+ }
2459
+ }
2460
+ async cacheWriteRooms(raw) {
2461
+ const store = this.opts.cacheStore;
2462
+ if (!store) return;
2463
+ try {
2464
+ await store.write(CacheKeys.rooms, JSON.stringify(raw));
2465
+ } catch (err) {
2466
+ this.logger?.("warn", `cache_write_failed key=${CacheKeys.rooms}`, err);
2467
+ }
1788
2468
  }
1789
2469
  /**
1790
2470
  * Accept an invite link — call this in flows where you receive a
@@ -1865,7 +2545,7 @@ var NoveraChat = class {
1865
2545
  return;
1866
2546
  }
1867
2547
  case "ack": {
1868
- if (idGreater(msg.message_id, this.lastMessageIdByRoom.get(msg.room_id))) {
2548
+ if (idGreater2(msg.message_id, this.lastMessageIdByRoom.get(msg.room_id))) {
1869
2549
  this.lastMessageIdByRoom.set(msg.room_id, msg.message_id);
1870
2550
  }
1871
2551
  const room = this.rooms.get(msg.room_id);
@@ -1882,7 +2562,7 @@ var NoveraChat = class {
1882
2562
  if (!roomId) return;
1883
2563
  const room = this.rooms.get(roomId);
1884
2564
  if (!room) return;
1885
- if (msg.type === "chat_receive" && idGreater(msg.message_id, this.lastMessageIdByRoom.get(roomId))) {
2565
+ if (msg.type === "chat_receive" && idGreater2(msg.message_id, this.lastMessageIdByRoom.get(roomId))) {
1886
2566
  this.lastMessageIdByRoom.set(roomId, msg.message_id);
1887
2567
  }
1888
2568
  if (msg.type === "chat_receive") {
@@ -1900,7 +2580,7 @@ var NoveraChat = class {
1900
2580
  highestKnownMessageId() {
1901
2581
  let max = null;
1902
2582
  for (const v of this.lastMessageIdByRoom.values()) {
1903
- if (idGreater(v, max)) max = v;
2583
+ if (idGreater2(v, max)) max = v;
1904
2584
  }
1905
2585
  return max;
1906
2586
  }
@@ -1941,7 +2621,7 @@ var NoveraChat = class {
1941
2621
  created_at: m.createdAtMs,
1942
2622
  server_ts: Date.now()
1943
2623
  });
1944
- if (idGreater(m.messageId, this.lastMessageIdByRoom.get(roomId))) {
2624
+ if (idGreater2(m.messageId, this.lastMessageIdByRoom.get(roomId))) {
1945
2625
  this.lastMessageIdByRoom.set(roomId, m.messageId);
1946
2626
  }
1947
2627
  }
@@ -1957,8 +2637,14 @@ var NoveraChat = class {
1957
2637
  };
1958
2638
  // Annotate the CommonJS export names for ESM import in node:
1959
2639
  0 && (module.exports = {
2640
+ CacheKeys,
1960
2641
  ErrorCode,
2642
+ MessageCollection,
1961
2643
  NoveraChat,
1962
2644
  NoveraChatError,
1963
- Room
2645
+ Room,
2646
+ chatMessageFromHistory,
2647
+ chatMessageFromLive,
2648
+ pendingChatMessage,
2649
+ pendingFileChatMessage
1964
2650
  });