@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/README.ko.md +1 -1
- package/README.md +1 -1
- package/dist/index.cjs +672 -13
- package/dist/index.d.cts +385 -5
- package/dist/index.d.ts +385 -5
- package/dist/index.js +665 -12
- package/package.json +1 -1
- package/src/cache.ts +65 -0
- package/src/chat-message.ts +214 -0
- package/src/client.ts +49 -2
- package/src/features/message-collection.ts +504 -0
- package/src/features/room.ts +118 -5
- package/src/index.ts +19 -0
- package/src/internal/compare-id.ts +17 -0
- package/src/types.ts +12 -2
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
|
|
|
@@ -176,6 +182,27 @@ function parseFileCreateResponse(j) {
|
|
|
176
182
|
expiresAtMs: j["expires_at_ms"]
|
|
177
183
|
};
|
|
178
184
|
}
|
|
185
|
+
function parseRoomOut(j) {
|
|
186
|
+
return {
|
|
187
|
+
appId: j["app_id"],
|
|
188
|
+
roomId: j["room_id"],
|
|
189
|
+
roomType: j["room_type"],
|
|
190
|
+
customType: j["custom_type"],
|
|
191
|
+
name: j["name"],
|
|
192
|
+
coverImageUrl: j["cover_image_url"],
|
|
193
|
+
isDistinct: j["is_distinct"],
|
|
194
|
+
isPublic: j["is_public"],
|
|
195
|
+
isFrozen: j["is_frozen"],
|
|
196
|
+
joinPolicy: j["join_policy"],
|
|
197
|
+
timerSeconds: j["timer_seconds"],
|
|
198
|
+
memberCount: j["member_count"],
|
|
199
|
+
lastMessageId: j["last_message_id"],
|
|
200
|
+
lastMessageAt: j["last_message_at"],
|
|
201
|
+
lastMessagePreview: j["last_message_preview"],
|
|
202
|
+
currentAnnouncement: j["current_announcement"] == null ? null : parseAnnouncementOut(j["current_announcement"]),
|
|
203
|
+
createdAt: j["created_at"]
|
|
204
|
+
};
|
|
205
|
+
}
|
|
179
206
|
function parseBlockOut(j) {
|
|
180
207
|
return {
|
|
181
208
|
blockedUserId: j["blocked_user_id"],
|
|
@@ -245,6 +272,16 @@ function parseReactionEntry(j) {
|
|
|
245
272
|
};
|
|
246
273
|
}
|
|
247
274
|
|
|
275
|
+
// src/cache.ts
|
|
276
|
+
var CacheKeys = {
|
|
277
|
+
/** 저장 포맷 스키마 버전. 저장되는 JSON 모양이 바뀌면 올린다. */
|
|
278
|
+
version: "v1",
|
|
279
|
+
/** 방 목록 스냅샷 (`GET /users/me/unread` 원문). */
|
|
280
|
+
rooms: "nc.v1.rooms",
|
|
281
|
+
/** 방 히스토리 최신 페이지 스냅샷 (`GET /rooms/{id}/messages` 원문). */
|
|
282
|
+
msgs: (roomId) => `nc.v1.msgs.${roomId}`
|
|
283
|
+
};
|
|
284
|
+
|
|
248
285
|
// src/errors.ts
|
|
249
286
|
var ErrorCode = {
|
|
250
287
|
INTERNAL: "NC-GEN-001",
|
|
@@ -539,6 +576,487 @@ function tempId() {
|
|
|
539
576
|
return "tmp_" + bytes.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
540
577
|
}
|
|
541
578
|
|
|
579
|
+
// src/chat-message.ts
|
|
580
|
+
function fileInlineFromWire(w) {
|
|
581
|
+
return {
|
|
582
|
+
id: w.id,
|
|
583
|
+
kind: w.kind,
|
|
584
|
+
mime: w.mime,
|
|
585
|
+
size: w.size,
|
|
586
|
+
name: w.name ?? null,
|
|
587
|
+
width: w.width ?? null,
|
|
588
|
+
height: w.height ?? null,
|
|
589
|
+
durationSec: w.duration_sec ?? null,
|
|
590
|
+
thumbnailStatus: w.thumbnail_status,
|
|
591
|
+
forwardBlocked: Boolean(w.forward_blocked)
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
function chatMessageFromHistory(m) {
|
|
595
|
+
return {
|
|
596
|
+
id: m.messageId,
|
|
597
|
+
senderId: m.senderId ?? null,
|
|
598
|
+
content: m.content ?? null,
|
|
599
|
+
createdAtMs: m.createdAtMs,
|
|
600
|
+
status: "sent",
|
|
601
|
+
messageType: m.messageType,
|
|
602
|
+
sender: m.sender ?? null,
|
|
603
|
+
customType: m.customType ?? null,
|
|
604
|
+
fileId: m.fileId ?? null,
|
|
605
|
+
file: m.file ?? null,
|
|
606
|
+
files: m.files ?? null,
|
|
607
|
+
replyToId: m.replyToId ?? null,
|
|
608
|
+
reactions: m.reactions ?? [],
|
|
609
|
+
customMeta: m.customMeta ?? null,
|
|
610
|
+
editedCount: m.editedCount ?? 0,
|
|
611
|
+
uploadProgress: null,
|
|
612
|
+
tempId: null,
|
|
613
|
+
isDeleted: m.deletedAt != null
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
function chatMessageFromLive(f) {
|
|
617
|
+
const data = f.data ?? null;
|
|
618
|
+
const customMeta = data && typeof data === "object" && "_customMeta" in data ? data._customMeta : null;
|
|
619
|
+
return {
|
|
620
|
+
id: f.message_id,
|
|
621
|
+
senderId: f.sender_id,
|
|
622
|
+
content: f.content ?? null,
|
|
623
|
+
createdAtMs: f.created_at,
|
|
624
|
+
status: "sent",
|
|
625
|
+
messageType: f.message_type,
|
|
626
|
+
sender: null,
|
|
627
|
+
customType: f.custom_type ?? null,
|
|
628
|
+
fileId: f.file_id ?? null,
|
|
629
|
+
file: f.file ? fileInlineFromWire(f.file) : null,
|
|
630
|
+
files: f.files ? f.files.map(fileInlineFromWire) : null,
|
|
631
|
+
replyToId: f.reply_to_id ?? null,
|
|
632
|
+
reactions: [],
|
|
633
|
+
customMeta,
|
|
634
|
+
editedCount: 0,
|
|
635
|
+
uploadProgress: null,
|
|
636
|
+
tempId: null,
|
|
637
|
+
isDeleted: false
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
function pendingChatMessage(p) {
|
|
641
|
+
return {
|
|
642
|
+
id: p.tempId,
|
|
643
|
+
senderId: null,
|
|
644
|
+
content: p.content,
|
|
645
|
+
createdAtMs: Date.now(),
|
|
646
|
+
status: "sending",
|
|
647
|
+
messageType: "TEXT",
|
|
648
|
+
sender: null,
|
|
649
|
+
customType: p.customType ?? null,
|
|
650
|
+
fileId: null,
|
|
651
|
+
file: null,
|
|
652
|
+
files: null,
|
|
653
|
+
replyToId: p.replyToId ?? null,
|
|
654
|
+
reactions: [],
|
|
655
|
+
customMeta: p.customMeta ?? null,
|
|
656
|
+
editedCount: 0,
|
|
657
|
+
uploadProgress: null,
|
|
658
|
+
tempId: p.tempId,
|
|
659
|
+
isDeleted: false
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
function pendingFileChatMessage(p) {
|
|
663
|
+
const mime = p.mime ?? "application/octet-stream";
|
|
664
|
+
return {
|
|
665
|
+
id: p.tempId,
|
|
666
|
+
senderId: null,
|
|
667
|
+
content: null,
|
|
668
|
+
createdAtMs: Date.now(),
|
|
669
|
+
status: "sending",
|
|
670
|
+
messageType: "FILE",
|
|
671
|
+
sender: null,
|
|
672
|
+
customType: null,
|
|
673
|
+
fileId: null,
|
|
674
|
+
file: {
|
|
675
|
+
id: "",
|
|
676
|
+
kind: mime.startsWith("image/") ? "image" : mime.startsWith("video/") ? "video" : "file",
|
|
677
|
+
mime,
|
|
678
|
+
size: p.size ?? 0,
|
|
679
|
+
name: p.name,
|
|
680
|
+
thumbnailStatus: "pending",
|
|
681
|
+
forwardBlocked: false
|
|
682
|
+
},
|
|
683
|
+
files: null,
|
|
684
|
+
replyToId: null,
|
|
685
|
+
reactions: [],
|
|
686
|
+
customMeta: null,
|
|
687
|
+
editedCount: 0,
|
|
688
|
+
uploadProgress: 0,
|
|
689
|
+
tempId: p.tempId,
|
|
690
|
+
isDeleted: false
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// src/internal/compare-id.ts
|
|
695
|
+
function idGreater(a, b) {
|
|
696
|
+
if (!a || a === "0") return false;
|
|
697
|
+
if (!b || b === "0") return true;
|
|
698
|
+
try {
|
|
699
|
+
return BigInt(a) > BigInt(b);
|
|
700
|
+
} catch {
|
|
701
|
+
return a > b;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// src/features/message-collection.ts
|
|
706
|
+
var uploadSeq = 0;
|
|
707
|
+
var MessageCollection = class {
|
|
708
|
+
constructor(room, policy = "replaceByApi") {
|
|
709
|
+
this.room = room;
|
|
710
|
+
this.policy = policy;
|
|
711
|
+
this.unsubs.push(
|
|
712
|
+
room.on("message", (f) => this.onIncoming(f)),
|
|
713
|
+
room.on("messageUpdated", (f) => this.onUpdated(f)),
|
|
714
|
+
room.on("messageDeleted", (f) => this.onDeleted(f)),
|
|
715
|
+
room.on("messagesCleared", (f) => this.onCleared(f)),
|
|
716
|
+
room.on("reactionAdded", (f) => this.onReactionAdded(f)),
|
|
717
|
+
room.on("reactionRemoved", (f) => this.onReactionRemoved(f)),
|
|
718
|
+
room.on("syncTick", (f) => this.onSyncTick(f))
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
room;
|
|
722
|
+
policy;
|
|
723
|
+
bus = new EventBus();
|
|
724
|
+
unsubs = [];
|
|
725
|
+
_messages = [];
|
|
726
|
+
_readWatermarks = {};
|
|
727
|
+
_hasMore = false;
|
|
728
|
+
oldestCursor = null;
|
|
729
|
+
loadingMore = false;
|
|
730
|
+
/**
|
|
731
|
+
* 캐시 스냅샷으로 렌더된 머리쪽 메시지 수 — API 도착 시 이만큼 걷어내고
|
|
732
|
+
* 통째 교체한다(replaceByApi). 라이브 수신은 리스트 꼬리에 붙으므로
|
|
733
|
+
* 머리 range 제거로 스냅샷만 정확히 걷힌다.
|
|
734
|
+
*/
|
|
735
|
+
hydratedCount = 0;
|
|
736
|
+
/** The current messages, oldest first. Treat as read-only. */
|
|
737
|
+
get messages() {
|
|
738
|
+
return this._messages;
|
|
739
|
+
}
|
|
740
|
+
/** True when older history remains — fire `loadMore()` from the top of
|
|
741
|
+
* the scroll view. */
|
|
742
|
+
get hasMore() {
|
|
743
|
+
return this._hasMore;
|
|
744
|
+
}
|
|
745
|
+
/** Per-user read watermarks (`userId` → last read `messageId`), kept live
|
|
746
|
+
* from `sync_tick` frames. Treat as read-only. */
|
|
747
|
+
get readWatermarks() {
|
|
748
|
+
return this._readWatermarks;
|
|
749
|
+
}
|
|
750
|
+
/** State-change notifications. One event per mutation; re-read `messages`
|
|
751
|
+
* (and friends) when it fires. Returns an unsubscribe fn. */
|
|
752
|
+
on(event, fn) {
|
|
753
|
+
return this.bus.on(event, fn);
|
|
754
|
+
}
|
|
755
|
+
/** Whether `userId` has read `messageId` according to the latest watermark. */
|
|
756
|
+
isReadBy(userId, messageId) {
|
|
757
|
+
const w = this._readWatermarks[userId];
|
|
758
|
+
if (!w) return false;
|
|
759
|
+
return !idGreater(messageId, w);
|
|
760
|
+
}
|
|
761
|
+
/** How many of the given users have read `messageId`. Pass the room's
|
|
762
|
+
* member ids (minus the sender) to get a KakaoTalk-style unread count:
|
|
763
|
+
* `members.length - readCount(...)`. */
|
|
764
|
+
readCount(messageId, userIds) {
|
|
765
|
+
let n = 0;
|
|
766
|
+
for (const u of userIds) if (this.isReadBy(u, messageId)) n++;
|
|
767
|
+
return n;
|
|
768
|
+
}
|
|
769
|
+
// ── loading — cache hydrate → API wholesale replace ─────────────
|
|
770
|
+
/**
|
|
771
|
+
* Load the most recent page of history.
|
|
772
|
+
*
|
|
773
|
+
* `ClientOptions.cacheStore`가 주입돼 있으면 캐시 스냅샷을 먼저 렌더한
|
|
774
|
+
* 뒤(`hydrated`), API 응답 도착 시 스냅샷을 걷어내고 통째로 교체한다
|
|
775
|
+
* (`replaced`). 스냅샷 렌더 중에는 `hasMore`/스크롤백을 열지 않는다 —
|
|
776
|
+
* 커서는 API 응답의 것만 신뢰한다.
|
|
777
|
+
*/
|
|
778
|
+
async load(limit = 50) {
|
|
779
|
+
const pending = this.room.listRecent(limit);
|
|
780
|
+
await this.hydrateFromCache();
|
|
781
|
+
const page = await pending;
|
|
782
|
+
this.applyFresh(page);
|
|
783
|
+
}
|
|
784
|
+
/** Scrollback: load the page of messages older than what's on screen and
|
|
785
|
+
* prepend it. No-op while a previous call is in flight or when `hasMore`
|
|
786
|
+
* is false. Returns the number of messages added. */
|
|
787
|
+
async loadMore(limit = 50) {
|
|
788
|
+
const cursor = this.oldestCursor;
|
|
789
|
+
if (this.loadingMore || !this._hasMore || cursor == null) return 0;
|
|
790
|
+
this.loadingMore = true;
|
|
791
|
+
try {
|
|
792
|
+
const page = await this.room.listBefore(cursor, limit);
|
|
793
|
+
this._messages.unshift(...page.items.map(chatMessageFromHistory));
|
|
794
|
+
this.oldestCursor = page.nextCursor;
|
|
795
|
+
this._hasMore = page.hasMore;
|
|
796
|
+
this.emit("inserted");
|
|
797
|
+
return page.items.length;
|
|
798
|
+
} finally {
|
|
799
|
+
this.loadingMore = false;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
/** 캐시 스냅샷 즉시 렌더 — 빈 리스트일 때만. 실패는 조용히 무시(캐시는
|
|
803
|
+
* 힌트일 뿐). */
|
|
804
|
+
async hydrateFromCache() {
|
|
805
|
+
if (this._messages.length > 0 || this.hydratedCount > 0) return;
|
|
806
|
+
const cached = await this.room.cachedRecent();
|
|
807
|
+
if (!cached || cached.items.length === 0 || this._messages.length > 0) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
this._messages.unshift(...cached.items.map(chatMessageFromHistory));
|
|
811
|
+
this.hydratedCount = cached.items.length;
|
|
812
|
+
this.emit("hydrated");
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* API 최신 페이지를 컬렉션에 반영한다 — **diff 병합 진입점**.
|
|
816
|
+
* replaceByApi(현재 유일 정책)는 스냅샷을 걷어내고 통째 교체하고,
|
|
817
|
+
* mergeDiff는 서버 무효화 규칙 확정 후 이 분기 안에 병합 함수가 채워진다.
|
|
818
|
+
*/
|
|
819
|
+
applyFresh(page) {
|
|
820
|
+
if (this.policy === "mergeDiff") {
|
|
821
|
+
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');
|
|
822
|
+
}
|
|
823
|
+
if (this.hydratedCount > 0) {
|
|
824
|
+
this._messages.splice(0, this.hydratedCount);
|
|
825
|
+
this.hydratedCount = 0;
|
|
826
|
+
}
|
|
827
|
+
this._messages.unshift(...page.items.map(chatMessageFromHistory));
|
|
828
|
+
this.oldestCursor = page.nextCursor;
|
|
829
|
+
this._hasMore = page.hasMore;
|
|
830
|
+
this.emit("replaced");
|
|
831
|
+
}
|
|
832
|
+
// ── optimistic sends ────────────────────────────────────────────
|
|
833
|
+
/**
|
|
834
|
+
* Send `text` optimistically: a `sending` bubble appears immediately (one
|
|
835
|
+
* microtask later — `Room.send` is async), then flips to `sent` (with the
|
|
836
|
+
* real id) on ack, or `failed` on error. Fire-and-forget: a WS-not-open
|
|
837
|
+
* failure surfaces as a `failed` bubble rather than a rejected promise.
|
|
838
|
+
*/
|
|
839
|
+
send(text, opts) {
|
|
840
|
+
void (async () => {
|
|
841
|
+
let tempId2;
|
|
842
|
+
let ackPromise;
|
|
843
|
+
try {
|
|
844
|
+
const sent = await this.room.send({
|
|
845
|
+
content: text,
|
|
846
|
+
...opts?.replyToId ? { replyToId: opts.replyToId } : {},
|
|
847
|
+
...opts?.customType ? { customType: opts.customType } : {},
|
|
848
|
+
...opts?.customMeta ? { customMeta: opts.customMeta } : {}
|
|
849
|
+
});
|
|
850
|
+
tempId2 = sent.tempId;
|
|
851
|
+
ackPromise = sent.messageId;
|
|
852
|
+
} catch {
|
|
853
|
+
const localId = `failed_${Date.now()}_${uploadSeq++}`;
|
|
854
|
+
this._messages.push({
|
|
855
|
+
...pendingChatMessage({ tempId: localId, content: text, ...opts }),
|
|
856
|
+
status: "failed"
|
|
857
|
+
});
|
|
858
|
+
this.emit("inserted");
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
this._messages.push(pendingChatMessage({ tempId: tempId2, content: text, ...opts }));
|
|
862
|
+
this.emit("inserted");
|
|
863
|
+
this.bindAck(tempId2, ackPromise);
|
|
864
|
+
})();
|
|
865
|
+
}
|
|
866
|
+
/** Send a file optimistically. A FILE bubble with `uploadProgress`
|
|
867
|
+
* appears immediately; progress advances during the S3 upload, then the
|
|
868
|
+
* bubble flips to `sent` on ack (or `failed`). Never throws — failures
|
|
869
|
+
* surface as the bubble's status. */
|
|
870
|
+
async sendFile(file, opts) {
|
|
871
|
+
const localId = `upload_${Date.now()}_${uploadSeq++}`;
|
|
872
|
+
this._messages.push(
|
|
873
|
+
pendingFileChatMessage({
|
|
874
|
+
tempId: localId,
|
|
875
|
+
name: opts?.name ?? file.name,
|
|
876
|
+
mime: file.type,
|
|
877
|
+
size: file.size
|
|
878
|
+
})
|
|
879
|
+
);
|
|
880
|
+
this.emit("inserted");
|
|
881
|
+
try {
|
|
882
|
+
const sent = await this.room.sendFile(file, {
|
|
883
|
+
...opts ?? {},
|
|
884
|
+
onProgress: (p) => {
|
|
885
|
+
this.mutateByTempId(localId, (m) => ({
|
|
886
|
+
...m,
|
|
887
|
+
uploadProgress: p.ratio
|
|
888
|
+
}));
|
|
889
|
+
opts?.onProgress?.(p);
|
|
890
|
+
}
|
|
891
|
+
});
|
|
892
|
+
this.mutateByTempId(localId, (m) => ({
|
|
893
|
+
...m,
|
|
894
|
+
fileId: sent.fileId,
|
|
895
|
+
file: m.file ? { ...m.file, id: sent.fileId } : m.file,
|
|
896
|
+
uploadProgress: null
|
|
897
|
+
}));
|
|
898
|
+
const realId = await sent.messageId;
|
|
899
|
+
this.mutateByTempId(localId, (m) => ({
|
|
900
|
+
...m,
|
|
901
|
+
id: realId,
|
|
902
|
+
status: "sent"
|
|
903
|
+
}));
|
|
904
|
+
} catch {
|
|
905
|
+
this.mutateByTempId(localId, (m) => ({
|
|
906
|
+
...m,
|
|
907
|
+
status: "failed",
|
|
908
|
+
uploadProgress: null
|
|
909
|
+
}));
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
/** Edit a message's content (server-side; peers get `message_updated`).
|
|
913
|
+
* The local copy updates optimistically. */
|
|
914
|
+
async edit(messageId, content) {
|
|
915
|
+
await this.room.edit(messageId, { content });
|
|
916
|
+
this.mutateById(messageId, (m) => ({
|
|
917
|
+
...m,
|
|
918
|
+
content,
|
|
919
|
+
editedCount: m.editedCount + 1
|
|
920
|
+
}));
|
|
921
|
+
}
|
|
922
|
+
/** Delete a message. `scope: "ALL"` (default) removes it for everyone;
|
|
923
|
+
* `"MY"` hides it only for the caller. Local copy flips to deleted. */
|
|
924
|
+
async delete(messageId, scope = "ALL") {
|
|
925
|
+
await this.room.delete(messageId, scope);
|
|
926
|
+
this.mutateById(messageId, (m) => ({ ...m, isDeleted: true }));
|
|
927
|
+
}
|
|
928
|
+
/** Add/remove the caller's reaction. Pass `myUserId` so the toggle can
|
|
929
|
+
* tell whether the caller already reacted with `key`. The authoritative
|
|
930
|
+
* state arrives back via the reaction WS events. */
|
|
931
|
+
async toggleReaction(messageId, key, myUserId) {
|
|
932
|
+
const m = this._messages.find((x) => x.id === messageId);
|
|
933
|
+
const mine = m?.reactions.some(
|
|
934
|
+
(r) => r.key === key && r.userIds.includes(myUserId)
|
|
935
|
+
) ?? false;
|
|
936
|
+
if (mine) {
|
|
937
|
+
await this.room.unreact(messageId, key);
|
|
938
|
+
} else {
|
|
939
|
+
await this.room.react(messageId, key);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
/** Mark `messageId` read (debounced inside the SDK). */
|
|
943
|
+
markRead(messageId) {
|
|
944
|
+
this.room.markRead(messageId);
|
|
945
|
+
}
|
|
946
|
+
/** Force-send the pending read watermark now (e.g. on unmount / tab
|
|
947
|
+
* backgrounding). */
|
|
948
|
+
flushMarkRead() {
|
|
949
|
+
this.room.flushMarkRead();
|
|
950
|
+
}
|
|
951
|
+
/** Unsubscribe from the room and flush the pending read watermark. The
|
|
952
|
+
* collection must not be used afterwards. */
|
|
953
|
+
dispose() {
|
|
954
|
+
this.room.flushMarkRead();
|
|
955
|
+
for (const u of this.unsubs.splice(0)) u();
|
|
956
|
+
this.bus.removeAll();
|
|
957
|
+
}
|
|
958
|
+
// ── event handlers ──────────────────────────────────────────────
|
|
959
|
+
onIncoming(f) {
|
|
960
|
+
this._messages.push(chatMessageFromLive(f));
|
|
961
|
+
this.emit("inserted");
|
|
962
|
+
}
|
|
963
|
+
onUpdated(f) {
|
|
964
|
+
this.mutateById(f.message_id, (m) => ({
|
|
965
|
+
...m,
|
|
966
|
+
content: f.content ?? m.content,
|
|
967
|
+
editedCount: m.editedCount + 1
|
|
968
|
+
}));
|
|
969
|
+
}
|
|
970
|
+
onDeleted(f) {
|
|
971
|
+
this.mutateById(f.message_id, (m) => ({ ...m, isDeleted: true }));
|
|
972
|
+
}
|
|
973
|
+
onCleared(f) {
|
|
974
|
+
const upTo = f.up_to_message_id;
|
|
975
|
+
let changed = false;
|
|
976
|
+
this._messages = this._messages.map((m) => {
|
|
977
|
+
if (!m.isDeleted && (upTo == null || !idGreater(m.id, upTo))) {
|
|
978
|
+
changed = true;
|
|
979
|
+
return { ...m, isDeleted: true };
|
|
980
|
+
}
|
|
981
|
+
return m;
|
|
982
|
+
});
|
|
983
|
+
if (changed) this.emit("updated");
|
|
984
|
+
}
|
|
985
|
+
onReactionAdded(f) {
|
|
986
|
+
this.mutateById(f.message_id, (m) => {
|
|
987
|
+
const next = m.reactions.map(
|
|
988
|
+
(r) => r.key === f.reaction_key ? {
|
|
989
|
+
key: r.key,
|
|
990
|
+
userIds: [.../* @__PURE__ */ new Set([...r.userIds, f.user_id])],
|
|
991
|
+
updatedAt: f.updated_at
|
|
992
|
+
} : r
|
|
993
|
+
);
|
|
994
|
+
if (!next.some((r) => r.key === f.reaction_key)) {
|
|
995
|
+
next.push({
|
|
996
|
+
key: f.reaction_key,
|
|
997
|
+
userIds: [f.user_id],
|
|
998
|
+
updatedAt: f.updated_at
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
return { ...m, reactions: next };
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
onReactionRemoved(f) {
|
|
1005
|
+
this.mutateById(f.message_id, (m) => {
|
|
1006
|
+
const next = [];
|
|
1007
|
+
for (const r of m.reactions) {
|
|
1008
|
+
if (r.key !== f.reaction_key) {
|
|
1009
|
+
next.push(r);
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
const users = r.userIds.filter((u) => u !== f.user_id);
|
|
1013
|
+
if (users.length > 0) {
|
|
1014
|
+
next.push({ key: r.key, userIds: users, updatedAt: f.updated_at });
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
return { ...m, reactions: next };
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
onSyncTick(f) {
|
|
1021
|
+
let changed = false;
|
|
1022
|
+
for (const [userId, mid] of Object.entries(f.read_states)) {
|
|
1023
|
+
const cur = this._readWatermarks[userId];
|
|
1024
|
+
if (!cur || idGreater(mid, cur)) {
|
|
1025
|
+
this._readWatermarks[userId] = mid;
|
|
1026
|
+
changed = true;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
if (changed) this.emit("watermarks");
|
|
1030
|
+
}
|
|
1031
|
+
// ── helpers ─────────────────────────────────────────────────────
|
|
1032
|
+
bindAck(tempId2, messageId) {
|
|
1033
|
+
messageId.then((realId) => {
|
|
1034
|
+
this.mutateByTempId(tempId2, (m) => ({
|
|
1035
|
+
...m,
|
|
1036
|
+
id: realId,
|
|
1037
|
+
status: "sent"
|
|
1038
|
+
}));
|
|
1039
|
+
}).catch(() => {
|
|
1040
|
+
this.mutateByTempId(tempId2, (m) => ({ ...m, status: "failed" }));
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
mutateById(id, f) {
|
|
1044
|
+
const i = this._messages.findIndex((m) => m.id === id);
|
|
1045
|
+
if (i === -1) return;
|
|
1046
|
+
this._messages[i] = f(this._messages[i]);
|
|
1047
|
+
this.emit("updated");
|
|
1048
|
+
}
|
|
1049
|
+
mutateByTempId(tempId2, f) {
|
|
1050
|
+
const i = this._messages.findIndex((m) => m.tempId === tempId2);
|
|
1051
|
+
if (i === -1) return;
|
|
1052
|
+
this._messages[i] = f(this._messages[i]);
|
|
1053
|
+
this.emit("updated");
|
|
1054
|
+
}
|
|
1055
|
+
emit(kind) {
|
|
1056
|
+
this.bus.emit("change", { kind });
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
|
|
542
1060
|
// src/features/room.ts
|
|
543
1061
|
function toFileInline(f) {
|
|
544
1062
|
return {
|
|
@@ -568,11 +1086,14 @@ function toMessageOut(j) {
|
|
|
568
1086
|
};
|
|
569
1087
|
}
|
|
570
1088
|
var Room = class _Room {
|
|
571
|
-
constructor(roomId, http, ws, readDebounceMs) {
|
|
1089
|
+
constructor(roomId, http, ws, readDebounceMs, opts) {
|
|
572
1090
|
this.roomId = roomId;
|
|
573
1091
|
this.http = http;
|
|
574
1092
|
this.ws = ws;
|
|
575
1093
|
this.readDebounceMs = readDebounceMs;
|
|
1094
|
+
this.cacheStore = opts?.cacheStore ?? null;
|
|
1095
|
+
this.cachePolicy = opts?.cachePolicy ?? "replaceByApi";
|
|
1096
|
+
this.logger = opts?.logger;
|
|
576
1097
|
}
|
|
577
1098
|
roomId;
|
|
578
1099
|
http;
|
|
@@ -616,6 +1137,30 @@ var Room = class _Room {
|
|
|
616
1137
|
* fine in practice (small strings, cleared on `chat.disconnect()`).
|
|
617
1138
|
*/
|
|
618
1139
|
mySentTempIds = /* @__PURE__ */ new Set();
|
|
1140
|
+
// ── 스냅샷 캐시 협력자 (클라이언트 옵션에서 흘러듦) ──────────────
|
|
1141
|
+
cacheStore;
|
|
1142
|
+
cachePolicy;
|
|
1143
|
+
logger;
|
|
1144
|
+
/** 삭제 타이머 여부 세션 메모 — null=미확인, 값=마지막 detail() 결과. */
|
|
1145
|
+
timerSeconds = null;
|
|
1146
|
+
timerKnown = false;
|
|
1147
|
+
/**
|
|
1148
|
+
* 이 방의 메시지 상태를 소유하는 `MessageCollection`을 만든다.
|
|
1149
|
+
*
|
|
1150
|
+
* 컬렉션이 이벤트 반영·낙관적 전송·캐시 hydrate→교체를 전부 담당하므로,
|
|
1151
|
+
* 리스트 화면은 컬렉션 하나만 물면 된다. 여러 개 만들 수 있고 각자
|
|
1152
|
+
* `dispose()`로 정리한다.
|
|
1153
|
+
*
|
|
1154
|
+
* ```ts
|
|
1155
|
+
* const col = chat.room("room_123").collection();
|
|
1156
|
+
* const unsub = col.on("change", () => render(col.messages));
|
|
1157
|
+
* await col.load();
|
|
1158
|
+
* col.send("hello");
|
|
1159
|
+
* ```
|
|
1160
|
+
*/
|
|
1161
|
+
collection(policy) {
|
|
1162
|
+
return new MessageCollection(this, policy ?? this.cachePolicy);
|
|
1163
|
+
}
|
|
619
1164
|
_sendPendingMarkRead() {
|
|
620
1165
|
const mid = this.pendingReadMid;
|
|
621
1166
|
if (!mid) return;
|
|
@@ -1072,11 +1617,76 @@ var Room = class _Room {
|
|
|
1072
1617
|
*/
|
|
1073
1618
|
async listRecent(limit = 50) {
|
|
1074
1619
|
const raw = await this.http.request("GET", `/api/v1/rooms/${this.roomId}/messages`, void 0, { limit });
|
|
1075
|
-
|
|
1620
|
+
const page = {
|
|
1076
1621
|
items: raw.items.map(toMessageOut),
|
|
1622
|
+
// 파싱 성공한 응답만 캐시에 넣는다
|
|
1077
1623
|
nextCursor: raw.next_cursor,
|
|
1078
1624
|
hasMore: raw.has_more
|
|
1079
1625
|
};
|
|
1626
|
+
void this.cacheWriteRecent(raw);
|
|
1627
|
+
return page;
|
|
1628
|
+
}
|
|
1629
|
+
/** 이 방의 상세 정보 (`GET /rooms/{id}`) — 이름·멤버 수·삭제 타이머 등. */
|
|
1630
|
+
async detail() {
|
|
1631
|
+
const raw = await this.http.request(
|
|
1632
|
+
"GET",
|
|
1633
|
+
`/api/v1/rooms/${this.roomId}`
|
|
1634
|
+
);
|
|
1635
|
+
const out = parseRoomOut(raw);
|
|
1636
|
+
this.timerSeconds = out.timerSeconds;
|
|
1637
|
+
this.timerKnown = true;
|
|
1638
|
+
return out;
|
|
1639
|
+
}
|
|
1640
|
+
/**
|
|
1641
|
+
* 히스토리 최신 페이지의 캐시 스냅샷 — 마지막 `listRecent()` 성공 응답의
|
|
1642
|
+
* 원문. 콜드 스타트 렌더 힌트 용도다: 있으면 즉시 그리고, `listRecent()`가
|
|
1643
|
+
* 도착하면 통째로 교체할 것. 캐시가 없거나(`cacheStore` 미주입 포함)
|
|
1644
|
+
* 파싱이 실패하면 `null`을 돌려주고, 깨진 스냅샷은 지운다.
|
|
1645
|
+
*
|
|
1646
|
+
* 스냅샷의 `nextCursor`/`hasMore`는 시점이 지난 값이므로 스크롤백
|
|
1647
|
+
* 커서로 쓰지 말 것 — API 응답이 도착한 뒤의 값만 신뢰한다.
|
|
1648
|
+
*/
|
|
1649
|
+
async cachedRecent() {
|
|
1650
|
+
const store = this.cacheStore;
|
|
1651
|
+
if (!store) return null;
|
|
1652
|
+
const key = CacheKeys.msgs(this.roomId);
|
|
1653
|
+
try {
|
|
1654
|
+
const raw = await store.read(key);
|
|
1655
|
+
if (raw == null) return null;
|
|
1656
|
+
const j = JSON.parse(raw);
|
|
1657
|
+
return {
|
|
1658
|
+
items: j.items.map(toMessageOut),
|
|
1659
|
+
nextCursor: j.next_cursor,
|
|
1660
|
+
hasMore: j.has_more
|
|
1661
|
+
};
|
|
1662
|
+
} catch (err) {
|
|
1663
|
+
this.logger?.("warn", `cache_read_failed key=${key}`, err);
|
|
1664
|
+
try {
|
|
1665
|
+
await store.remove(key);
|
|
1666
|
+
} catch {
|
|
1667
|
+
}
|
|
1668
|
+
return null;
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
/**
|
|
1672
|
+
* 최신 페이지 스냅샷 저장. 삭제 타이머가 켜진 방은 저장하지 않고 기존
|
|
1673
|
+
* 스냅샷도 지운다 — 타이머 만료로 서버에서 사라진 메시지가 로컬 캐시에
|
|
1674
|
+
* 살아남는 보안 충돌을 원천 차단한다.
|
|
1675
|
+
*/
|
|
1676
|
+
async cacheWriteRecent(raw) {
|
|
1677
|
+
const store = this.cacheStore;
|
|
1678
|
+
if (!store) return;
|
|
1679
|
+
const key = CacheKeys.msgs(this.roomId);
|
|
1680
|
+
try {
|
|
1681
|
+
if (!this.timerKnown) await this.detail();
|
|
1682
|
+
if (this.timerSeconds != null) {
|
|
1683
|
+
await store.remove(key);
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
await store.write(key, JSON.stringify(raw));
|
|
1687
|
+
} catch (err) {
|
|
1688
|
+
this.logger?.("warn", `cache_write_failed key=${key}`, err);
|
|
1689
|
+
}
|
|
1080
1690
|
}
|
|
1081
1691
|
/**
|
|
1082
1692
|
* Scrollback. Fetches the page of `limit` messages immediately preceding
|
|
@@ -1095,7 +1705,7 @@ var Room = class _Room {
|
|
|
1095
1705
|
hasMore: raw.has_more
|
|
1096
1706
|
};
|
|
1097
1707
|
}
|
|
1098
|
-
/**
|
|
1708
|
+
/** 방 내 메시지 검색.
|
|
1099
1709
|
*
|
|
1100
1710
|
* ``content`` 필드에 대한 대소문자 무시 부분 문자열 매칭 (ILIKE). 삭제된
|
|
1101
1711
|
* 메시지는 제외. 결과는 최신순 (message_id DESC) 으로 반환되고, ``before``
|
|
@@ -1451,7 +2061,7 @@ var Room = class _Room {
|
|
|
1451
2061
|
};
|
|
1452
2062
|
|
|
1453
2063
|
// src/client.ts
|
|
1454
|
-
function
|
|
2064
|
+
function idGreater2(a, b) {
|
|
1455
2065
|
if (!a) return false;
|
|
1456
2066
|
if (!b) return true;
|
|
1457
2067
|
try {
|
|
@@ -1466,6 +2076,11 @@ var NoveraChat = class {
|
|
|
1466
2076
|
if (!opts.appId) throw new Error("appId required");
|
|
1467
2077
|
if (!opts.endpoint) throw new Error("endpoint required");
|
|
1468
2078
|
if (!opts.tokenProvider) throw new Error("tokenProvider required");
|
|
2079
|
+
if (opts.cachePolicy === "mergeDiff") {
|
|
2080
|
+
throw new Error(
|
|
2081
|
+
'CachePolicy "mergeDiff" \uB294 \uC544\uC9C1 \uBBF8\uAD6C\uD604 \u2014 \uD604\uC7AC "replaceByApi"\uB9CC \uC9C0\uC6D0'
|
|
2082
|
+
);
|
|
2083
|
+
}
|
|
1469
2084
|
this.readDebounceMs = opts.readWatermarkDebounceMs ?? 400;
|
|
1470
2085
|
this.logger = opts.logger;
|
|
1471
2086
|
if (opts.initialLastMessageIds) {
|
|
@@ -1537,7 +2152,11 @@ var NoveraChat = class {
|
|
|
1537
2152
|
room(roomId) {
|
|
1538
2153
|
let r = this.rooms.get(roomId);
|
|
1539
2154
|
if (!r) {
|
|
1540
|
-
r = new Room(roomId, this.http, this.ws, this.readDebounceMs
|
|
2155
|
+
r = new Room(roomId, this.http, this.ws, this.readDebounceMs, {
|
|
2156
|
+
...this.opts.cacheStore ? { cacheStore: this.opts.cacheStore } : {},
|
|
2157
|
+
...this.opts.cachePolicy ? { cachePolicy: this.opts.cachePolicy } : {},
|
|
2158
|
+
...this.logger ? { logger: this.logger } : {}
|
|
2159
|
+
});
|
|
1541
2160
|
this.rooms.set(roomId, r);
|
|
1542
2161
|
}
|
|
1543
2162
|
return r;
|
|
@@ -1582,7 +2201,7 @@ var NoveraChat = class {
|
|
|
1582
2201
|
const sid = typeof messageId === "number" ? String(messageId) : messageId;
|
|
1583
2202
|
if (!sid || sid === "0") return;
|
|
1584
2203
|
const cur = this.lastMessageIdByRoom.get(roomId);
|
|
1585
|
-
if (
|
|
2204
|
+
if (idGreater2(sid, cur)) this.lastMessageIdByRoom.set(roomId, sid);
|
|
1586
2205
|
}
|
|
1587
2206
|
/** Server-side aggregate unread state: total count + per-room breakdown
|
|
1588
2207
|
* for the authenticated user. Single REST call; cheap enough to poll
|
|
@@ -1784,7 +2403,41 @@ var NoveraChat = class {
|
|
|
1784
2403
|
}
|
|
1785
2404
|
async unreadSummary() {
|
|
1786
2405
|
const raw = await this.http.request("GET", "/api/v1/users/me/unread");
|
|
1787
|
-
|
|
2406
|
+
const out = parseUnreadSummary(raw);
|
|
2407
|
+
void this.cacheWriteRooms(raw);
|
|
2408
|
+
return out;
|
|
2409
|
+
}
|
|
2410
|
+
/**
|
|
2411
|
+
* 방 목록의 캐시 스냅샷 — 마지막 `unreadSummary()` 성공 응답의 원문.
|
|
2412
|
+
*
|
|
2413
|
+
* 콜드 스타트 렌더 힌트 용도다: 있으면 즉시 그리고, `unreadSummary()`가
|
|
2414
|
+
* 도착하면 통째로 교체할 것. 캐시가 없거나(`cacheStore` 미주입 포함)
|
|
2415
|
+
* 파싱이 실패하면 `null`을 돌려주고, 깨진 스냅샷은 지운다.
|
|
2416
|
+
*/
|
|
2417
|
+
async cachedUnreadSummary() {
|
|
2418
|
+
const store = this.opts.cacheStore;
|
|
2419
|
+
if (!store) return null;
|
|
2420
|
+
try {
|
|
2421
|
+
const raw = await store.read(CacheKeys.rooms);
|
|
2422
|
+
if (raw == null) return null;
|
|
2423
|
+
return parseUnreadSummary(JSON.parse(raw));
|
|
2424
|
+
} catch (err) {
|
|
2425
|
+
this.logger?.("warn", `cache_read_failed key=${CacheKeys.rooms}`, err);
|
|
2426
|
+
try {
|
|
2427
|
+
await store.remove(CacheKeys.rooms);
|
|
2428
|
+
} catch {
|
|
2429
|
+
}
|
|
2430
|
+
return null;
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
async cacheWriteRooms(raw) {
|
|
2434
|
+
const store = this.opts.cacheStore;
|
|
2435
|
+
if (!store) return;
|
|
2436
|
+
try {
|
|
2437
|
+
await store.write(CacheKeys.rooms, JSON.stringify(raw));
|
|
2438
|
+
} catch (err) {
|
|
2439
|
+
this.logger?.("warn", `cache_write_failed key=${CacheKeys.rooms}`, err);
|
|
2440
|
+
}
|
|
1788
2441
|
}
|
|
1789
2442
|
/**
|
|
1790
2443
|
* Accept an invite link — call this in flows where you receive a
|
|
@@ -1865,7 +2518,7 @@ var NoveraChat = class {
|
|
|
1865
2518
|
return;
|
|
1866
2519
|
}
|
|
1867
2520
|
case "ack": {
|
|
1868
|
-
if (
|
|
2521
|
+
if (idGreater2(msg.message_id, this.lastMessageIdByRoom.get(msg.room_id))) {
|
|
1869
2522
|
this.lastMessageIdByRoom.set(msg.room_id, msg.message_id);
|
|
1870
2523
|
}
|
|
1871
2524
|
const room = this.rooms.get(msg.room_id);
|
|
@@ -1882,7 +2535,7 @@ var NoveraChat = class {
|
|
|
1882
2535
|
if (!roomId) return;
|
|
1883
2536
|
const room = this.rooms.get(roomId);
|
|
1884
2537
|
if (!room) return;
|
|
1885
|
-
if (msg.type === "chat_receive" &&
|
|
2538
|
+
if (msg.type === "chat_receive" && idGreater2(msg.message_id, this.lastMessageIdByRoom.get(roomId))) {
|
|
1886
2539
|
this.lastMessageIdByRoom.set(roomId, msg.message_id);
|
|
1887
2540
|
}
|
|
1888
2541
|
if (msg.type === "chat_receive") {
|
|
@@ -1900,7 +2553,7 @@ var NoveraChat = class {
|
|
|
1900
2553
|
highestKnownMessageId() {
|
|
1901
2554
|
let max = null;
|
|
1902
2555
|
for (const v of this.lastMessageIdByRoom.values()) {
|
|
1903
|
-
if (
|
|
2556
|
+
if (idGreater2(v, max)) max = v;
|
|
1904
2557
|
}
|
|
1905
2558
|
return max;
|
|
1906
2559
|
}
|
|
@@ -1941,7 +2594,7 @@ var NoveraChat = class {
|
|
|
1941
2594
|
created_at: m.createdAtMs,
|
|
1942
2595
|
server_ts: Date.now()
|
|
1943
2596
|
});
|
|
1944
|
-
if (
|
|
2597
|
+
if (idGreater2(m.messageId, this.lastMessageIdByRoom.get(roomId))) {
|
|
1945
2598
|
this.lastMessageIdByRoom.set(roomId, m.messageId);
|
|
1946
2599
|
}
|
|
1947
2600
|
}
|
|
@@ -1957,8 +2610,14 @@ var NoveraChat = class {
|
|
|
1957
2610
|
};
|
|
1958
2611
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1959
2612
|
0 && (module.exports = {
|
|
2613
|
+
CacheKeys,
|
|
1960
2614
|
ErrorCode,
|
|
2615
|
+
MessageCollection,
|
|
1961
2616
|
NoveraChat,
|
|
1962
2617
|
NoveraChatError,
|
|
1963
|
-
Room
|
|
2618
|
+
Room,
|
|
2619
|
+
chatMessageFromHistory,
|
|
2620
|
+
chatMessageFromLive,
|
|
2621
|
+
pendingChatMessage,
|
|
2622
|
+
pendingFileChatMessage
|
|
1964
2623
|
});
|