@noverachat/sdk-web 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
@@ -147,6 +147,27 @@ function parseFileCreateResponse(j) {
147
147
  expiresAtMs: j["expires_at_ms"]
148
148
  };
149
149
  }
150
+ function parseRoomOut(j) {
151
+ return {
152
+ appId: j["app_id"],
153
+ roomId: j["room_id"],
154
+ roomType: j["room_type"],
155
+ customType: j["custom_type"],
156
+ name: j["name"],
157
+ coverImageUrl: j["cover_image_url"],
158
+ isDistinct: j["is_distinct"],
159
+ isPublic: j["is_public"],
160
+ isFrozen: j["is_frozen"],
161
+ joinPolicy: j["join_policy"],
162
+ timerSeconds: j["timer_seconds"],
163
+ memberCount: j["member_count"],
164
+ lastMessageId: j["last_message_id"],
165
+ lastMessageAt: j["last_message_at"],
166
+ lastMessagePreview: j["last_message_preview"],
167
+ currentAnnouncement: j["current_announcement"] == null ? null : parseAnnouncementOut(j["current_announcement"]),
168
+ createdAt: j["created_at"]
169
+ };
170
+ }
150
171
  function parseBlockOut(j) {
151
172
  return {
152
173
  blockedUserId: j["blocked_user_id"],
@@ -216,6 +237,16 @@ function parseReactionEntry(j) {
216
237
  };
217
238
  }
218
239
 
240
+ // src/cache.ts
241
+ var CacheKeys = {
242
+ /** 저장 포맷 스키마 버전. 저장되는 JSON 모양이 바뀌면 올린다. */
243
+ version: "v1",
244
+ /** 방 목록 스냅샷 (`GET /users/me/unread` 원문). */
245
+ rooms: "nc.v1.rooms",
246
+ /** 방 히스토리 최신 페이지 스냅샷 (`GET /rooms/{id}/messages` 원문). */
247
+ msgs: (roomId) => `nc.v1.msgs.${roomId}`
248
+ };
249
+
219
250
  // src/errors.ts
220
251
  var ErrorCode = {
221
252
  INTERNAL: "NC-GEN-001",
@@ -510,6 +541,487 @@ function tempId() {
510
541
  return "tmp_" + bytes.map((b) => b.toString(16).padStart(2, "0")).join("");
511
542
  }
512
543
 
544
+ // src/chat-message.ts
545
+ function fileInlineFromWire(w) {
546
+ return {
547
+ id: w.id,
548
+ kind: w.kind,
549
+ mime: w.mime,
550
+ size: w.size,
551
+ name: w.name ?? null,
552
+ width: w.width ?? null,
553
+ height: w.height ?? null,
554
+ durationSec: w.duration_sec ?? null,
555
+ thumbnailStatus: w.thumbnail_status,
556
+ forwardBlocked: Boolean(w.forward_blocked)
557
+ };
558
+ }
559
+ function chatMessageFromHistory(m) {
560
+ return {
561
+ id: m.messageId,
562
+ senderId: m.senderId ?? null,
563
+ content: m.content ?? null,
564
+ createdAtMs: m.createdAtMs,
565
+ status: "sent",
566
+ messageType: m.messageType,
567
+ sender: m.sender ?? null,
568
+ customType: m.customType ?? null,
569
+ fileId: m.fileId ?? null,
570
+ file: m.file ?? null,
571
+ files: m.files ?? null,
572
+ replyToId: m.replyToId ?? null,
573
+ reactions: m.reactions ?? [],
574
+ customMeta: m.customMeta ?? null,
575
+ editedCount: m.editedCount ?? 0,
576
+ uploadProgress: null,
577
+ tempId: null,
578
+ isDeleted: m.deletedAt != null
579
+ };
580
+ }
581
+ function chatMessageFromLive(f) {
582
+ const data = f.data ?? null;
583
+ const customMeta = data && typeof data === "object" && "_customMeta" in data ? data._customMeta : null;
584
+ return {
585
+ id: f.message_id,
586
+ senderId: f.sender_id,
587
+ content: f.content ?? null,
588
+ createdAtMs: f.created_at,
589
+ status: "sent",
590
+ messageType: f.message_type,
591
+ sender: null,
592
+ customType: f.custom_type ?? null,
593
+ fileId: f.file_id ?? null,
594
+ file: f.file ? fileInlineFromWire(f.file) : null,
595
+ files: f.files ? f.files.map(fileInlineFromWire) : null,
596
+ replyToId: f.reply_to_id ?? null,
597
+ reactions: [],
598
+ customMeta,
599
+ editedCount: 0,
600
+ uploadProgress: null,
601
+ tempId: null,
602
+ isDeleted: false
603
+ };
604
+ }
605
+ function pendingChatMessage(p) {
606
+ return {
607
+ id: p.tempId,
608
+ senderId: null,
609
+ content: p.content,
610
+ createdAtMs: Date.now(),
611
+ status: "sending",
612
+ messageType: "TEXT",
613
+ sender: null,
614
+ customType: p.customType ?? null,
615
+ fileId: null,
616
+ file: null,
617
+ files: null,
618
+ replyToId: p.replyToId ?? null,
619
+ reactions: [],
620
+ customMeta: p.customMeta ?? null,
621
+ editedCount: 0,
622
+ uploadProgress: null,
623
+ tempId: p.tempId,
624
+ isDeleted: false
625
+ };
626
+ }
627
+ function pendingFileChatMessage(p) {
628
+ const mime = p.mime ?? "application/octet-stream";
629
+ return {
630
+ id: p.tempId,
631
+ senderId: null,
632
+ content: null,
633
+ createdAtMs: Date.now(),
634
+ status: "sending",
635
+ messageType: "FILE",
636
+ sender: null,
637
+ customType: null,
638
+ fileId: null,
639
+ file: {
640
+ id: "",
641
+ kind: mime.startsWith("image/") ? "image" : mime.startsWith("video/") ? "video" : "file",
642
+ mime,
643
+ size: p.size ?? 0,
644
+ name: p.name,
645
+ thumbnailStatus: "pending",
646
+ forwardBlocked: false
647
+ },
648
+ files: null,
649
+ replyToId: null,
650
+ reactions: [],
651
+ customMeta: null,
652
+ editedCount: 0,
653
+ uploadProgress: 0,
654
+ tempId: p.tempId,
655
+ isDeleted: false
656
+ };
657
+ }
658
+
659
+ // src/internal/compare-id.ts
660
+ function idGreater(a, b) {
661
+ if (!a || a === "0") return false;
662
+ if (!b || b === "0") return true;
663
+ try {
664
+ return BigInt(a) > BigInt(b);
665
+ } catch {
666
+ return a > b;
667
+ }
668
+ }
669
+
670
+ // src/features/message-collection.ts
671
+ var uploadSeq = 0;
672
+ var MessageCollection = class {
673
+ constructor(room, policy = "replaceByApi") {
674
+ this.room = room;
675
+ this.policy = policy;
676
+ this.unsubs.push(
677
+ room.on("message", (f) => this.onIncoming(f)),
678
+ room.on("messageUpdated", (f) => this.onUpdated(f)),
679
+ room.on("messageDeleted", (f) => this.onDeleted(f)),
680
+ room.on("messagesCleared", (f) => this.onCleared(f)),
681
+ room.on("reactionAdded", (f) => this.onReactionAdded(f)),
682
+ room.on("reactionRemoved", (f) => this.onReactionRemoved(f)),
683
+ room.on("syncTick", (f) => this.onSyncTick(f))
684
+ );
685
+ }
686
+ room;
687
+ policy;
688
+ bus = new EventBus();
689
+ unsubs = [];
690
+ _messages = [];
691
+ _readWatermarks = {};
692
+ _hasMore = false;
693
+ oldestCursor = null;
694
+ loadingMore = false;
695
+ /**
696
+ * 캐시 스냅샷으로 렌더된 머리쪽 메시지 수 — API 도착 시 이만큼 걷어내고
697
+ * 통째 교체한다(replaceByApi). 라이브 수신은 리스트 꼬리에 붙으므로
698
+ * 머리 range 제거로 스냅샷만 정확히 걷힌다.
699
+ */
700
+ hydratedCount = 0;
701
+ /** The current messages, oldest first. Treat as read-only. */
702
+ get messages() {
703
+ return this._messages;
704
+ }
705
+ /** True when older history remains — fire `loadMore()` from the top of
706
+ * the scroll view. */
707
+ get hasMore() {
708
+ return this._hasMore;
709
+ }
710
+ /** Per-user read watermarks (`userId` → last read `messageId`), kept live
711
+ * from `sync_tick` frames. Treat as read-only. */
712
+ get readWatermarks() {
713
+ return this._readWatermarks;
714
+ }
715
+ /** State-change notifications. One event per mutation; re-read `messages`
716
+ * (and friends) when it fires. Returns an unsubscribe fn. */
717
+ on(event, fn) {
718
+ return this.bus.on(event, fn);
719
+ }
720
+ /** Whether `userId` has read `messageId` according to the latest watermark. */
721
+ isReadBy(userId, messageId) {
722
+ const w = this._readWatermarks[userId];
723
+ if (!w) return false;
724
+ return !idGreater(messageId, w);
725
+ }
726
+ /** How many of the given users have read `messageId`. Pass the room's
727
+ * member ids (minus the sender) to get a KakaoTalk-style unread count:
728
+ * `members.length - readCount(...)`. */
729
+ readCount(messageId, userIds) {
730
+ let n = 0;
731
+ for (const u of userIds) if (this.isReadBy(u, messageId)) n++;
732
+ return n;
733
+ }
734
+ // ── loading — cache hydrate → API wholesale replace ─────────────
735
+ /**
736
+ * Load the most recent page of history.
737
+ *
738
+ * `ClientOptions.cacheStore`가 주입돼 있으면 캐시 스냅샷을 먼저 렌더한
739
+ * 뒤(`hydrated`), API 응답 도착 시 스냅샷을 걷어내고 통째로 교체한다
740
+ * (`replaced`). 스냅샷 렌더 중에는 `hasMore`/스크롤백을 열지 않는다 —
741
+ * 커서는 API 응답의 것만 신뢰한다.
742
+ */
743
+ async load(limit = 50) {
744
+ const pending = this.room.listRecent(limit);
745
+ await this.hydrateFromCache();
746
+ const page = await pending;
747
+ this.applyFresh(page);
748
+ }
749
+ /** Scrollback: load the page of messages older than what's on screen and
750
+ * prepend it. No-op while a previous call is in flight or when `hasMore`
751
+ * is false. Returns the number of messages added. */
752
+ async loadMore(limit = 50) {
753
+ const cursor = this.oldestCursor;
754
+ if (this.loadingMore || !this._hasMore || cursor == null) return 0;
755
+ this.loadingMore = true;
756
+ try {
757
+ const page = await this.room.listBefore(cursor, limit);
758
+ this._messages.unshift(...page.items.map(chatMessageFromHistory));
759
+ this.oldestCursor = page.nextCursor;
760
+ this._hasMore = page.hasMore;
761
+ this.emit("inserted");
762
+ return page.items.length;
763
+ } finally {
764
+ this.loadingMore = false;
765
+ }
766
+ }
767
+ /** 캐시 스냅샷 즉시 렌더 — 빈 리스트일 때만. 실패는 조용히 무시(캐시는
768
+ * 힌트일 뿐). */
769
+ async hydrateFromCache() {
770
+ if (this._messages.length > 0 || this.hydratedCount > 0) return;
771
+ const cached = await this.room.cachedRecent();
772
+ if (!cached || cached.items.length === 0 || this._messages.length > 0) {
773
+ return;
774
+ }
775
+ this._messages.unshift(...cached.items.map(chatMessageFromHistory));
776
+ this.hydratedCount = cached.items.length;
777
+ this.emit("hydrated");
778
+ }
779
+ /**
780
+ * API 최신 페이지를 컬렉션에 반영한다 — **diff 병합 진입점**.
781
+ * replaceByApi(현재 유일 정책)는 스냅샷을 걷어내고 통째 교체하고,
782
+ * mergeDiff는 서버 무효화 규칙 확정 후 이 분기 안에 병합 함수가 채워진다.
783
+ */
784
+ applyFresh(page) {
785
+ if (this.policy === "mergeDiff") {
786
+ throw new Error('CachePolicy "mergeDiff" \uB294 \uC544\uC9C1 \uBBF8\uAD6C\uD604 \u2014 \uC11C\uBC84 \uBB34\uD6A8\uD654 \uADDC\uCE59 \uD655\uC815 \uD6C4 \uC9C0\uC6D0 \uC608\uC815');
787
+ }
788
+ if (this.hydratedCount > 0) {
789
+ this._messages.splice(0, this.hydratedCount);
790
+ this.hydratedCount = 0;
791
+ }
792
+ this._messages.unshift(...page.items.map(chatMessageFromHistory));
793
+ this.oldestCursor = page.nextCursor;
794
+ this._hasMore = page.hasMore;
795
+ this.emit("replaced");
796
+ }
797
+ // ── optimistic sends ────────────────────────────────────────────
798
+ /**
799
+ * Send `text` optimistically: a `sending` bubble appears immediately (one
800
+ * microtask later — `Room.send` is async), then flips to `sent` (with the
801
+ * real id) on ack, or `failed` on error. Fire-and-forget: a WS-not-open
802
+ * failure surfaces as a `failed` bubble rather than a rejected promise.
803
+ */
804
+ send(text, opts) {
805
+ void (async () => {
806
+ let tempId2;
807
+ let ackPromise;
808
+ try {
809
+ const sent = await this.room.send({
810
+ content: text,
811
+ ...opts?.replyToId ? { replyToId: opts.replyToId } : {},
812
+ ...opts?.customType ? { customType: opts.customType } : {},
813
+ ...opts?.customMeta ? { customMeta: opts.customMeta } : {}
814
+ });
815
+ tempId2 = sent.tempId;
816
+ ackPromise = sent.messageId;
817
+ } catch {
818
+ const localId = `failed_${Date.now()}_${uploadSeq++}`;
819
+ this._messages.push({
820
+ ...pendingChatMessage({ tempId: localId, content: text, ...opts }),
821
+ status: "failed"
822
+ });
823
+ this.emit("inserted");
824
+ return;
825
+ }
826
+ this._messages.push(pendingChatMessage({ tempId: tempId2, content: text, ...opts }));
827
+ this.emit("inserted");
828
+ this.bindAck(tempId2, ackPromise);
829
+ })();
830
+ }
831
+ /** Send a file optimistically. A FILE bubble with `uploadProgress`
832
+ * appears immediately; progress advances during the S3 upload, then the
833
+ * bubble flips to `sent` on ack (or `failed`). Never throws — failures
834
+ * surface as the bubble's status. */
835
+ async sendFile(file, opts) {
836
+ const localId = `upload_${Date.now()}_${uploadSeq++}`;
837
+ this._messages.push(
838
+ pendingFileChatMessage({
839
+ tempId: localId,
840
+ name: opts?.name ?? file.name,
841
+ mime: file.type,
842
+ size: file.size
843
+ })
844
+ );
845
+ this.emit("inserted");
846
+ try {
847
+ const sent = await this.room.sendFile(file, {
848
+ ...opts ?? {},
849
+ onProgress: (p) => {
850
+ this.mutateByTempId(localId, (m) => ({
851
+ ...m,
852
+ uploadProgress: p.ratio
853
+ }));
854
+ opts?.onProgress?.(p);
855
+ }
856
+ });
857
+ this.mutateByTempId(localId, (m) => ({
858
+ ...m,
859
+ fileId: sent.fileId,
860
+ file: m.file ? { ...m.file, id: sent.fileId } : m.file,
861
+ uploadProgress: null
862
+ }));
863
+ const realId = await sent.messageId;
864
+ this.mutateByTempId(localId, (m) => ({
865
+ ...m,
866
+ id: realId,
867
+ status: "sent"
868
+ }));
869
+ } catch {
870
+ this.mutateByTempId(localId, (m) => ({
871
+ ...m,
872
+ status: "failed",
873
+ uploadProgress: null
874
+ }));
875
+ }
876
+ }
877
+ /** Edit a message's content (server-side; peers get `message_updated`).
878
+ * The local copy updates optimistically. */
879
+ async edit(messageId, content) {
880
+ await this.room.edit(messageId, { content });
881
+ this.mutateById(messageId, (m) => ({
882
+ ...m,
883
+ content,
884
+ editedCount: m.editedCount + 1
885
+ }));
886
+ }
887
+ /** Delete a message. `scope: "ALL"` (default) removes it for everyone;
888
+ * `"MY"` hides it only for the caller. Local copy flips to deleted. */
889
+ async delete(messageId, scope = "ALL") {
890
+ await this.room.delete(messageId, scope);
891
+ this.mutateById(messageId, (m) => ({ ...m, isDeleted: true }));
892
+ }
893
+ /** Add/remove the caller's reaction. Pass `myUserId` so the toggle can
894
+ * tell whether the caller already reacted with `key`. The authoritative
895
+ * state arrives back via the reaction WS events. */
896
+ async toggleReaction(messageId, key, myUserId) {
897
+ const m = this._messages.find((x) => x.id === messageId);
898
+ const mine = m?.reactions.some(
899
+ (r) => r.key === key && r.userIds.includes(myUserId)
900
+ ) ?? false;
901
+ if (mine) {
902
+ await this.room.unreact(messageId, key);
903
+ } else {
904
+ await this.room.react(messageId, key);
905
+ }
906
+ }
907
+ /** Mark `messageId` read (debounced inside the SDK). */
908
+ markRead(messageId) {
909
+ this.room.markRead(messageId);
910
+ }
911
+ /** Force-send the pending read watermark now (e.g. on unmount / tab
912
+ * backgrounding). */
913
+ flushMarkRead() {
914
+ this.room.flushMarkRead();
915
+ }
916
+ /** Unsubscribe from the room and flush the pending read watermark. The
917
+ * collection must not be used afterwards. */
918
+ dispose() {
919
+ this.room.flushMarkRead();
920
+ for (const u of this.unsubs.splice(0)) u();
921
+ this.bus.removeAll();
922
+ }
923
+ // ── event handlers ──────────────────────────────────────────────
924
+ onIncoming(f) {
925
+ this._messages.push(chatMessageFromLive(f));
926
+ this.emit("inserted");
927
+ }
928
+ onUpdated(f) {
929
+ this.mutateById(f.message_id, (m) => ({
930
+ ...m,
931
+ content: f.content ?? m.content,
932
+ editedCount: m.editedCount + 1
933
+ }));
934
+ }
935
+ onDeleted(f) {
936
+ this.mutateById(f.message_id, (m) => ({ ...m, isDeleted: true }));
937
+ }
938
+ onCleared(f) {
939
+ const upTo = f.up_to_message_id;
940
+ let changed = false;
941
+ this._messages = this._messages.map((m) => {
942
+ if (!m.isDeleted && (upTo == null || !idGreater(m.id, upTo))) {
943
+ changed = true;
944
+ return { ...m, isDeleted: true };
945
+ }
946
+ return m;
947
+ });
948
+ if (changed) this.emit("updated");
949
+ }
950
+ onReactionAdded(f) {
951
+ this.mutateById(f.message_id, (m) => {
952
+ const next = m.reactions.map(
953
+ (r) => r.key === f.reaction_key ? {
954
+ key: r.key,
955
+ userIds: [.../* @__PURE__ */ new Set([...r.userIds, f.user_id])],
956
+ updatedAt: f.updated_at
957
+ } : r
958
+ );
959
+ if (!next.some((r) => r.key === f.reaction_key)) {
960
+ next.push({
961
+ key: f.reaction_key,
962
+ userIds: [f.user_id],
963
+ updatedAt: f.updated_at
964
+ });
965
+ }
966
+ return { ...m, reactions: next };
967
+ });
968
+ }
969
+ onReactionRemoved(f) {
970
+ this.mutateById(f.message_id, (m) => {
971
+ const next = [];
972
+ for (const r of m.reactions) {
973
+ if (r.key !== f.reaction_key) {
974
+ next.push(r);
975
+ continue;
976
+ }
977
+ const users = r.userIds.filter((u) => u !== f.user_id);
978
+ if (users.length > 0) {
979
+ next.push({ key: r.key, userIds: users, updatedAt: f.updated_at });
980
+ }
981
+ }
982
+ return { ...m, reactions: next };
983
+ });
984
+ }
985
+ onSyncTick(f) {
986
+ let changed = false;
987
+ for (const [userId, mid] of Object.entries(f.read_states)) {
988
+ const cur = this._readWatermarks[userId];
989
+ if (!cur || idGreater(mid, cur)) {
990
+ this._readWatermarks[userId] = mid;
991
+ changed = true;
992
+ }
993
+ }
994
+ if (changed) this.emit("watermarks");
995
+ }
996
+ // ── helpers ─────────────────────────────────────────────────────
997
+ bindAck(tempId2, messageId) {
998
+ messageId.then((realId) => {
999
+ this.mutateByTempId(tempId2, (m) => ({
1000
+ ...m,
1001
+ id: realId,
1002
+ status: "sent"
1003
+ }));
1004
+ }).catch(() => {
1005
+ this.mutateByTempId(tempId2, (m) => ({ ...m, status: "failed" }));
1006
+ });
1007
+ }
1008
+ mutateById(id, f) {
1009
+ const i = this._messages.findIndex((m) => m.id === id);
1010
+ if (i === -1) return;
1011
+ this._messages[i] = f(this._messages[i]);
1012
+ this.emit("updated");
1013
+ }
1014
+ mutateByTempId(tempId2, f) {
1015
+ const i = this._messages.findIndex((m) => m.tempId === tempId2);
1016
+ if (i === -1) return;
1017
+ this._messages[i] = f(this._messages[i]);
1018
+ this.emit("updated");
1019
+ }
1020
+ emit(kind) {
1021
+ this.bus.emit("change", { kind });
1022
+ }
1023
+ };
1024
+
513
1025
  // src/features/room.ts
514
1026
  function toFileInline(f) {
515
1027
  return {
@@ -539,11 +1051,14 @@ function toMessageOut(j) {
539
1051
  };
540
1052
  }
541
1053
  var Room = class _Room {
542
- constructor(roomId, http, ws, readDebounceMs) {
1054
+ constructor(roomId, http, ws, readDebounceMs, opts) {
543
1055
  this.roomId = roomId;
544
1056
  this.http = http;
545
1057
  this.ws = ws;
546
1058
  this.readDebounceMs = readDebounceMs;
1059
+ this.cacheStore = opts?.cacheStore ?? null;
1060
+ this.cachePolicy = opts?.cachePolicy ?? "replaceByApi";
1061
+ this.logger = opts?.logger;
547
1062
  }
548
1063
  roomId;
549
1064
  http;
@@ -587,6 +1102,30 @@ var Room = class _Room {
587
1102
  * fine in practice (small strings, cleared on `chat.disconnect()`).
588
1103
  */
589
1104
  mySentTempIds = /* @__PURE__ */ new Set();
1105
+ // ── 스냅샷 캐시 협력자 (클라이언트 옵션에서 흘러듦) ──────────────
1106
+ cacheStore;
1107
+ cachePolicy;
1108
+ logger;
1109
+ /** 삭제 타이머 여부 세션 메모 — null=미확인, 값=마지막 detail() 결과. */
1110
+ timerSeconds = null;
1111
+ timerKnown = false;
1112
+ /**
1113
+ * 이 방의 메시지 상태를 소유하는 `MessageCollection`을 만든다.
1114
+ *
1115
+ * 컬렉션이 이벤트 반영·낙관적 전송·캐시 hydrate→교체를 전부 담당하므로,
1116
+ * 리스트 화면은 컬렉션 하나만 물면 된다. 여러 개 만들 수 있고 각자
1117
+ * `dispose()`로 정리한다.
1118
+ *
1119
+ * ```ts
1120
+ * const col = chat.room("room_123").collection();
1121
+ * const unsub = col.on("change", () => render(col.messages));
1122
+ * await col.load();
1123
+ * col.send("hello");
1124
+ * ```
1125
+ */
1126
+ collection(policy) {
1127
+ return new MessageCollection(this, policy ?? this.cachePolicy);
1128
+ }
590
1129
  _sendPendingMarkRead() {
591
1130
  const mid = this.pendingReadMid;
592
1131
  if (!mid) return;
@@ -1043,11 +1582,76 @@ var Room = class _Room {
1043
1582
  */
1044
1583
  async listRecent(limit = 50) {
1045
1584
  const raw = await this.http.request("GET", `/api/v1/rooms/${this.roomId}/messages`, void 0, { limit });
1046
- return {
1585
+ const page = {
1047
1586
  items: raw.items.map(toMessageOut),
1587
+ // 파싱 성공한 응답만 캐시에 넣는다
1048
1588
  nextCursor: raw.next_cursor,
1049
1589
  hasMore: raw.has_more
1050
1590
  };
1591
+ void this.cacheWriteRecent(raw);
1592
+ return page;
1593
+ }
1594
+ /** 이 방의 상세 정보 (`GET /rooms/{id}`) — 이름·멤버 수·삭제 타이머 등. */
1595
+ async detail() {
1596
+ const raw = await this.http.request(
1597
+ "GET",
1598
+ `/api/v1/rooms/${this.roomId}`
1599
+ );
1600
+ const out = parseRoomOut(raw);
1601
+ this.timerSeconds = out.timerSeconds;
1602
+ this.timerKnown = true;
1603
+ return out;
1604
+ }
1605
+ /**
1606
+ * 히스토리 최신 페이지의 캐시 스냅샷 — 마지막 `listRecent()` 성공 응답의
1607
+ * 원문. 콜드 스타트 렌더 힌트 용도다: 있으면 즉시 그리고, `listRecent()`가
1608
+ * 도착하면 통째로 교체할 것. 캐시가 없거나(`cacheStore` 미주입 포함)
1609
+ * 파싱이 실패하면 `null`을 돌려주고, 깨진 스냅샷은 지운다.
1610
+ *
1611
+ * 스냅샷의 `nextCursor`/`hasMore`는 시점이 지난 값이므로 스크롤백
1612
+ * 커서로 쓰지 말 것 — API 응답이 도착한 뒤의 값만 신뢰한다.
1613
+ */
1614
+ async cachedRecent() {
1615
+ const store = this.cacheStore;
1616
+ if (!store) return null;
1617
+ const key = CacheKeys.msgs(this.roomId);
1618
+ try {
1619
+ const raw = await store.read(key);
1620
+ if (raw == null) return null;
1621
+ const j = JSON.parse(raw);
1622
+ return {
1623
+ items: j.items.map(toMessageOut),
1624
+ nextCursor: j.next_cursor,
1625
+ hasMore: j.has_more
1626
+ };
1627
+ } catch (err) {
1628
+ this.logger?.("warn", `cache_read_failed key=${key}`, err);
1629
+ try {
1630
+ await store.remove(key);
1631
+ } catch {
1632
+ }
1633
+ return null;
1634
+ }
1635
+ }
1636
+ /**
1637
+ * 최신 페이지 스냅샷 저장. 삭제 타이머가 켜진 방은 저장하지 않고 기존
1638
+ * 스냅샷도 지운다 — 타이머 만료로 서버에서 사라진 메시지가 로컬 캐시에
1639
+ * 살아남는 보안 충돌을 원천 차단한다.
1640
+ */
1641
+ async cacheWriteRecent(raw) {
1642
+ const store = this.cacheStore;
1643
+ if (!store) return;
1644
+ const key = CacheKeys.msgs(this.roomId);
1645
+ try {
1646
+ if (!this.timerKnown) await this.detail();
1647
+ if (this.timerSeconds != null) {
1648
+ await store.remove(key);
1649
+ return;
1650
+ }
1651
+ await store.write(key, JSON.stringify(raw));
1652
+ } catch (err) {
1653
+ this.logger?.("warn", `cache_write_failed key=${key}`, err);
1654
+ }
1051
1655
  }
1052
1656
  /**
1053
1657
  * Scrollback. Fetches the page of `limit` messages immediately preceding
@@ -1066,7 +1670,7 @@ var Room = class _Room {
1066
1670
  hasMore: raw.has_more
1067
1671
  };
1068
1672
  }
1069
- /** Phase D #2 — 방 내 메시지 검색.
1673
+ /** 방 내 메시지 검색.
1070
1674
  *
1071
1675
  * ``content`` 필드에 대한 대소문자 무시 부분 문자열 매칭 (ILIKE). 삭제된
1072
1676
  * 메시지는 제외. 결과는 최신순 (message_id DESC) 으로 반환되고, ``before``
@@ -1422,7 +2026,7 @@ var Room = class _Room {
1422
2026
  };
1423
2027
 
1424
2028
  // src/client.ts
1425
- function idGreater(a, b) {
2029
+ function idGreater2(a, b) {
1426
2030
  if (!a) return false;
1427
2031
  if (!b) return true;
1428
2032
  try {
@@ -1437,6 +2041,11 @@ var NoveraChat = class {
1437
2041
  if (!opts.appId) throw new Error("appId required");
1438
2042
  if (!opts.endpoint) throw new Error("endpoint required");
1439
2043
  if (!opts.tokenProvider) throw new Error("tokenProvider required");
2044
+ if (opts.cachePolicy === "mergeDiff") {
2045
+ throw new Error(
2046
+ 'CachePolicy "mergeDiff" \uB294 \uC544\uC9C1 \uBBF8\uAD6C\uD604 \u2014 \uD604\uC7AC "replaceByApi"\uB9CC \uC9C0\uC6D0'
2047
+ );
2048
+ }
1440
2049
  this.readDebounceMs = opts.readWatermarkDebounceMs ?? 400;
1441
2050
  this.logger = opts.logger;
1442
2051
  if (opts.initialLastMessageIds) {
@@ -1508,7 +2117,11 @@ var NoveraChat = class {
1508
2117
  room(roomId) {
1509
2118
  let r = this.rooms.get(roomId);
1510
2119
  if (!r) {
1511
- r = new Room(roomId, this.http, this.ws, this.readDebounceMs);
2120
+ r = new Room(roomId, this.http, this.ws, this.readDebounceMs, {
2121
+ ...this.opts.cacheStore ? { cacheStore: this.opts.cacheStore } : {},
2122
+ ...this.opts.cachePolicy ? { cachePolicy: this.opts.cachePolicy } : {},
2123
+ ...this.logger ? { logger: this.logger } : {}
2124
+ });
1512
2125
  this.rooms.set(roomId, r);
1513
2126
  }
1514
2127
  return r;
@@ -1553,7 +2166,7 @@ var NoveraChat = class {
1553
2166
  const sid = typeof messageId === "number" ? String(messageId) : messageId;
1554
2167
  if (!sid || sid === "0") return;
1555
2168
  const cur = this.lastMessageIdByRoom.get(roomId);
1556
- if (idGreater(sid, cur)) this.lastMessageIdByRoom.set(roomId, sid);
2169
+ if (idGreater2(sid, cur)) this.lastMessageIdByRoom.set(roomId, sid);
1557
2170
  }
1558
2171
  /** Server-side aggregate unread state: total count + per-room breakdown
1559
2172
  * for the authenticated user. Single REST call; cheap enough to poll
@@ -1755,7 +2368,41 @@ var NoveraChat = class {
1755
2368
  }
1756
2369
  async unreadSummary() {
1757
2370
  const raw = await this.http.request("GET", "/api/v1/users/me/unread");
1758
- return parseUnreadSummary(raw);
2371
+ const out = parseUnreadSummary(raw);
2372
+ void this.cacheWriteRooms(raw);
2373
+ return out;
2374
+ }
2375
+ /**
2376
+ * 방 목록의 캐시 스냅샷 — 마지막 `unreadSummary()` 성공 응답의 원문.
2377
+ *
2378
+ * 콜드 스타트 렌더 힌트 용도다: 있으면 즉시 그리고, `unreadSummary()`가
2379
+ * 도착하면 통째로 교체할 것. 캐시가 없거나(`cacheStore` 미주입 포함)
2380
+ * 파싱이 실패하면 `null`을 돌려주고, 깨진 스냅샷은 지운다.
2381
+ */
2382
+ async cachedUnreadSummary() {
2383
+ const store = this.opts.cacheStore;
2384
+ if (!store) return null;
2385
+ try {
2386
+ const raw = await store.read(CacheKeys.rooms);
2387
+ if (raw == null) return null;
2388
+ return parseUnreadSummary(JSON.parse(raw));
2389
+ } catch (err) {
2390
+ this.logger?.("warn", `cache_read_failed key=${CacheKeys.rooms}`, err);
2391
+ try {
2392
+ await store.remove(CacheKeys.rooms);
2393
+ } catch {
2394
+ }
2395
+ return null;
2396
+ }
2397
+ }
2398
+ async cacheWriteRooms(raw) {
2399
+ const store = this.opts.cacheStore;
2400
+ if (!store) return;
2401
+ try {
2402
+ await store.write(CacheKeys.rooms, JSON.stringify(raw));
2403
+ } catch (err) {
2404
+ this.logger?.("warn", `cache_write_failed key=${CacheKeys.rooms}`, err);
2405
+ }
1759
2406
  }
1760
2407
  /**
1761
2408
  * Accept an invite link — call this in flows where you receive a
@@ -1836,7 +2483,7 @@ var NoveraChat = class {
1836
2483
  return;
1837
2484
  }
1838
2485
  case "ack": {
1839
- if (idGreater(msg.message_id, this.lastMessageIdByRoom.get(msg.room_id))) {
2486
+ if (idGreater2(msg.message_id, this.lastMessageIdByRoom.get(msg.room_id))) {
1840
2487
  this.lastMessageIdByRoom.set(msg.room_id, msg.message_id);
1841
2488
  }
1842
2489
  const room = this.rooms.get(msg.room_id);
@@ -1853,7 +2500,7 @@ var NoveraChat = class {
1853
2500
  if (!roomId) return;
1854
2501
  const room = this.rooms.get(roomId);
1855
2502
  if (!room) return;
1856
- if (msg.type === "chat_receive" && idGreater(msg.message_id, this.lastMessageIdByRoom.get(roomId))) {
2503
+ if (msg.type === "chat_receive" && idGreater2(msg.message_id, this.lastMessageIdByRoom.get(roomId))) {
1857
2504
  this.lastMessageIdByRoom.set(roomId, msg.message_id);
1858
2505
  }
1859
2506
  if (msg.type === "chat_receive") {
@@ -1871,7 +2518,7 @@ var NoveraChat = class {
1871
2518
  highestKnownMessageId() {
1872
2519
  let max = null;
1873
2520
  for (const v of this.lastMessageIdByRoom.values()) {
1874
- if (idGreater(v, max)) max = v;
2521
+ if (idGreater2(v, max)) max = v;
1875
2522
  }
1876
2523
  return max;
1877
2524
  }
@@ -1912,7 +2559,7 @@ var NoveraChat = class {
1912
2559
  created_at: m.createdAtMs,
1913
2560
  server_ts: Date.now()
1914
2561
  });
1915
- if (idGreater(m.messageId, this.lastMessageIdByRoom.get(roomId))) {
2562
+ if (idGreater2(m.messageId, this.lastMessageIdByRoom.get(roomId))) {
1916
2563
  this.lastMessageIdByRoom.set(roomId, m.messageId);
1917
2564
  }
1918
2565
  }
@@ -1927,8 +2574,14 @@ var NoveraChat = class {
1927
2574
  }
1928
2575
  };
1929
2576
  export {
2577
+ CacheKeys,
1930
2578
  ErrorCode,
2579
+ MessageCollection,
1931
2580
  NoveraChat,
1932
2581
  NoveraChatError,
1933
- Room
2582
+ Room,
2583
+ chatMessageFromHistory,
2584
+ chatMessageFromLive,
2585
+ pendingChatMessage,
2586
+ pendingFileChatMessage
1934
2587
  };