@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.d.cts CHANGED
@@ -225,6 +225,7 @@ interface UnreadRoomItem {
225
225
  lastMessageId?: string | null;
226
226
  lastReadMessageId?: string | null;
227
227
  lastMessagePreview?: string | null;
228
+ lastMessageAt?: number | null;
228
229
  memberCount?: number;
229
230
  membersPreview?: MemberPreview[];
230
231
  }
@@ -304,6 +305,25 @@ interface DeviceOut {
304
305
  lastActiveAt: string | null;
305
306
  createdAt: string;
306
307
  }
308
+ interface RoomOut {
309
+ appId: string;
310
+ roomId: string;
311
+ roomType: RoomType;
312
+ customType: string | null;
313
+ name: string | null;
314
+ coverImageUrl: string | null;
315
+ isDistinct: boolean;
316
+ isPublic: boolean;
317
+ isFrozen: boolean;
318
+ joinPolicy: JoinPolicy;
319
+ timerSeconds: number | null;
320
+ memberCount: number;
321
+ lastMessageId?: string | null;
322
+ lastMessageAt: string | null;
323
+ lastMessagePreview?: string | null;
324
+ currentAnnouncement?: AnnouncementOut | null;
325
+ createdAt: string;
326
+ }
307
327
  /** One row from ``user_blocks`` for the caller. We DON'T echo ``blocker_user_id`` — it's always the caller, and including it in every list entry would just add noise. ``model_config`` lets the router return ORM instances directly via ``response_model``. */
