@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/README.ko.md +1 -1
- package/README.md +1 -1
- package/dist/index.cjs +700 -14
- package/dist/index.d.cts +404 -5
- package/dist/index.d.ts +404 -5
- package/dist/index.js +693 -13
- package/package.json +1 -1
- package/src/cache.ts +67 -0
- package/src/chat-message.ts +214 -0
- package/src/client.ts +44 -2
- package/src/features/message-collection.ts +561 -0
- package/src/features/room.ts +112 -5
- package/src/generated/rest.ts +64 -0
- package/src/index.ts +19 -0
- package/src/internal/compare-id.ts +17 -0
- package/src/types.ts +13 -2
package/dist/index.js
CHANGED
|
@@ -23,6 +23,7 @@ function parseUnreadRoomItem(j) {
|
|
|
23
23
|
lastMessageId: j["last_message_id"],
|
|
24
24
|
lastReadMessageId: j["last_read_message_id"],
|
|
25
25
|
lastMessagePreview: j["last_message_preview"],
|
|
26
|
+
lastMessageAt: j["last_message_at"],
|
|
26
27
|
memberCount: j["member_count"],
|
|
27
28
|
membersPreview: j["members_preview"] == null ? void 0 : j["members_preview"].map((e) => parseMemberPreview(e))
|
|
28
29
|
};
|
|
@@ -147,6 +148,27 @@ function parseFileCreateResponse(j) {
|
|
|
147
148
|
expiresAtMs: j["expires_at_ms"]
|
|
148
149
|
};
|
|
149
150
|
}
|
|
151
|
+
function parseRoomOut(j) {
|
|
152
|
+
return {
|
|
153
|
+
appId: j["app_id"],
|
|
154
|
+
roomId: j["room_id"],
|
|
155
|
+
roomType: j["room_type"],
|
|
156
|
+
customType: j["custom_type"],
|
|
157
|
+
name: j["name"],
|
|
158
|
+
coverImageUrl: j["cover_image_url"],
|
|
159
|
+
isDistinct: j["is_distinct"],
|
|
160
|
+
isPublic: j["is_public"],
|
|
161
|
+
isFrozen: j["is_frozen"],
|
|
162
|
+
joinPolicy: j["join_policy"],
|
|
163
|
+
timerSeconds: j["timer_seconds"],
|
|
164
|
+
memberCount: j["member_count"],
|
|
165
|
+
lastMessageId: j["last_message_id"],
|
|
166
|
+
lastMessageAt: j["last_message_at"],
|
|
167
|
+
lastMessagePreview: j["last_message_preview"],
|
|
168
|
+
currentAnnouncement: j["current_announcement"] == null ? null : parseAnnouncementOut(j["current_announcement"]),
|
|
169
|
+
createdAt: j["created_at"]
|
|
170
|
+
};
|
|
171
|
+
}
|
|
150
172
|
function parseBlockOut(j) {
|
|
151
173
|
return {
|
|
152
174
|
blockedUserId: j["blocked_user_id"],
|
|
@@ -216,6 +238,16 @@ function parseReactionEntry(j) {
|
|
|
216
238
|
};
|
|
217
239
|
}
|
|
218
240
|
|
|
241
|
+
// src/cache.ts
|
|
242
|
+
var CacheKeys = {
|
|
243
|
+
/** 저장 포맷 스키마 버전. 저장되는 JSON 모양이 바뀌면 올린다. */
|
|
244
|
+
version: "v1",
|
|
245
|
+
/** 방 목록 스냅샷 (`GET /users/me/unread` 원문). */
|
|
246
|
+
rooms: "nc.v1.rooms",
|
|
247
|
+
/** 방 히스토리 최신 페이지 스냅샷 (`GET /rooms/{id}/messages` 원문). */
|
|
248
|
+
msgs: (roomId) => `nc.v1.msgs.${roomId}`
|
|
249
|
+
};
|
|
250
|
+
|
|
219
251
|
// src/errors.ts
|
|
220
252
|
var ErrorCode = {
|
|
221
253
|
INTERNAL: "NC-GEN-001",
|
|
@@ -510,6 +542,527 @@ function tempId() {
|
|
|
510
542
|
return "tmp_" + bytes.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
511
543
|
}
|
|
512
544
|
|
|
545
|
+
// src/chat-message.ts
|
|
546
|
+
function fileInlineFromWire(w) {
|
|
547
|
+
return {
|
|
548
|
+
id: w.id,
|
|
549
|
+
kind: w.kind,
|
|
550
|
+
mime: w.mime,
|
|
551
|
+
size: w.size,
|
|
552
|
+
name: w.name ?? null,
|
|
553
|
+
width: w.width ?? null,
|
|
554
|
+
height: w.height ?? null,
|
|
555
|
+
durationSec: w.duration_sec ?? null,
|
|
556
|
+
thumbnailStatus: w.thumbnail_status,
|
|
557
|
+
forwardBlocked: Boolean(w.forward_blocked)
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
function chatMessageFromHistory(m) {
|
|
561
|
+
return {
|
|
562
|
+
id: m.messageId,
|
|
563
|
+
senderId: m.senderId ?? null,
|
|
564
|
+
content: m.content ?? null,
|
|
565
|
+
createdAtMs: m.createdAtMs,
|
|
566
|
+
status: "sent",
|
|
567
|
+
messageType: m.messageType,
|
|
568
|
+
sender: m.sender ?? null,
|
|
569
|
+
customType: m.customType ?? null,
|
|
570
|
+
fileId: m.fileId ?? null,
|
|
571
|
+
file: m.file ?? null,
|
|
572
|
+
files: m.files ?? null,
|
|
573
|
+
replyToId: m.replyToId ?? null,
|
|
574
|
+
reactions: m.reactions ?? [],
|
|
575
|
+
customMeta: m.customMeta ?? null,
|
|
576
|
+
editedCount: m.editedCount ?? 0,
|
|
577
|
+
uploadProgress: null,
|
|
578
|
+
tempId: null,
|
|
579
|
+
isDeleted: m.deletedAt != null
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
function chatMessageFromLive(f) {
|
|
583
|
+
const data = f.data ?? null;
|
|
584
|
+
const customMeta = data && typeof data === "object" && "_customMeta" in data ? data._customMeta : null;
|
|
585
|
+
return {
|
|
586
|
+
id: f.message_id,
|
|
587
|
+
senderId: f.sender_id,
|
|
588
|
+
content: f.content ?? null,
|
|
589
|
+
createdAtMs: f.created_at,
|
|
590
|
+
status: "sent",
|
|
591
|
+
messageType: f.message_type,
|
|
592
|
+
sender: null,
|
|
593
|
+
customType: f.custom_type ?? null,
|
|
594
|
+
fileId: f.file_id ?? null,
|
|
595
|
+
file: f.file ? fileInlineFromWire(f.file) : null,
|
|
596
|
+
files: f.files ? f.files.map(fileInlineFromWire) : null,
|
|
597
|
+
replyToId: f.reply_to_id ?? null,
|
|
598
|
+
reactions: [],
|
|
599
|
+
customMeta,
|
|
600
|
+
editedCount: 0,
|
|
601
|
+
uploadProgress: null,
|
|
602
|
+
tempId: null,
|
|
603
|
+
isDeleted: false
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
function pendingChatMessage(p) {
|
|
607
|
+
return {
|
|
608
|
+
id: p.tempId,
|
|
609
|
+
senderId: null,
|
|
610
|
+
content: p.content,
|
|
611
|
+
createdAtMs: Date.now(),
|
|
612
|
+
status: "sending",
|
|
613
|
+
messageType: "TEXT",
|
|
614
|
+
sender: null,
|
|
615
|
+
customType: p.customType ?? null,
|
|
616
|
+
fileId: null,
|
|
617
|
+
file: null,
|
|
618
|
+
files: null,
|
|
619
|
+
replyToId: p.replyToId ?? null,
|
|
620
|
+
reactions: [],
|
|
621
|
+
customMeta: p.customMeta ?? null,
|
|
622
|
+
editedCount: 0,
|
|
623
|
+
uploadProgress: null,
|
|
624
|
+
tempId: p.tempId,
|
|
625
|
+
isDeleted: false
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
function pendingFileChatMessage(p) {
|
|
629
|
+
const mime = p.mime ?? "application/octet-stream";
|
|
630
|
+
return {
|
|
631
|
+
id: p.tempId,
|
|
632
|
+
senderId: null,
|
|
633
|
+
content: null,
|
|
634
|
+
createdAtMs: Date.now(),
|
|
635
|
+
status: "sending",
|
|
636
|
+
messageType: "FILE",
|
|
637
|
+
sender: null,
|
|
638
|
+
customType: null,
|
|
639
|
+
fileId: null,
|
|
640
|
+
file: {
|
|
641
|
+
id: "",
|
|
642
|
+
kind: mime.startsWith("image/") ? "image" : mime.startsWith("video/") ? "video" : "file",
|
|
643
|
+
mime,
|
|
644
|
+
size: p.size ?? 0,
|
|
645
|
+
name: p.name,
|
|
646
|
+
thumbnailStatus: "pending",
|
|
647
|
+
forwardBlocked: false
|
|
648
|
+
},
|
|
649
|
+
files: null,
|
|
650
|
+
replyToId: null,
|
|
651
|
+
reactions: [],
|
|
652
|
+
customMeta: null,
|
|
653
|
+
editedCount: 0,
|
|
654
|
+
uploadProgress: 0,
|
|
655
|
+
tempId: p.tempId,
|
|
656
|
+
isDeleted: false
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// src/internal/compare-id.ts
|
|
661
|
+
function idGreater(a, b) {
|
|
662
|
+
if (!a || a === "0") return false;
|
|
663
|
+
if (!b || b === "0") return true;
|
|
664
|
+
try {
|
|
665
|
+
return BigInt(a) > BigInt(b);
|
|
666
|
+
} catch {
|
|
667
|
+
return a > b;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// src/features/message-collection.ts
|
|
672
|
+
var uploadSeq = 0;
|
|
673
|
+
var MessageCollection = class {
|
|
674
|
+
constructor(room, policy = "replaceByApi") {
|
|
675
|
+
this.room = room;
|
|
676
|
+
this.policy = policy;
|
|
677
|
+
this.unsubs.push(
|
|
678
|
+
room.on("message", (f) => this.onIncoming(f)),
|
|
679
|
+
room.on("messageUpdated", (f) => this.onUpdated(f)),
|
|
680
|
+
room.on("messageDeleted", (f) => this.onDeleted(f)),
|
|
681
|
+
room.on("messagesCleared", (f) => this.onCleared(f)),
|
|
682
|
+
room.on("reactionAdded", (f) => this.onReactionAdded(f)),
|
|
683
|
+
room.on("reactionRemoved", (f) => this.onReactionRemoved(f)),
|
|
684
|
+
room.on("syncTick", (f) => this.onSyncTick(f))
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
room;
|
|
688
|
+
policy;
|
|
689
|
+
bus = new EventBus();
|
|
690
|
+
unsubs = [];
|
|
691
|
+
_messages = [];
|
|
692
|
+
_readWatermarks = {};
|
|
693
|
+
_hasMore = false;
|
|
694
|
+
oldestCursor = null;
|
|
695
|
+
loadingMore = false;
|
|
696
|
+
/**
|
|
697
|
+
* 캐시 스냅샷으로 렌더된 머리쪽 메시지 수 — API 도착 시 이만큼 걷어내고
|
|
698
|
+
* 통째 교체한다(replaceByApi). 라이브 수신은 리스트 꼬리에 붙으므로
|
|
699
|
+
* 머리 range 제거로 스냅샷만 정확히 걷힌다.
|
|
700
|
+
*/
|
|
701
|
+
hydratedCount = 0;
|
|
702
|
+
/** The current messages, oldest first. Treat as read-only. */
|
|
703
|
+
get messages() {
|
|
704
|
+
return this._messages;
|
|
705
|
+
}
|
|
706
|
+
/** True when older history remains — fire `loadMore()` from the top of
|
|
707
|
+
* the scroll view. */
|
|
708
|
+
get hasMore() {
|
|
709
|
+
return this._hasMore;
|
|
710
|
+
}
|
|
711
|
+
/** Per-user read watermarks (`userId` → last read `messageId`), kept live
|
|
712
|
+
* from `sync_tick` frames. Treat as read-only. */
|
|
713
|
+
get readWatermarks() {
|
|
714
|
+
return this._readWatermarks;
|
|
715
|
+
}
|
|
716
|
+
/** State-change notifications. One event per mutation; re-read `messages`
|
|
717
|
+
* (and friends) when it fires. Returns an unsubscribe fn. */
|
|
718
|
+
on(event, fn) {
|
|
719
|
+
return this.bus.on(event, fn);
|
|
720
|
+
}
|
|
721
|
+
/** Whether `userId` has read `messageId` according to the latest watermark. */
|
|
722
|
+
isReadBy(userId, messageId) {
|
|
723
|
+
const w = this._readWatermarks[userId];
|
|
724
|
+
if (!w) return false;
|
|
725
|
+
return !idGreater(messageId, w);
|
|
726
|
+
}
|
|
727
|
+
/** How many of the given users have read `messageId`. Pass the room's
|
|
728
|
+
* member ids (minus the sender) to get a KakaoTalk-style unread count:
|
|
729
|
+
* `members.length - readCount(...)`. */
|
|
730
|
+
readCount(messageId, userIds) {
|
|
731
|
+
let n = 0;
|
|
732
|
+
for (const u of userIds) if (this.isReadBy(u, messageId)) n++;
|
|
733
|
+
return n;
|
|
734
|
+
}
|
|
735
|
+
// ── loading — cache hydrate → API wholesale replace ─────────────
|
|
736
|
+
/**
|
|
737
|
+
* Load the most recent page of history.
|
|
738
|
+
*
|
|
739
|
+
* `ClientOptions.cacheStore`가 주입돼 있으면 캐시 스냅샷을 먼저 렌더한
|
|
740
|
+
* 뒤(`hydrated`), API 응답 도착 시 스냅샷을 걷어내고 통째로 교체한다
|
|
741
|
+
* (`replaced`). 스냅샷 렌더 중에는 `hasMore`/스크롤백을 열지 않는다 —
|
|
742
|
+
* 커서는 API 응답의 것만 신뢰한다.
|
|
743
|
+
*/
|
|
744
|
+
async load(limit = 50) {
|
|
745
|
+
const pending = this.room.listRecent(limit);
|
|
746
|
+
await this.hydrateFromCache();
|
|
747
|
+
const page = await pending;
|
|
748
|
+
this.applyFresh(page);
|
|
749
|
+
}
|
|
750
|
+
/** Scrollback: load the page of messages older than what's on screen and
|
|
751
|
+
* prepend it. No-op while a previous call is in flight or when `hasMore`
|
|
752
|
+
* is false. Returns the number of messages added. */
|
|
753
|
+
async loadMore(limit = 50) {
|
|
754
|
+
const cursor = this.oldestCursor;
|
|
755
|
+
if (this.loadingMore || !this._hasMore || cursor == null) return 0;
|
|
756
|
+
this.loadingMore = true;
|
|
757
|
+
try {
|
|
758
|
+
const page = await this.room.listBefore(cursor, limit);
|
|
759
|
+
this._messages.unshift(...page.items.map(chatMessageFromHistory));
|
|
760
|
+
this.oldestCursor = page.nextCursor;
|
|
761
|
+
this._hasMore = page.hasMore;
|
|
762
|
+
this.emit("inserted");
|
|
763
|
+
return page.items.length;
|
|
764
|
+
} finally {
|
|
765
|
+
this.loadingMore = false;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
/** 캐시 스냅샷 즉시 렌더 — 빈 리스트일 때만. 실패는 조용히 무시(캐시는
|
|
769
|
+
* 힌트일 뿐). */
|
|
770
|
+
async hydrateFromCache() {
|
|
771
|
+
if (this._messages.length > 0 || this.hydratedCount > 0) return;
|
|
772
|
+
const cached = await this.room.cachedRecent();
|
|
773
|
+
if (!cached || cached.items.length === 0 || this._messages.length > 0) {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
this._messages.unshift(...cached.items.map(chatMessageFromHistory));
|
|
777
|
+
this.hydratedCount = cached.items.length;
|
|
778
|
+
this.emit("hydrated");
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* API 최신 페이지를 컬렉션에 반영한다.
|
|
782
|
+
* replaceByApi(기본)는 스냅샷을 걷어내고 통째 교체하고, mergeDiff는
|
|
783
|
+
* 스냅샷의 옛 메시지를 보존·병합한다(`mergeFresh`).
|
|
784
|
+
*/
|
|
785
|
+
applyFresh(page) {
|
|
786
|
+
if (this.policy === "mergeDiff" && this.hydratedCount > 0) {
|
|
787
|
+
this.mergeFresh(page);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (this.hydratedCount > 0) {
|
|
791
|
+
this._messages.splice(0, this.hydratedCount);
|
|
792
|
+
this.hydratedCount = 0;
|
|
793
|
+
}
|
|
794
|
+
this._messages.unshift(...page.items.map(chatMessageFromHistory));
|
|
795
|
+
this.oldestCursor = page.nextCursor;
|
|
796
|
+
this._hasMore = page.hasMore;
|
|
797
|
+
this.emit("replaced");
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* diff 병합 — 스냅샷의 옛 메시지를 보존해 fresh 페이지 아래에 잇는다.
|
|
801
|
+
*
|
|
802
|
+
* 판정 규칙(서버 계약, 07-23 확정):
|
|
803
|
+
* - **컷오프분**: `history_from`(히스토리 하한, unix ms)보다 오래된 캐시
|
|
804
|
+
* 메시지는 재입장 컷/대화 지우기로 볼 수 없게 된 것 → 폐기.
|
|
805
|
+
* - **삭제분**: fresh 커버 구간(id ≥ fresh 최저 id)에 있는데 fresh에
|
|
806
|
+
* 없는 캐시 메시지는 그 사이 삭제된 것(타이머 만료 포함) → 폐기.
|
|
807
|
+
* - **갭 폴백**: 캐시의 최신 메시지가 fresh와 겹치지 않으면 누락 구간이
|
|
808
|
+
* 있을 수 있으므로 병합하지 않고 통째 교체.
|
|
809
|
+
*
|
|
810
|
+
* 겹치는 구간은 항상 fresh가 정답(수정 반영) — 보존되는 건 fresh 범위
|
|
811
|
+
* **밖**의 옛 메시지뿐이라 "서버가 항상 이긴다" 불변식은 유지된다.
|
|
812
|
+
*/
|
|
813
|
+
mergeFresh(page) {
|
|
814
|
+
const cachedHead = this._messages.slice(0, this.hydratedCount);
|
|
815
|
+
this._messages.splice(0, this.hydratedCount);
|
|
816
|
+
this.hydratedCount = 0;
|
|
817
|
+
const freshIds = new Set(page.items.map((m) => m.messageId));
|
|
818
|
+
const freshOldest = page.items.length > 0 ? page.items[0].messageId : null;
|
|
819
|
+
const hf = page.historyFrom ?? null;
|
|
820
|
+
const overlaps = cachedHead.length > 0 && freshOldest != null && freshIds.has(cachedHead.at(-1).id);
|
|
821
|
+
let preserved = [];
|
|
822
|
+
if (overlaps && page.hasMore) {
|
|
823
|
+
preserved = cachedHead.filter((m) => {
|
|
824
|
+
if (freshIds.has(m.id)) return false;
|
|
825
|
+
if (!idGreater(freshOldest, m.id)) return false;
|
|
826
|
+
if (hf != null && m.createdAtMs != null && m.createdAtMs < hf) {
|
|
827
|
+
return false;
|
|
828
|
+
}
|
|
829
|
+
return true;
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
this._messages.unshift(...page.items.map(chatMessageFromHistory));
|
|
833
|
+
this._messages.unshift(...preserved);
|
|
834
|
+
this.oldestCursor = preserved.length > 0 ? preserved[0].id : page.nextCursor;
|
|
835
|
+
this._hasMore = page.hasMore;
|
|
836
|
+
this.emit("replaced");
|
|
837
|
+
}
|
|
838
|
+
// ── optimistic sends ────────────────────────────────────────────
|
|
839
|
+
/**
|
|
840
|
+
* Send `text` optimistically: a `sending` bubble appears immediately (one
|
|
841
|
+
* microtask later — `Room.send` is async), then flips to `sent` (with the
|
|
842
|
+
* real id) on ack, or `failed` on error. Fire-and-forget: a WS-not-open
|
|
843
|
+
* failure surfaces as a `failed` bubble rather than a rejected promise.
|
|
844
|
+
*/
|
|
845
|
+
send(text, opts) {
|
|
846
|
+
void (async () => {
|
|
847
|
+
let tempId2;
|
|
848
|
+
let ackPromise;
|
|
849
|
+
try {
|
|
850
|
+
const sent = await this.room.send({
|
|
851
|
+
content: text,
|
|
852
|
+
...opts?.replyToId ? { replyToId: opts.replyToId } : {},
|
|
853
|
+
...opts?.customType ? { customType: opts.customType } : {},
|
|
854
|
+
...opts?.customMeta ? { customMeta: opts.customMeta } : {}
|
|
855
|
+
});
|
|
856
|
+
tempId2 = sent.tempId;
|
|
857
|
+
ackPromise = sent.messageId;
|
|
858
|
+
} catch {
|
|
859
|
+
const localId = `failed_${Date.now()}_${uploadSeq++}`;
|
|
860
|
+
this._messages.push({
|
|
861
|
+
...pendingChatMessage({ tempId: localId, content: text, ...opts }),
|
|
862
|
+
status: "failed"
|
|
863
|
+
});
|
|
864
|
+
this.emit("inserted");
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
this._messages.push(pendingChatMessage({ tempId: tempId2, content: text, ...opts }));
|
|
868
|
+
this.emit("inserted");
|
|
869
|
+
this.bindAck(tempId2, ackPromise);
|
|
870
|
+
})();
|
|
871
|
+
}
|
|
872
|
+
/** Send a file optimistically. A FILE bubble with `uploadProgress`
|
|
873
|
+
* appears immediately; progress advances during the S3 upload, then the
|
|
874
|
+
* bubble flips to `sent` on ack (or `failed`). Never throws — failures
|
|
875
|
+
* surface as the bubble's status. */
|
|
876
|
+
async sendFile(file, opts) {
|
|
877
|
+
const localId = `upload_${Date.now()}_${uploadSeq++}`;
|
|
878
|
+
this._messages.push(
|
|
879
|
+
pendingFileChatMessage({
|
|
880
|
+
tempId: localId,
|
|
881
|
+
name: opts?.name ?? file.name,
|
|
882
|
+
mime: file.type,
|
|
883
|
+
size: file.size
|
|
884
|
+
})
|
|
885
|
+
);
|
|
886
|
+
this.emit("inserted");
|
|
887
|
+
try {
|
|
888
|
+
const sent = await this.room.sendFile(file, {
|
|
889
|
+
...opts ?? {},
|
|
890
|
+
onProgress: (p) => {
|
|
891
|
+
this.mutateByTempId(localId, (m) => ({
|
|
892
|
+
...m,
|
|
893
|
+
uploadProgress: p.ratio
|
|
894
|
+
}));
|
|
895
|
+
opts?.onProgress?.(p);
|
|
896
|
+
}
|
|
897
|
+
});
|
|
898
|
+
this.mutateByTempId(localId, (m) => ({
|
|
899
|
+
...m,
|
|
900
|
+
fileId: sent.fileId,
|
|
901
|
+
file: m.file ? { ...m.file, id: sent.fileId } : m.file,
|
|
902
|
+
uploadProgress: null
|
|
903
|
+
}));
|
|
904
|
+
const realId = await sent.messageId;
|
|
905
|
+
this.mutateByTempId(localId, (m) => ({
|
|
906
|
+
...m,
|
|
907
|
+
id: realId,
|
|
908
|
+
status: "sent"
|
|
909
|
+
}));
|
|
910
|
+
} catch {
|
|
911
|
+
this.mutateByTempId(localId, (m) => ({
|
|
912
|
+
...m,
|
|
913
|
+
status: "failed",
|
|
914
|
+
uploadProgress: null
|
|
915
|
+
}));
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
/** Edit a message's content (server-side; peers get `message_updated`).
|
|
919
|
+
* The local copy updates optimistically. */
|
|
920
|
+
async edit(messageId, content) {
|
|
921
|
+
await this.room.edit(messageId, { content });
|
|
922
|
+
this.mutateById(messageId, (m) => ({
|
|
923
|
+
...m,
|
|
924
|
+
content,
|
|
925
|
+
editedCount: m.editedCount + 1
|
|
926
|
+
}));
|
|
927
|
+
}
|
|
928
|
+
/** Delete a message. `scope: "ALL"` (default) removes it for everyone;
|
|
929
|
+
* `"MY"` hides it only for the caller. Local copy flips to deleted. */
|
|
930
|
+
async delete(messageId, scope = "ALL") {
|
|
931
|
+
await this.room.delete(messageId, scope);
|
|
932
|
+
this.mutateById(messageId, (m) => ({ ...m, isDeleted: true }));
|
|
933
|
+
}
|
|
934
|
+
/** Add/remove the caller's reaction. Pass `myUserId` so the toggle can
|
|
935
|
+
* tell whether the caller already reacted with `key`. The authoritative
|
|
936
|
+
* state arrives back via the reaction WS events. */
|
|
937
|
+
async toggleReaction(messageId, key, myUserId) {
|
|
938
|
+
const m = this._messages.find((x) => x.id === messageId);
|
|
939
|
+
const mine = m?.reactions.some(
|
|
940
|
+
(r) => r.key === key && r.userIds.includes(myUserId)
|
|
941
|
+
) ?? false;
|
|
942
|
+
if (mine) {
|
|
943
|
+
await this.room.unreact(messageId, key);
|
|
944
|
+
} else {
|
|
945
|
+
await this.room.react(messageId, key);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
/** Mark `messageId` read (debounced inside the SDK). */
|
|
949
|
+
markRead(messageId) {
|
|
950
|
+
this.room.markRead(messageId);
|
|
951
|
+
}
|
|
952
|
+
/** Force-send the pending read watermark now (e.g. on unmount / tab
|
|
953
|
+
* backgrounding). */
|
|
954
|
+
flushMarkRead() {
|
|
955
|
+
this.room.flushMarkRead();
|
|
956
|
+
}
|
|
957
|
+
/** Unsubscribe from the room and flush the pending read watermark. The
|
|
958
|
+
* collection must not be used afterwards. */
|
|
959
|
+
dispose() {
|
|
960
|
+
this.room.flushMarkRead();
|
|
961
|
+
for (const u of this.unsubs.splice(0)) u();
|
|
962
|
+
this.bus.removeAll();
|
|
963
|
+
}
|
|
964
|
+
// ── event handlers ──────────────────────────────────────────────
|
|
965
|
+
onIncoming(f) {
|
|
966
|
+
this._messages.push(chatMessageFromLive(f));
|
|
967
|
+
this.emit("inserted");
|
|
968
|
+
}
|
|
969
|
+
onUpdated(f) {
|
|
970
|
+
this.mutateById(f.message_id, (m) => ({
|
|
971
|
+
...m,
|
|
972
|
+
content: f.content ?? m.content,
|
|
973
|
+
editedCount: m.editedCount + 1
|
|
974
|
+
}));
|
|
975
|
+
}
|
|
976
|
+
onDeleted(f) {
|
|
977
|
+
this.mutateById(f.message_id, (m) => ({ ...m, isDeleted: true }));
|
|
978
|
+
}
|
|
979
|
+
onCleared(f) {
|
|
980
|
+
const upTo = f.up_to_message_id;
|
|
981
|
+
let changed = false;
|
|
982
|
+
this._messages = this._messages.map((m) => {
|
|
983
|
+
if (!m.isDeleted && (upTo == null || !idGreater(m.id, upTo))) {
|
|
984
|
+
changed = true;
|
|
985
|
+
return { ...m, isDeleted: true };
|
|
986
|
+
}
|
|
987
|
+
return m;
|
|
988
|
+
});
|
|
989
|
+
if (changed) this.emit("updated");
|
|
990
|
+
}
|
|
991
|
+
onReactionAdded(f) {
|
|
992
|
+
this.mutateById(f.message_id, (m) => {
|
|
993
|
+
const next = m.reactions.map(
|
|
994
|
+
(r) => r.key === f.reaction_key ? {
|
|
995
|
+
key: r.key,
|
|
996
|
+
userIds: [.../* @__PURE__ */ new Set([...r.userIds, f.user_id])],
|
|
997
|
+
updatedAt: f.updated_at
|
|
998
|
+
} : r
|
|
999
|
+
);
|
|
1000
|
+
if (!next.some((r) => r.key === f.reaction_key)) {
|
|
1001
|
+
next.push({
|
|
1002
|
+
key: f.reaction_key,
|
|
1003
|
+
userIds: [f.user_id],
|
|
1004
|
+
updatedAt: f.updated_at
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
return { ...m, reactions: next };
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
onReactionRemoved(f) {
|
|
1011
|
+
this.mutateById(f.message_id, (m) => {
|
|
1012
|
+
const next = [];
|
|
1013
|
+
for (const r of m.reactions) {
|
|
1014
|
+
if (r.key !== f.reaction_key) {
|
|
1015
|
+
next.push(r);
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
const users = r.userIds.filter((u) => u !== f.user_id);
|
|
1019
|
+
if (users.length > 0) {
|
|
1020
|
+
next.push({ key: r.key, userIds: users, updatedAt: f.updated_at });
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return { ...m, reactions: next };
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
onSyncTick(f) {
|
|
1027
|
+
let changed = false;
|
|
1028
|
+
for (const [userId, mid] of Object.entries(f.read_states)) {
|
|
1029
|
+
const cur = this._readWatermarks[userId];
|
|
1030
|
+
if (!cur || idGreater(mid, cur)) {
|
|
1031
|
+
this._readWatermarks[userId] = mid;
|
|
1032
|
+
changed = true;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
if (changed) this.emit("watermarks");
|
|
1036
|
+
}
|
|
1037
|
+
// ── helpers ─────────────────────────────────────────────────────
|
|
1038
|
+
bindAck(tempId2, messageId) {
|
|
1039
|
+
messageId.then((realId) => {
|
|
1040
|
+
this.mutateByTempId(tempId2, (m) => ({
|
|
1041
|
+
...m,
|
|
1042
|
+
id: realId,
|
|
1043
|
+
status: "sent"
|
|
1044
|
+
}));
|
|
1045
|
+
}).catch(() => {
|
|
1046
|
+
this.mutateByTempId(tempId2, (m) => ({ ...m, status: "failed" }));
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
mutateById(id, f) {
|
|
1050
|
+
const i = this._messages.findIndex((m) => m.id === id);
|
|
1051
|
+
if (i === -1) return;
|
|
1052
|
+
this._messages[i] = f(this._messages[i]);
|
|
1053
|
+
this.emit("updated");
|
|
1054
|
+
}
|
|
1055
|
+
mutateByTempId(tempId2, f) {
|
|
1056
|
+
const i = this._messages.findIndex((m) => m.tempId === tempId2);
|
|
1057
|
+
if (i === -1) return;
|
|
1058
|
+
this._messages[i] = f(this._messages[i]);
|
|
1059
|
+
this.emit("updated");
|
|
1060
|
+
}
|
|
1061
|
+
emit(kind) {
|
|
1062
|
+
this.bus.emit("change", { kind });
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
1065
|
+
|
|
513
1066
|
// src/features/room.ts
|
|
514
1067
|
function toFileInline(f) {
|
|
515
1068
|
return {
|
|
@@ -539,11 +1092,14 @@ function toMessageOut(j) {
|
|
|
539
1092
|
};
|
|
540
1093
|
}
|
|
541
1094
|
var Room = class _Room {
|
|
542
|
-
constructor(roomId, http, ws, readDebounceMs) {
|
|
1095
|
+
constructor(roomId, http, ws, readDebounceMs, opts) {
|
|
543
1096
|
this.roomId = roomId;
|
|
544
1097
|
this.http = http;
|
|
545
1098
|
this.ws = ws;
|
|
546
1099
|
this.readDebounceMs = readDebounceMs;
|
|
1100
|
+
this.cacheStore = opts?.cacheStore ?? null;
|
|
1101
|
+
this.cachePolicy = opts?.cachePolicy ?? "replaceByApi";
|
|
1102
|
+
this.logger = opts?.logger;
|
|
547
1103
|
}
|
|
548
1104
|
roomId;
|
|
549
1105
|
http;
|
|
@@ -587,6 +1143,27 @@ var Room = class _Room {
|
|
|
587
1143
|
* fine in practice (small strings, cleared on `chat.disconnect()`).
|
|
588
1144
|
*/
|
|
589
1145
|
mySentTempIds = /* @__PURE__ */ new Set();
|
|
1146
|
+
// ── 스냅샷 캐시 협력자 (클라이언트 옵션에서 흘러듦) ──────────────
|
|
1147
|
+
cacheStore;
|
|
1148
|
+
cachePolicy;
|
|
1149
|
+
logger;
|
|
1150
|
+
/**
|
|
1151
|
+
* 이 방의 메시지 상태를 소유하는 `MessageCollection`을 만든다.
|
|
1152
|
+
*
|
|
1153
|
+
* 컬렉션이 이벤트 반영·낙관적 전송·캐시 hydrate→교체를 전부 담당하므로,
|
|
1154
|
+
* 리스트 화면은 컬렉션 하나만 물면 된다. 여러 개 만들 수 있고 각자
|
|
1155
|
+
* `dispose()`로 정리한다.
|
|
1156
|
+
*
|
|
1157
|
+
* ```ts
|
|
1158
|
+
* const col = chat.room("room_123").collection();
|
|
1159
|
+
* const unsub = col.on("change", () => render(col.messages));
|
|
1160
|
+
* await col.load();
|
|
1161
|
+
* col.send("hello");
|
|
1162
|
+
* ```
|
|
1163
|
+
*/
|
|
1164
|
+
collection(policy) {
|
|
1165
|
+
return new MessageCollection(this, policy ?? this.cachePolicy);
|
|
1166
|
+
}
|
|
590
1167
|
_sendPendingMarkRead() {
|
|
591
1168
|
const mid = this.pendingReadMid;
|
|
592
1169
|
if (!mid) return;
|
|
@@ -1043,11 +1620,70 @@ var Room = class _Room {
|
|
|
1043
1620
|
*/
|
|
1044
1621
|
async listRecent(limit = 50) {
|
|
1045
1622
|
const raw = await this.http.request("GET", `/api/v1/rooms/${this.roomId}/messages`, void 0, { limit });
|
|
1046
|
-
|
|
1623
|
+
const page = {
|
|
1047
1624
|
items: raw.items.map(toMessageOut),
|
|
1625
|
+
// 파싱 성공한 응답만 캐시에 넣는다
|
|
1048
1626
|
nextCursor: raw.next_cursor,
|
|
1049
|
-
hasMore: raw.has_more
|
|
1627
|
+
hasMore: raw.has_more,
|
|
1628
|
+
historyFrom: raw.history_from ?? null
|
|
1050
1629
|
};
|
|
1630
|
+
void this.cacheWriteRecent(raw);
|
|
1631
|
+
return page;
|
|
1632
|
+
}
|
|
1633
|
+
/** 이 방의 상세 정보 (`GET /rooms/{id}`) — 이름·멤버 수·삭제 타이머 등. */
|
|
1634
|
+
async detail() {
|
|
1635
|
+
const raw = await this.http.request(
|
|
1636
|
+
"GET",
|
|
1637
|
+
`/api/v1/rooms/${this.roomId}`
|
|
1638
|
+
);
|
|
1639
|
+
return parseRoomOut(raw);
|
|
1640
|
+
}
|
|
1641
|
+
/**
|
|
1642
|
+
* 히스토리 최신 페이지의 캐시 스냅샷 — 마지막 `listRecent()` 성공 응답의
|
|
1643
|
+
* 원문. 콜드 스타트 렌더 힌트 용도다: 있으면 즉시 그리고, `listRecent()`가
|
|
1644
|
+
* 도착하면 통째로 교체할 것. 캐시가 없거나(`cacheStore` 미주입 포함)
|
|
1645
|
+
* 파싱이 실패하면 `null`을 돌려주고, 깨진 스냅샷은 지운다.
|
|
1646
|
+
*
|
|
1647
|
+
* 스냅샷의 `nextCursor`/`hasMore`는 시점이 지난 값이므로 스크롤백
|
|
1648
|
+
* 커서로 쓰지 말 것 — API 응답이 도착한 뒤의 값만 신뢰한다.
|
|
1649
|
+
*/
|
|
1650
|
+
async cachedRecent() {
|
|
1651
|
+
const store = this.cacheStore;
|
|
1652
|
+
if (!store) return null;
|
|
1653
|
+
const key = CacheKeys.msgs(this.roomId);
|
|
1654
|
+
try {
|
|
1655
|
+
const raw = await store.read(key);
|
|
1656
|
+
if (raw == null) return null;
|
|
1657
|
+
const j = JSON.parse(raw);
|
|
1658
|
+
return {
|
|
1659
|
+
items: j.items.map(toMessageOut),
|
|
1660
|
+
nextCursor: j.next_cursor,
|
|
1661
|
+
hasMore: j.has_more,
|
|
1662
|
+
historyFrom: j.history_from ?? null
|
|
1663
|
+
};
|
|
1664
|
+
} catch (err) {
|
|
1665
|
+
this.logger?.("warn", `cache_read_failed key=${key}`, err);
|
|
1666
|
+
try {
|
|
1667
|
+
await store.remove(key);
|
|
1668
|
+
} catch {
|
|
1669
|
+
}
|
|
1670
|
+
return null;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* 최신 페이지 스냅샷 저장. 삭제 타이머 방도 저장한다 — 서버가 만료를
|
|
1675
|
+
* 일반 `message_deleted` 프레임으로 브로드캐스트하고 히스토리에서도
|
|
1676
|
+
* 즉시 제외하므로, 만료분은 라이브 이벤트/`history_from` diff로 정리된다.
|
|
1677
|
+
*/
|
|
1678
|
+
async cacheWriteRecent(raw) {
|
|
1679
|
+
const store = this.cacheStore;
|
|
1680
|
+
if (!store) return;
|
|
1681
|
+
const key = CacheKeys.msgs(this.roomId);
|
|
1682
|
+
try {
|
|
1683
|
+
await store.write(key, JSON.stringify(raw));
|
|
1684
|
+
} catch (err) {
|
|
1685
|
+
this.logger?.("warn", `cache_write_failed key=${key}`, err);
|
|
1686
|
+
}
|
|
1051
1687
|
}
|
|
1052
1688
|
/**
|
|
1053
1689
|
* Scrollback. Fetches the page of `limit` messages immediately preceding
|
|
@@ -1066,7 +1702,7 @@ var Room = class _Room {
|
|
|
1066
1702
|
hasMore: raw.has_more
|
|
1067
1703
|
};
|
|
1068
1704
|
}
|
|
1069
|
-
/**
|
|
1705
|
+
/** 방 내 메시지 검색.
|
|
1070
1706
|
*
|
|
1071
1707
|
* ``content`` 필드에 대한 대소문자 무시 부분 문자열 매칭 (ILIKE). 삭제된
|
|
1072
1708
|
* 메시지는 제외. 결과는 최신순 (message_id DESC) 으로 반환되고, ``before``
|
|
@@ -1422,7 +2058,7 @@ var Room = class _Room {
|
|
|
1422
2058
|
};
|
|
1423
2059
|
|
|
1424
2060
|
// src/client.ts
|
|
1425
|
-
function
|
|
2061
|
+
function idGreater2(a, b) {
|
|
1426
2062
|
if (!a) return false;
|
|
1427
2063
|
if (!b) return true;
|
|
1428
2064
|
try {
|
|
@@ -1508,7 +2144,11 @@ var NoveraChat = class {
|
|
|
1508
2144
|
room(roomId) {
|
|
1509
2145
|
let r = this.rooms.get(roomId);
|
|
1510
2146
|
if (!r) {
|
|
1511
|
-
r = new Room(roomId, this.http, this.ws, this.readDebounceMs
|
|
2147
|
+
r = new Room(roomId, this.http, this.ws, this.readDebounceMs, {
|
|
2148
|
+
...this.opts.cacheStore ? { cacheStore: this.opts.cacheStore } : {},
|
|
2149
|
+
...this.opts.cachePolicy ? { cachePolicy: this.opts.cachePolicy } : {},
|
|
2150
|
+
...this.logger ? { logger: this.logger } : {}
|
|
2151
|
+
});
|
|
1512
2152
|
this.rooms.set(roomId, r);
|
|
1513
2153
|
}
|
|
1514
2154
|
return r;
|
|
@@ -1553,7 +2193,7 @@ var NoveraChat = class {
|
|
|
1553
2193
|
const sid = typeof messageId === "number" ? String(messageId) : messageId;
|
|
1554
2194
|
if (!sid || sid === "0") return;
|
|
1555
2195
|
const cur = this.lastMessageIdByRoom.get(roomId);
|
|
1556
|
-
if (
|
|
2196
|
+
if (idGreater2(sid, cur)) this.lastMessageIdByRoom.set(roomId, sid);
|
|
1557
2197
|
}
|
|
1558
2198
|
/** Server-side aggregate unread state: total count + per-room breakdown
|
|
1559
2199
|
* for the authenticated user. Single REST call; cheap enough to poll
|
|
@@ -1755,7 +2395,41 @@ var NoveraChat = class {
|
|
|
1755
2395
|
}
|
|
1756
2396
|
async unreadSummary() {
|
|
1757
2397
|
const raw = await this.http.request("GET", "/api/v1/users/me/unread");
|
|
1758
|
-
|
|
2398
|
+
const out = parseUnreadSummary(raw);
|
|
2399
|
+
void this.cacheWriteRooms(raw);
|
|
2400
|
+
return out;
|
|
2401
|
+
}
|
|
2402
|
+
/**
|
|
2403
|
+
* 방 목록의 캐시 스냅샷 — 마지막 `unreadSummary()` 성공 응답의 원문.
|
|
2404
|
+
*
|
|
2405
|
+
* 콜드 스타트 렌더 힌트 용도다: 있으면 즉시 그리고, `unreadSummary()`가
|
|
2406
|
+
* 도착하면 통째로 교체할 것. 캐시가 없거나(`cacheStore` 미주입 포함)
|
|
2407
|
+
* 파싱이 실패하면 `null`을 돌려주고, 깨진 스냅샷은 지운다.
|
|
2408
|
+
*/
|
|
2409
|
+
async cachedUnreadSummary() {
|
|
2410
|
+
const store = this.opts.cacheStore;
|
|
2411
|
+
if (!store) return null;
|
|
2412
|
+
try {
|
|
2413
|
+
const raw = await store.read(CacheKeys.rooms);
|
|
2414
|
+
if (raw == null) return null;
|
|
2415
|
+
return parseUnreadSummary(JSON.parse(raw));
|
|
2416
|
+
} catch (err) {
|
|
2417
|
+
this.logger?.("warn", `cache_read_failed key=${CacheKeys.rooms}`, err);
|
|
2418
|
+
try {
|
|
2419
|
+
await store.remove(CacheKeys.rooms);
|
|
2420
|
+
} catch {
|
|
2421
|
+
}
|
|
2422
|
+
return null;
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
async cacheWriteRooms(raw) {
|
|
2426
|
+
const store = this.opts.cacheStore;
|
|
2427
|
+
if (!store) return;
|
|
2428
|
+
try {
|
|
2429
|
+
await store.write(CacheKeys.rooms, JSON.stringify(raw));
|
|
2430
|
+
} catch (err) {
|
|
2431
|
+
this.logger?.("warn", `cache_write_failed key=${CacheKeys.rooms}`, err);
|
|
2432
|
+
}
|
|
1759
2433
|
}
|
|
1760
2434
|
/**
|
|
1761
2435
|
* Accept an invite link — call this in flows where you receive a
|
|
@@ -1836,7 +2510,7 @@ var NoveraChat = class {
|
|
|
1836
2510
|
return;
|
|
1837
2511
|
}
|
|
1838
2512
|
case "ack": {
|
|
1839
|
-
if (
|
|
2513
|
+
if (idGreater2(msg.message_id, this.lastMessageIdByRoom.get(msg.room_id))) {
|
|
1840
2514
|
this.lastMessageIdByRoom.set(msg.room_id, msg.message_id);
|
|
1841
2515
|
}
|
|
1842
2516
|
const room = this.rooms.get(msg.room_id);
|
|
@@ -1853,7 +2527,7 @@ var NoveraChat = class {
|
|
|
1853
2527
|
if (!roomId) return;
|
|
1854
2528
|
const room = this.rooms.get(roomId);
|
|
1855
2529
|
if (!room) return;
|
|
1856
|
-
if (msg.type === "chat_receive" &&
|
|
2530
|
+
if (msg.type === "chat_receive" && idGreater2(msg.message_id, this.lastMessageIdByRoom.get(roomId))) {
|
|
1857
2531
|
this.lastMessageIdByRoom.set(roomId, msg.message_id);
|
|
1858
2532
|
}
|
|
1859
2533
|
if (msg.type === "chat_receive") {
|
|
@@ -1871,7 +2545,7 @@ var NoveraChat = class {
|
|
|
1871
2545
|
highestKnownMessageId() {
|
|
1872
2546
|
let max = null;
|
|
1873
2547
|
for (const v of this.lastMessageIdByRoom.values()) {
|
|
1874
|
-
if (
|
|
2548
|
+
if (idGreater2(v, max)) max = v;
|
|
1875
2549
|
}
|
|
1876
2550
|
return max;
|
|
1877
2551
|
}
|
|
@@ -1912,7 +2586,7 @@ var NoveraChat = class {
|
|
|
1912
2586
|
created_at: m.createdAtMs,
|
|
1913
2587
|
server_ts: Date.now()
|
|
1914
2588
|
});
|
|
1915
|
-
if (
|
|
2589
|
+
if (idGreater2(m.messageId, this.lastMessageIdByRoom.get(roomId))) {
|
|
1916
2590
|
this.lastMessageIdByRoom.set(roomId, m.messageId);
|
|
1917
2591
|
}
|
|
1918
2592
|
}
|
|
@@ -1927,8 +2601,14 @@ var NoveraChat = class {
|
|
|
1927
2601
|
}
|
|
1928
2602
|
};
|
|
1929
2603
|
export {
|
|
2604
|
+
CacheKeys,
|
|
1930
2605
|
ErrorCode,
|
|
2606
|
+
MessageCollection,
|
|
1931
2607
|
NoveraChat,
|
|
1932
2608
|
NoveraChatError,
|
|
1933
|
-
Room
|
|
2609
|
+
Room,
|
|
2610
|
+
chatMessageFromHistory,
|
|
2611
|
+
chatMessageFromLive,
|
|
2612
|
+
pendingChatMessage,
|
|
2613
|
+
pendingFileChatMessage
|
|
1934
2614
|
};
|