@noverachat/sdk-web 0.2.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 +44 -0
- package/src/internal/compare-id.ts +17 -0
- package/src/types.ts +14 -2
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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" &&
|
|
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 (
|
|
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 (
|
|
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
|
};
|