308
328
  interface BlockOut {
309
329
  blockedUserId: string;
@@ -350,9 +370,76 @@ interface ReactionEntry {
350
370
  userIds: string[];
351
371
  updatedAt: number;
352
372
  }
373
+ type RoomType = "ONE" | "GROUP" | "SUPER_GROUP";
374
+ type JoinPolicy = "OPEN" | "APPROVAL_REQUIRED";
353
375
  type MemberRole = "OPERATOR" | "MEMBER" | "KICKED";
354
376
  type PushTrigger = "ALL" | "MENTION_ONLY" | "OFF";
355
377
 
378
+ /**
379
+ * 스냅샷 캐시 — 콜드 스타트 스피너 제거용 렌더 힌트 계층.
380
+ * (noverachat_dart `lib/src/cache.dart`의 TS 미러 — 의미론 동일)
381
+ *
382
+ * 원칙:
383
+ * 1. 캐시 = 렌더 힌트, 서버가 항상 이긴다 — 시작 시 스냅샷 즉시 렌더,
384
+ * API 도착 시 기본 통째 교체(replaceByApi). 옵트인 mergeDiff는
385
+ * fresh보다 오래된 메시지만 보존하는 병합(서버를 덮어쓰지 않음).
386
+ * 2. 저장소는 SDK가 정하지 않는다 — `CacheStore` 인터페이스만 정의,
387
+ * 구현은 앱이 주입(localStorage/IndexedDB 등).
388
+ * 3. 버전 스탬프 + 통째 폐기 — 키에 스키마 버전(`nc.v1.…`), 파싱 실패
389
+ * 시 해당 키를 드랍.
390
+ */
391
+ /**
392
+ * 앱이 주입하는 key-value 스냅샷 저장소.
393
+ *
394
+ * SDK는 저장 방식에 관여하지 않는다 — 값은 불투명한 JSON 문자열이며,
395
+ * 앱이 localStorage·IndexedDB·파일 등 아무 곳에나 보관하면 된다.
396
+ * 모든 메서드는 실패해도 SDK 동작에 영향이 없다(캐시는 힌트일 뿐).
397
+ *
398
+ * ```ts
399
+ * const localStorageCache: CacheStore = {
400
+ * async read(key) { return localStorage.getItem(key); },
401
+ * async write(key, value) { localStorage.setItem(key, value); },
402
+ * async remove(key) { localStorage.removeItem(key); },
403
+ * };
404
+ *
405
+ * const chat = new NoveraChat({
406
+ * appId, endpoint, tokenProvider,
407
+ * cacheStore: localStorageCache,
408
+ * });
409
+ * ```
410
+ */
411
+ interface CacheStore {
412
+ /** `key`의 스냅샷을 돌려준다. 없으면 null. */
413
+ read(key: string): Promise<string | null>;
414
+ /** `key`에 스냅샷을 저장한다(덮어쓰기). */
415
+ write(key: string, value: string): Promise<void>;
416
+ /** `key`의 스냅샷을 지운다. 없어도 에러 없이 통과. */
417
+ remove(key: string): Promise<void>;
418
+ }
419
+ /**
420
+ * 캐시 스냅샷과 API 응답을 어떻게 합칠지.
421
+ *
422
+ * - `"replaceByApi"` (기본) — 스냅샷 즉시 렌더 후 API 도착 시 통째 교체.
423
+ * - `"mergeDiff"` — fresh 페이지보다 오래된 캐시 메시지를 보존·병합해 더
424
+ * 긴 연속 히스토리 유지. `history_from`으로 컷오프분을, fresh 대조로
425
+ * 삭제분을 걸러내고, 캐시·fresh 사이 갭(겹침 없음)이면 통째 교체 폴백.
426
+ */
427
+ type CachePolicy = "replaceByApi" | "mergeDiff";
428
+ /**
429
+ * 캐시 키 규칙 — `nc.<스키마버전>.<대상>`.
430
+ *
431
+ * 스키마 버전이 키에 박혀 있으므로, 저장 포맷이 바뀌면 버전을 올리는
432
+ * 것만으로 구버전 스냅샷은 다시는 읽히지 않는다(자연 폐기).
433
+ */
434
+ declare const CacheKeys: {
435
+ /** 저장 포맷 스키마 버전. 저장되는 JSON 모양이 바뀌면 올린다. */
436
+ readonly version: "v1";
437
+ /** 방 목록 스냅샷 (`GET /users/me/unread` 원문). */
438
+ readonly rooms: "nc.v1.rooms";
439
+ /** 방 히스토리 최신 페이지 스냅샷 (`GET /rooms/{id}/messages` 원문). */
440
+ readonly msgs: (roomId: string) => string;
441
+ };
442
+
356
443
  /**
357
444
  * Shared wire types. Must stay in lock-step with:
358
445
  * noverachat-backend/src/noverachat/ws/protocol.py
@@ -433,8 +520,7 @@ interface SendParams {
433
520
  forwardBlocked?: boolean;
434
521
  replyToId?: MessageIdStr;
435
522
  /** Set when this send is forwarding an existing message. The server
436
- * uses it to enforce the source file's ``forwardBlocked`` flag (Phase
437
- * C #7): if the source's file is no-forward AND the destination room
523
+ * uses it to enforce the source file's ``forwardBlocked`` flag: if the source's file is no-forward AND the destination room
438
524
  * differs from the source room, the send is rejected with
439
525
  * ``NC-FILE-014``. Pass the ORIGINAL message's ``messageId``. */
440
526
  forwardOfMessageId?: MessageIdStr;
@@ -507,6 +593,16 @@ interface ClientOptions {
507
593
  endpoint: string;
508
594
  wsEndpoint?: string;
509
595
  tokenProvider: () => Promise<string>;
596
+ /**
597
+ * 스냅샷 캐시 저장소 — 생략(기본)하면 캐시 기능이 전부 꺼진다.
598
+ * 주입하면 방 목록·방 히스토리 최신 페이지가 REST 원문 그대로 저장되고,
599
+ * `cachedUnreadSummary()` / `room.cachedRecent()` 로 콜드 스타트 즉시
600
+ * 렌더에 쓸 수 있다. 자세한 건 `CacheStore` 문서 참조.
601
+ */
602
+ cacheStore?: CacheStore;
603
+ /** 캐시 스냅샷과 API 응답의 합성 방식. 기본 `"replaceByApi"`(통째 교체),
604
+ * `"mergeDiff"`는 옵트인 — 옛 캐시 메시지를 보존·병합. */
605
+ cachePolicy?: CachePolicy;
510
606
  autoReconnect?: boolean;
511
607
  pingIntervalMs?: number;
512
608
  readWatermarkDebounceMs?: number;
@@ -582,6 +678,253 @@ declare class ReconnectingWs {
582
678
  private setState;
583
679
  }
584
680
 
681
+ /**
682
+ * UI 지향 메시지 뷰모델 — sdk-react에서 코어로 이동.
683
+ * (`MessageCollection`이 이 모양으로 상태를 소유하고, 바인딩은 재수출만 한다.
684
+ * noverachat_dart `lib/src/chat_message.dart`의 TS 대응물.)
685
+ */
686
+
687
+ /** Delivery status of an outgoing (optimistic) message. */
688
+ type ChatMessageStatus = "sending" | "sent" | "failed";
689
+ /**
690
+ * A single message as the UI wants it — one uniform shape over the SDK's
691
+ * three sources: `MessageOut` (history/REST), `WsChatReceive` (live/WS), and
692
+ * an optimistic bubble we just sent.
693
+ *
694
+ * Treat instances as immutable — `MessageCollection` evolves them by
695
+ * replacement, never mutation.
696
+ */
697
+ interface ChatMessage {
698
+ /** The server message id, or the `tempId` while still `sending`. */
699
+ id: string;
700
+ /** Author user id. `null` means the current user (an optimistic bubble). */
701
+ senderId: string | null;
702
+ content: string | null;
703
+ /** Creation time in unix milliseconds. */
704
+ createdAtMs: number | null;
705
+ status: ChatMessageStatus;
706
+ /** `TEXT | FILE | EMOTICON | ADMIN | SYSTEM` — UIs use this to render
707
+ * system/admin lines (centered notices) differently from chat bubbles. */
708
+ messageType: string;
709
+ /** Sender identity (nickname / profile image) when the server included it.
710
+ * Null for optimistic bubbles and live frames (which carry only
711
+ * `senderId`) — UIs typically resolve the profile from their member list. */
712
+ sender: SenderMini | null;
713
+ /** App-defined subtype (e.g. a message-priority convention). */
714
+ customType: string | null;
715
+ /** Snowflake id (string) of the attached file, when this is a file message. */
716
+ fileId: string | null;
717
+ /** Inline metadata of the attached file — enough to render a placeholder
718
+ * without a round-trip. May be null even when `fileId` is set; fetch via
719
+ * `chat.getFileMeta` then. */
720
+ file: FileInline | null;
721
+ /** Multi-file bundle (`file_group`). Null for single-file / text messages.
722
+ * Check `files?.length` first and fall back to the `file` singleton. */
723
+ files: FileInline[] | null;
724
+ /** Id of the message this one replies to, if any. */
725
+ replyToId: string | null;
726
+ /** Emoji reactions grouped by key. Kept live by `MessageCollection` from
727
+ * the reaction WS events. */
728
+ reactions: ReactionEntry[];
729
+ /** Sender-attached custom metadata (`data._customMeta`). */
730
+ customMeta: Record<string, unknown> | null;
731
+ /** How many times the message was edited. `> 0` → show an "edited" mark. */
732
+ editedCount: number;
733
+ /** Upload progress in [0, 1] while an optimistic FILE message's bytes are
734
+ * still going up to S3. Null once committed (or for non-file messages). */
735
+ uploadProgress: number | null;
736
+ /** The optimistic temp id, kept for ack reconciliation. Null once confirmed. */
737
+ tempId: string | null;
738
+ isDeleted: boolean;
739
+ }
740
+ /** Build a `ChatMessage` from server history (REST). */
741
+ declare function chatMessageFromHistory(m: MessageOut): ChatMessage;
742
+ /** Build a `ChatMessage` from a live inbound frame (WS). */
743
+ declare function chatMessageFromLive(f: WsChatReceive): ChatMessage;
744
+ /** An optimistic bubble for a message we just sent, before the server acks. */
745
+ declare function pendingChatMessage(p: {
746
+ tempId: string;
747
+ content: string;
748
+ replyToId?: string;
749
+ customType?: string;
750
+ customMeta?: Record<string, unknown>;
751
+ }): ChatMessage;
752
+ /** An optimistic bubble for a FILE message whose bytes are still uploading.
753
+ * `uploadProgress` starts at 0 and is advanced by `MessageCollection`. */
754
+ declare function pendingFileChatMessage(p: {
755
+ tempId: string;
756
+ name: string;
757
+ mime?: string;
758
+ size?: number;
759
+ }): ChatMessage;
760
+
761
+ /**
762
+ * 코어 소유 메시지 상태 — noverachat_dart `MessageCollection`의 TS 미러.
763
+ *
764
+ * RoomStore(sdk-react)에 있던 이벤트 반영·낙관적 전송·캐시 hydrate→교체
765
+ * 로직의 코어 이관본. 바인딩(React·Flutter)은 이 컬렉션을 감싸는 얇은
766
+ * 접착층이 되고, "캐시는 렌더 힌트, 서버가 항상 이긴다" 같은 불변식은
767
+ * 코어가 강제한다. diff 병합(mergeDiff)은 `applyFresh`→`mergeFresh`로
768
+ * 들어온다.
769
+ */
770
+
771
+ /**
772
+ * 컬렉션 상태가 바뀐 이유. 지금 소비자(얇은 래퍼)는 "바뀌었다"만 알면
773
+ * 되므로 kind만 담는다 — 세밀한 렌더 최적화가 필요해지면 필드를
774
+ * **추가**(비파괴)로 확장한다.
775
+ *
776
+ * - `hydrated` 캐시 스냅샷이 렌더됨 (콜드 스타트 힌트 — 곧 `replaced`가 따라온다)
777
+ * - `replaced` API 응답이 스냅샷/기존 페이지를 통째로 대체함 (서버가 이겼다)
778
+ * - `inserted` 메시지가 추가됨 (라이브 수신·낙관적 버블·스크롤백 페이지)
779
+ * - `updated` 기존 메시지가 제자리 갱신됨 (수정·삭제 플래그·리액션·ack 플립)
780
+ * - `watermarks` 읽음 워터마크가 전진함
781
+ */
782
+ type CollectionChangeKind = "hydrated" | "replaced" | "inserted" | "updated" | "watermarks";
783
+ /** `MessageCollection`의 `change` 이벤트로 흐르는 변경 통지. */
784
+ interface CollectionChange {
785
+ kind: CollectionChangeKind;
786
+ }
787
+ /**
788
+ * A `Room`'s message list as owned, live state — the core-side equivalent
789
+ * of Sendbird's `MessageCollection` (without a local DB).
790
+ *
791
+ * Applies the room's events into one `messages` list, drives optimistic
792
+ * sends, and runs the snapshot-cache contract (`load()` hydrates from cache
793
+ * instantly, then wholesale-replaces when the API answers). Subscribe with
794
+ * `on("change", ...)` and re-render `messages` on every event — all state
795
+ * mutations and change emits are synchronous with the WS dispatch.
796
+ *
797
+ * ```ts
798
+ * const col = chat.room("room_123").collection();
799
+ * const unsub = col.on("change", () => render(col.messages));
800
+ * await col.load();
801
+ * col.send("hello");
802
+ * col.markRead(col.messages.at(-1)!.id);
803
+ * // ...
804
+ * col.dispose(); unsub();
805
+ * ```
806
+ */
807
+ declare class MessageCollection {
808
+ /** The room this collection is bound to. */
809
+ readonly room: Room;
810
+ private readonly policy;
811
+ private readonly bus;
812
+ private readonly unsubs;
813
+ private _messages;
814
+ private _readWatermarks;
815
+ private _hasMore;
816
+ private oldestCursor;
817
+ private loadingMore;
818
+ /**
819
+ * 캐시 스냅샷으로 렌더된 머리쪽 메시지 수 — API 도착 시 이만큼 걷어내고
820
+ * 통째 교체한다(replaceByApi). 라이브 수신은 리스트 꼬리에 붙으므로
821
+ * 머리 range 제거로 스냅샷만 정확히 걷힌다.
822
+ */
823
+ private hydratedCount;
824
+ constructor(
825
+ /** The room this collection is bound to. */
826
+ room: Room, policy?: CachePolicy);
827
+ /** The current messages, oldest first. Treat as read-only. */
828
+ get messages(): readonly ChatMessage[];
829
+ /** True when older history remains — fire `loadMore()` from the top of
830
+ * the scroll view. */
831
+ get hasMore(): boolean;
832
+ /** Per-user read watermarks (`userId` → last read `messageId`), kept live
833
+ * from `sync_tick` frames. Treat as read-only. */
834
+ get readWatermarks(): Readonly<Record<string, string>>;
835
+ /** State-change notifications. One event per mutation; re-read `messages`
836
+ * (and friends) when it fires. Returns an unsubscribe fn. */
837
+ on(event: "change", fn: (c: CollectionChange) => void): () => void;
838
+ /** Whether `userId` has read `messageId` according to the latest watermark. */
839
+ isReadBy(userId: string, messageId: string): boolean;
840
+ /** How many of the given users have read `messageId`. Pass the room's
841
+ * member ids (minus the sender) to get a KakaoTalk-style unread count:
842
+ * `members.length - readCount(...)`. */
843
+ readCount(messageId: string, userIds: Iterable<string>): number;
844
+ /**
845
+ * Load the most recent page of history.
846
+ *
847
+ * `ClientOptions.cacheStore`가 주입돼 있으면 캐시 스냅샷을 먼저 렌더한
848
+ * 뒤(`hydrated`), API 응답 도착 시 스냅샷을 걷어내고 통째로 교체한다
849
+ * (`replaced`). 스냅샷 렌더 중에는 `hasMore`/스크롤백을 열지 않는다 —
850
+ * 커서는 API 응답의 것만 신뢰한다.
851
+ */
852
+ load(limit?: number): Promise<void>;
853
+ /** Scrollback: load the page of messages older than what's on screen and
854
+ * prepend it. No-op while a previous call is in flight or when `hasMore`
855
+ * is false. Returns the number of messages added. */
856
+ loadMore(limit?: number): Promise<number>;
857
+ /** 캐시 스냅샷 즉시 렌더 — 빈 리스트일 때만. 실패는 조용히 무시(캐시는
858
+ * 힌트일 뿐). */
859
+ private hydrateFromCache;
860
+ /**
861
+ * API 최신 페이지를 컬렉션에 반영한다.
862
+ * replaceByApi(기본)는 스냅샷을 걷어내고 통째 교체하고, mergeDiff는
863
+ * 스냅샷의 옛 메시지를 보존·병합한다(`mergeFresh`).
864
+ */
865
+ private applyFresh;
866
+ /**
867
+ * diff 병합 — 스냅샷의 옛 메시지를 보존해 fresh 페이지 아래에 잇는다.
868
+ *
869
+ * 판정 규칙(서버 계약, 07-23 확정):
870
+ * - **컷오프분**: `history_from`(히스토리 하한, unix ms)보다 오래된 캐시
871
+ * 메시지는 재입장 컷/대화 지우기로 볼 수 없게 된 것 → 폐기.
872
+ * - **삭제분**: fresh 커버 구간(id ≥ fresh 최저 id)에 있는데 fresh에
873
+ * 없는 캐시 메시지는 그 사이 삭제된 것(타이머 만료 포함) → 폐기.
874
+ * - **갭 폴백**: 캐시의 최신 메시지가 fresh와 겹치지 않으면 누락 구간이
875
+ * 있을 수 있으므로 병합하지 않고 통째 교체.
876
+ *
877
+ * 겹치는 구간은 항상 fresh가 정답(수정 반영) — 보존되는 건 fresh 범위
878
+ * **밖**의 옛 메시지뿐이라 "서버가 항상 이긴다" 불변식은 유지된다.
879
+ */
880
+ private mergeFresh;
881
+ /**
882
+ * Send `text` optimistically: a `sending` bubble appears immediately (one
883
+ * microtask later — `Room.send` is async), then flips to `sent` (with the
884
+ * real id) on ack, or `failed` on error. Fire-and-forget: a WS-not-open
885
+ * failure surfaces as a `failed` bubble rather than a rejected promise.
886
+ */
887
+ send(text: string, opts?: {
888
+ replyToId?: string;
889
+ customType?: string;
890
+ customMeta?: Record<string, unknown>;
891
+ }): void;
892
+ /** Send a file optimistically. A FILE bubble with `uploadProgress`
893
+ * appears immediately; progress advances during the S3 upload, then the
894
+ * bubble flips to `sent` on ack (or `failed`). Never throws — failures
895
+ * surface as the bubble's status. */
896
+ sendFile(file: File, opts?: SendFileOptions): Promise<void>;
897
+ /** Edit a message's content (server-side; peers get `message_updated`).
898
+ * The local copy updates optimistically. */
899
+ edit(messageId: string, content: string): Promise<void>;
900
+ /** Delete a message. `scope: "ALL"` (default) removes it for everyone;
901
+ * `"MY"` hides it only for the caller. Local copy flips to deleted. */
902
+ delete(messageId: string, scope?: DeleteScope): Promise<void>;
903
+ /** Add/remove the caller's reaction. Pass `myUserId` so the toggle can
904
+ * tell whether the caller already reacted with `key`. The authoritative
905
+ * state arrives back via the reaction WS events. */
906
+ toggleReaction(messageId: string, key: string, myUserId: string): Promise<void>;
907
+ /** Mark `messageId` read (debounced inside the SDK). */
908
+ markRead(messageId: string): void;
909
+ /** Force-send the pending read watermark now (e.g. on unmount / tab
910
+ * backgrounding). */
911
+ flushMarkRead(): void;
912
+ /** Unsubscribe from the room and flush the pending read watermark. The
913
+ * collection must not be used afterwards. */
914
+ dispose(): void;
915
+ private onIncoming;
916
+ private onUpdated;
917
+ private onDeleted;
918
+ private onCleared;
919
+ private onReactionAdded;
920
+ private onReactionRemoved;
921
+ private onSyncTick;
922
+ private bindAck;
923
+ private mutateById;
924
+ private mutateByTempId;
925
+ private emit;
926
+ }
927
+
585
928
  interface RoomEvents {
586
929
  message: WsChatReceive;
587
930
  messageUpdated: WsMessageUpdated;
@@ -619,7 +962,29 @@ declare class Room {
619
962
  * fine in practice (small strings, cleared on `chat.disconnect()`).
620
963
  */
621
964
  private mySentTempIds;
622
- constructor(roomId: string, http: HttpClient, ws: ReconnectingWs, readDebounceMs: number);
965
+ private readonly cacheStore;
966
+ private readonly cachePolicy;
967
+ private readonly logger;
968
+ constructor(roomId: string, http: HttpClient, ws: ReconnectingWs, readDebounceMs: number, opts?: {
969
+ cacheStore?: CacheStore;
970
+ cachePolicy?: CachePolicy;
971
+ logger?: ClientOptions["logger"];
972
+ });
973
+ /**
974
+ * 이 방의 메시지 상태를 소유하는 `MessageCollection`을 만든다.
975
+ *
976
+ * 컬렉션이 이벤트 반영·낙관적 전송·캐시 hydrate→교체를 전부 담당하므로,
977
+ * 리스트 화면은 컬렉션 하나만 물면 된다. 여러 개 만들 수 있고 각자
978
+ * `dispose()`로 정리한다.
979
+ *
980
+ * ```ts
981
+ * const col = chat.room("room_123").collection();
982
+ * const unsub = col.on("change", () => render(col.messages));
983
+ * await col.load();
984
+ * col.send("hello");
985
+ * ```
986
+ */
987
+ collection(policy?: CachePolicy): MessageCollection;
623
988
  private _sendPendingMarkRead;
624
989
  send(params: string | SendParams): Promise<{
625
990
  tempId: string;
@@ -801,7 +1166,32 @@ declare class Room {
801
1166
  items: MessageOut[];
802
1167
  nextCursor: MessageIdStr | null;
803
1168
  hasMore: boolean;
1169
+ /** 히스토리 하한(unix ms). null=컷 없음 — 재입장 컷·대화 지우기가 수렴. */
1170
+ historyFrom: number | null;
804
1171
  }>;
1172
+ /** 이 방의 상세 정보 (`GET /rooms/{id}`) — 이름·멤버 수·삭제 타이머 등. */
1173
+ detail(): Promise<RoomOut>;
1174
+ /**
1175
+ * 히스토리 최신 페이지의 캐시 스냅샷 — 마지막 `listRecent()` 성공 응답의
1176
+ * 원문. 콜드 스타트 렌더 힌트 용도다: 있으면 즉시 그리고, `listRecent()`가
1177
+ * 도착하면 통째로 교체할 것. 캐시가 없거나(`cacheStore` 미주입 포함)
1178
+ * 파싱이 실패하면 `null`을 돌려주고, 깨진 스냅샷은 지운다.
1179
+ *
1180
+ * 스냅샷의 `nextCursor`/`hasMore`는 시점이 지난 값이므로 스크롤백
1181
+ * 커서로 쓰지 말 것 — API 응답이 도착한 뒤의 값만 신뢰한다.
1182
+ */
1183
+ cachedRecent(): Promise<{
1184
+ items: MessageOut[];
1185
+ nextCursor: MessageIdStr | null;
1186
+ hasMore: boolean;
1187
+ historyFrom: number | null;
1188
+ } | null>;
1189
+ /**
1190
+ * 최신 페이지 스냅샷 저장. 삭제 타이머 방도 저장한다 — 서버가 만료를
1191
+ * 일반 `message_deleted` 프레임으로 브로드캐스트하고 히스토리에서도
1192
+ * 즉시 제외하므로, 만료분은 라이브 이벤트/`history_from` diff로 정리된다.
1193
+ */
1194
+ private cacheWriteRecent;
805
1195
  /**
806
1196
  * Scrollback. Fetches the page of `limit` messages immediately preceding
807
1197
  * `beforeMessageId` (exclusive), in ASC order. The returned
@@ -813,7 +1203,7 @@ declare class Room {
813
1203
  nextCursor: MessageIdStr | null;
814
1204
  hasMore: boolean;
815
1205
  }>;
816
- /** Phase D #2 — 방 내 메시지 검색.
1206
+ /** 방 내 메시지 검색.
817
1207
  *
818
1208
  * ``content`` 필드에 대한 대소문자 무시 부분 문자열 매칭 (ILIKE). 삭제된
819
1209
  * 메시지는 제외. 결과는 최신순 (message_id DESC) 으로 반환되고, ``before``
@@ -1136,6 +1526,15 @@ declare class NoveraChat {
1136
1526
  */
1137
1527
  getFileMeta(fileId: MessageIdStr): Promise<FileMeta>;
1138
1528
  unreadSummary(): Promise<UnreadSummary>;
1529
+ /**
1530
+ * 방 목록의 캐시 스냅샷 — 마지막 `unreadSummary()` 성공 응답의 원문.
1531
+ *
1532
+ * 콜드 스타트 렌더 힌트 용도다: 있으면 즉시 그리고, `unreadSummary()`가
1533
+ * 도착하면 통째로 교체할 것. 캐시가 없거나(`cacheStore` 미주입 포함)
1534
+ * 파싱이 실패하면 `null`을 돌려주고, 깨진 스냅샷은 지운다.
1535
+ */
1536
+ cachedUnreadSummary(): Promise<UnreadSummary | null>;
1537
+ private cacheWriteRooms;
1139
1538
  /**
1140
1539
  * Accept an invite link — call this in flows where you receive a
1141
1540
  * shareable URL (e.g. after parsing a deep link like
@@ -1191,4 +1590,4 @@ declare class NoveraChatError extends Error {
1191
1590
  static fromResponse(status: number, body: unknown): NoveraChatError;
1192
1591
  }
1193
1592
 
1194
- export { type InviteAcceptResponse as AcceptInviteResult, type AnnouncementOut as Announcement, type AppPolicy, type BlockOut as Block, type ClientEvents, type ClientOptions, type DeleteScope, ErrorCode, type ErrorCodeValue, type FileInline, type FileKind, type FileMeta, type FileThumbnailStatus, type InviteTokenOut as InviteToken, type JoinRequestCreateResponse, type JoinRequestListResponse, type JoinRequestOut, type MemberPreview, type MemberRole, type MessageIdStr, type MessageOut, type MessageType, NoveraChat, NoveraChatError, type PushTrigger, type ReactionEntry, Room, type RoomEvents, type RoomMemberOut, type RoomUnread, type SendFileOptions, type SendParams, type SenderMini, type UnreadRoomItem, type UnreadSummary, type WsAnnouncementEvent, type WsChatReceive, type WsInbound, type WsMessageDeleted, type WsMessageUpdated, type WsMessagesCleared, type WsOutbound, type WsPresence, type WsReactionAdded, type WsReactionRemoved, type WsRoomEvent, type WsSyncTick, type WsTypingBroadcast };
1593
+ export { type InviteAcceptResponse as AcceptInviteResult, type AnnouncementOut as Announcement, type AppPolicy, type BlockOut as Block, CacheKeys, type CachePolicy, type CacheStore, type ChatMessage, type ChatMessageStatus, type ClientEvents, type ClientOptions, type CollectionChange, type CollectionChangeKind, type DeleteScope, ErrorCode, type ErrorCodeValue, type FileInline, type FileKind, type FileMeta, type FileThumbnailStatus, type InviteTokenOut as InviteToken, type JoinRequestCreateResponse, type JoinRequestListResponse, type JoinRequestOut, type MemberPreview, type MemberRole, MessageCollection, type MessageIdStr, type MessageOut, type MessageType, NoveraChat, NoveraChatError, type PushTrigger, type ReactionEntry, Room, type RoomEvents, type RoomMemberOut, type RoomOut, type RoomUnread, type SendFileOptions, type SendParams, type SenderMini, type UnreadRoomItem, type UnreadSummary, type WsAnnouncementEvent, type WsChatReceive, type WsInbound, type WsMessageDeleted, type WsMessageUpdated, type WsMessagesCleared, type WsOutbound, type WsPresence, type WsReactionAdded, type WsReactionRemoved, type WsRoomEvent, type WsSyncTick, type WsTypingBroadcast, chatMessageFromHistory, chatMessageFromLive, pendingChatMessage, pendingFileChatMessage };