@noverachat/sdk-web 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +92 -29
- package/dist/index.d.cts +57 -17
- package/dist/index.d.ts +57 -17
- package/dist/index.js +92 -29
- package/package.json +1 -1
- package/src/cache.ts +7 -5
- package/src/client.ts +10 -5
- package/src/features/message-collection.ts +95 -9
- package/src/features/room.ts +11 -17
- package/src/generated/rest.ts +64 -0
- package/src/types.ts +2 -1
package/dist/index.cjs
CHANGED
|
@@ -58,6 +58,7 @@ function parseUnreadRoomItem(j) {
|
|
|
58
58
|
lastMessageId: j["last_message_id"],
|
|
59
59
|
lastReadMessageId: j["last_read_message_id"],
|
|
60
60
|
lastMessagePreview: j["last_message_preview"],
|
|
61
|
+
lastMessageAt: j["last_message_at"],
|
|
61
62
|
memberCount: j["member_count"],
|
|
62
63
|
membersPreview: j["members_preview"] == null ? void 0 : j["members_preview"].map((e) => parseMemberPreview(e))
|
|
63
64
|
};
|
|
@@ -812,23 +813,71 @@ var MessageCollection = class {
|
|
|
812
813
|
this.emit("hydrated");
|
|
813
814
|
}
|
|
814
815
|
/**
|
|
815
|
-
* API 최신 페이지를 컬렉션에
|
|
816
|
-
* replaceByApi(
|
|
817
|
-
*
|
|
816
|
+
* API 최신 페이지를 컬렉션에 반영한다.
|
|
817
|
+
* replaceByApi(기본)는 스냅샷을 걷어내고 통째 교체하고, mergeDiff는
|
|
818
|
+
* 스냅샷의 옛 메시지를 보존·병합한다(`mergeFresh`).
|
|
818
819
|
*/
|
|
819
820
|
applyFresh(page) {
|
|
820
|
-
if (this.policy === "mergeDiff") {
|
|
821
|
-
|
|
821
|
+
if (this.policy === "mergeDiff" && this.hydratedCount > 0) {
|
|
822
|
+
this.mergeFresh(page);
|
|
823
|
+
return;
|
|
822
824
|
}
|
|
823
825
|
if (this.hydratedCount > 0) {
|
|
824
826
|
this._messages.splice(0, this.hydratedCount);
|
|
825
827
|
this.hydratedCount = 0;
|
|
826
828
|
}
|
|
827
|
-
|
|
829
|
+
const indexById = /* @__PURE__ */ new Map();
|
|
830
|
+
this._messages.forEach((m, i) => indexById.set(m.id, i));
|
|
831
|
+
const fresh = [];
|
|
832
|
+
for (const item of page.items.map(chatMessageFromHistory)) {
|
|
833
|
+
const at = indexById.get(item.id);
|
|
834
|
+
if (at === void 0) fresh.push(item);
|
|
835
|
+
else this._messages[at] = item;
|
|
836
|
+
}
|
|
837
|
+
this._messages.unshift(...fresh);
|
|
828
838
|
this.oldestCursor = page.nextCursor;
|
|
829
839
|
this._hasMore = page.hasMore;
|
|
830
840
|
this.emit("replaced");
|
|
831
841
|
}
|
|
842
|
+
/**
|
|
843
|
+
* diff 병합 — 스냅샷의 옛 메시지를 보존해 fresh 페이지 아래에 잇는다.
|
|
844
|
+
*
|
|
845
|
+
* 판정 규칙(서버 계약, 07-23 확정):
|
|
846
|
+
* - **컷오프분**: `history_from`(히스토리 하한, unix ms)보다 오래된 캐시
|
|
847
|
+
* 메시지는 재입장 컷/대화 지우기로 볼 수 없게 된 것 → 폐기.
|
|
848
|
+
* - **삭제분**: fresh 커버 구간(id ≥ fresh 최저 id)에 있는데 fresh에
|
|
849
|
+
* 없는 캐시 메시지는 그 사이 삭제된 것(타이머 만료 포함) → 폐기.
|
|
850
|
+
* - **갭 폴백**: 캐시의 최신 메시지가 fresh와 겹치지 않으면 누락 구간이
|
|
851
|
+
* 있을 수 있으므로 병합하지 않고 통째 교체.
|
|
852
|
+
*
|
|
853
|
+
* 겹치는 구간은 항상 fresh가 정답(수정 반영) — 보존되는 건 fresh 범위
|
|
854
|
+
* **밖**의 옛 메시지뿐이라 "서버가 항상 이긴다" 불변식은 유지된다.
|
|
855
|
+
*/
|
|
856
|
+
mergeFresh(page) {
|
|
857
|
+
const cachedHead = this._messages.slice(0, this.hydratedCount);
|
|
858
|
+
this._messages.splice(0, this.hydratedCount);
|
|
859
|
+
this.hydratedCount = 0;
|
|
860
|
+
const freshIds = new Set(page.items.map((m) => m.messageId));
|
|
861
|
+
const freshOldest = page.items.length > 0 ? page.items[0].messageId : null;
|
|
862
|
+
const hf = page.historyFrom ?? null;
|
|
863
|
+
const overlaps = cachedHead.length > 0 && freshOldest != null && freshIds.has(cachedHead.at(-1).id);
|
|
864
|
+
let preserved = [];
|
|
865
|
+
if (overlaps && page.hasMore) {
|
|
866
|
+
preserved = cachedHead.filter((m) => {
|
|
867
|
+
if (freshIds.has(m.id)) return false;
|
|
868
|
+
if (!idGreater(freshOldest, m.id)) return false;
|
|
869
|
+
if (hf != null && m.createdAtMs != null && m.createdAtMs < hf) {
|
|
870
|
+
return false;
|
|
871
|
+
}
|
|
872
|
+
return true;
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
this._messages.unshift(...page.items.map(chatMessageFromHistory));
|
|
876
|
+
this._messages.unshift(...preserved);
|
|
877
|
+
this.oldestCursor = preserved.length > 0 ? preserved[0].id : page.nextCursor;
|
|
878
|
+
this._hasMore = page.hasMore;
|
|
879
|
+
this.emit("replaced");
|
|
880
|
+
}
|
|
832
881
|
// ── optimistic sends ────────────────────────────────────────────
|
|
833
882
|
/**
|
|
834
883
|
* Send `text` optimistically: a `sending` bubble appears immediately (one
|
|
@@ -956,8 +1005,26 @@ var MessageCollection = class {
|
|
|
956
1005
|
this.bus.removeAll();
|
|
957
1006
|
}
|
|
958
1007
|
// ── event handlers ──────────────────────────────────────────────
|
|
1008
|
+
/**
|
|
1009
|
+
* 수신 프레임을 컬렉션에 넣는다.
|
|
1010
|
+
*
|
|
1011
|
+
* 두 가지를 방어한다 — backfill(`chat.backfill()`)은 재연결 시 앵커
|
|
1012
|
+
* 이후를 **다시** 실어오므로, 이미 들고 있는 메시지가 섞여 오고 화면보다
|
|
1013
|
+
* 오래된 메시지도 함께 온다:
|
|
1014
|
+
* - **중복**: 같은 id가 이미 있으면 무시한다.
|
|
1015
|
+
* - **순서**: 뒤에서부터 자리를 찾아 꽂는다. 실시간 프레임은 항상 마지막
|
|
1016
|
+
* 메시지보다 새로우므로 비교 1번으로 끝나고(빠른 경로), 옛 메시지가
|
|
1017
|
+
* 섞여 올 때만 탐색이 일어난다.
|
|
1018
|
+
*/
|
|
959
1019
|
onIncoming(f) {
|
|
960
|
-
|
|
1020
|
+
const msg = chatMessageFromLive(f);
|
|
1021
|
+
let i = this._messages.length - 1;
|
|
1022
|
+
for (; i >= 0; i--) {
|
|
1023
|
+
const cur = this._messages[i];
|
|
1024
|
+
if (cur.id === msg.id) return;
|
|
1025
|
+
if (!idGreater(cur.id, msg.id)) break;
|
|
1026
|
+
}
|
|
1027
|
+
this._messages.splice(i + 1, 0, msg);
|
|
961
1028
|
this.emit("inserted");
|
|
962
1029
|
}
|
|
963
1030
|
onUpdated(f) {
|
|
@@ -1141,9 +1208,6 @@ var Room = class _Room {
|
|
|
1141
1208
|
cacheStore;
|
|
1142
1209
|
cachePolicy;
|
|
1143
1210
|
logger;
|
|
1144
|
-
/** 삭제 타이머 여부 세션 메모 — null=미확인, 값=마지막 detail() 결과. */
|
|
1145
|
-
timerSeconds = null;
|
|
1146
|
-
timerKnown = false;
|
|
1147
1211
|
/**
|
|
1148
1212
|
* 이 방의 메시지 상태를 소유하는 `MessageCollection`을 만든다.
|
|
1149
1213
|
*
|
|
@@ -1621,7 +1685,8 @@ var Room = class _Room {
|
|
|
1621
1685
|
items: raw.items.map(toMessageOut),
|
|
1622
1686
|
// 파싱 성공한 응답만 캐시에 넣는다
|
|
1623
1687
|
nextCursor: raw.next_cursor,
|
|
1624
|
-
hasMore: raw.has_more
|
|
1688
|
+
hasMore: raw.has_more,
|
|
1689
|
+
historyFrom: raw.history_from ?? null
|
|
1625
1690
|
};
|
|
1626
1691
|
void this.cacheWriteRecent(raw);
|
|
1627
1692
|
return page;
|
|
@@ -1632,10 +1697,7 @@ var Room = class _Room {
|
|
|
1632
1697
|
"GET",
|
|
1633
1698
|
`/api/v1/rooms/${this.roomId}`
|
|
1634
1699
|
);
|
|
1635
|
-
|
|
1636
|
-
this.timerSeconds = out.timerSeconds;
|
|
1637
|
-
this.timerKnown = true;
|
|
1638
|
-
return out;
|
|
1700
|
+
return parseRoomOut(raw);
|
|
1639
1701
|
}
|
|
1640
1702
|
/**
|
|
1641
1703
|
* 히스토리 최신 페이지의 캐시 스냅샷 — 마지막 `listRecent()` 성공 응답의
|
|
@@ -1657,7 +1719,8 @@ var Room = class _Room {
|
|
|
1657
1719
|
return {
|
|
1658
1720
|
items: j.items.map(toMessageOut),
|
|
1659
1721
|
nextCursor: j.next_cursor,
|
|
1660
|
-
hasMore: j.has_more
|
|
1722
|
+
hasMore: j.has_more,
|
|
1723
|
+
historyFrom: j.history_from ?? null
|
|
1661
1724
|
};
|
|
1662
1725
|
} catch (err) {
|
|
1663
1726
|
this.logger?.("warn", `cache_read_failed key=${key}`, err);
|
|
@@ -1669,20 +1732,15 @@ var Room = class _Room {
|
|
|
1669
1732
|
}
|
|
1670
1733
|
}
|
|
1671
1734
|
/**
|
|
1672
|
-
* 최신 페이지 스냅샷 저장. 삭제
|
|
1673
|
-
*
|
|
1674
|
-
*
|
|
1735
|
+
* 최신 페이지 스냅샷 저장. 삭제 타이머 방도 저장한다 — 서버가 만료를
|
|
1736
|
+
* 일반 `message_deleted` 프레임으로 브로드캐스트하고 히스토리에서도
|
|
1737
|
+
* 즉시 제외하므로, 만료분은 라이브 이벤트/`history_from` diff로 정리된다.
|
|
1675
1738
|
*/
|
|
1676
1739
|
async cacheWriteRecent(raw) {
|
|
1677
1740
|
const store = this.cacheStore;
|
|
1678
1741
|
if (!store) return;
|
|
1679
1742
|
const key = CacheKeys.msgs(this.roomId);
|
|
1680
1743
|
try {
|
|
1681
|
-
if (!this.timerKnown) await this.detail();
|
|
1682
|
-
if (this.timerSeconds != null) {
|
|
1683
|
-
await store.remove(key);
|
|
1684
|
-
return;
|
|
1685
|
-
}
|
|
1686
1744
|
await store.write(key, JSON.stringify(raw));
|
|
1687
1745
|
} catch (err) {
|
|
1688
1746
|
this.logger?.("warn", `cache_write_failed key=${key}`, err);
|
|
@@ -2076,11 +2134,6 @@ var NoveraChat = class {
|
|
|
2076
2134
|
if (!opts.appId) throw new Error("appId required");
|
|
2077
2135
|
if (!opts.endpoint) throw new Error("endpoint required");
|
|
2078
2136
|
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
|
-
}
|
|
2084
2137
|
this.readDebounceMs = opts.readWatermarkDebounceMs ?? 400;
|
|
2085
2138
|
this.logger = opts.logger;
|
|
2086
2139
|
if (opts.initialLastMessageIds) {
|
|
@@ -2136,6 +2189,16 @@ var NoveraChat = class {
|
|
|
2136
2189
|
readDebounceMs;
|
|
2137
2190
|
logger;
|
|
2138
2191
|
connectedOnce = false;
|
|
2192
|
+
/**
|
|
2193
|
+
* 방별 "실시간 파이프라인으로 본 최대 message_id" — 재연결 핸드셰이크의
|
|
2194
|
+
* `?since=`와 backfill 시작점.
|
|
2195
|
+
*
|
|
2196
|
+
* **의도적으로 낮게 유지한다.** 히스토리 로드(REST)는 이 값을 올리지
|
|
2197
|
+
* 않는다 — 앵커가 낮으면 backfill이 이미 가진 것까지 다시 주는 쪽으로
|
|
2198
|
+
* (안전), 높으면 못 받은 것을 영영 건너뛰는 쪽으로(치명) 실패하기
|
|
2199
|
+
* 때문이다. 중복은 수신 측(`MessageCollection.onIncoming`)의 id dedupe가
|
|
2200
|
+
* 흡수하므로, "빠뜨린 갱신"으로 보고 올리지 말 것.
|
|
2201
|
+
*/
|
|
2139
2202
|
lastMessageIdByRoom = /* @__PURE__ */ new Map();
|
|
2140
2203
|
/** Most recently dispatched presence event key, `${userId}:${at}:${online}`.
|
|
2141
2204
|
* Used to drop the N-1 shared-room copies of a single online/offline
|
package/dist/index.d.cts
CHANGED
|
@@ -225,6 +225,7 @@ interface UnreadRoomItem {
|
|
|
225
225
|
lastMessageId?: string | null;
|
|
226
226
|
lastReadMessageId?: string | null;
|
|
227
227
|
lastMessagePreview?: string | null;
|
|
228
|
+
lastMessageAt?: number | null;
|
|
228
229
|
memberCount?: number;
|
|
229
230
|
membersPreview?: MemberPreview[];
|
|
230
231
|
}
|
|
@@ -380,11 +381,12 @@ type PushTrigger = "ALL" | "MENTION_ONLY" | "OFF";
|
|
|
380
381
|
*
|
|
381
382
|
* 원칙:
|
|
382
383
|
* 1. 캐시 = 렌더 힌트, 서버가 항상 이긴다 — 시작 시 스냅샷 즉시 렌더,
|
|
383
|
-
* API 도착 시 통째 교체(
|
|
384
|
+
* API 도착 시 기본 통째 교체(replaceByApi). 옵트인 mergeDiff는
|
|
385
|
+
* fresh보다 오래된 메시지만 보존하는 병합(서버를 덮어쓰지 않음).
|
|
384
386
|
* 2. 저장소는 SDK가 정하지 않는다 — `CacheStore` 인터페이스만 정의,
|
|
385
387
|
* 구현은 앱이 주입(localStorage/IndexedDB 등).
|
|
386
388
|
* 3. 버전 스탬프 + 통째 폐기 — 키에 스키마 버전(`nc.v1.…`), 파싱 실패
|
|
387
|
-
* 시 해당 키를 드랍.
|
|
389
|
+
* 시 해당 키를 드랍.
|
|
388
390
|
*/
|
|
389
391
|
/**
|
|
390
392
|
* 앱이 주입하는 key-value 스냅샷 저장소.
|
|
@@ -417,9 +419,10 @@ interface CacheStore {
|
|
|
417
419
|
/**
|
|
418
420
|
* 캐시 스냅샷과 API 응답을 어떻게 합칠지.
|
|
419
421
|
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
422
|
+
* - `"replaceByApi"` (기본) — 스냅샷 즉시 렌더 후 API 도착 시 통째 교체.
|
|
423
|
+
* - `"mergeDiff"` — fresh 페이지보다 오래된 캐시 메시지를 보존·병합해 더
|
|
424
|
+
* 긴 연속 히스토리 유지. `history_from`으로 컷오프분을, fresh 대조로
|
|
425
|
+
* 삭제분을 걸러내고, 캐시·fresh 사이 갭(겹침 없음)이면 통째 교체 폴백.
|
|
423
426
|
*/
|
|
424
427
|
type CachePolicy = "replaceByApi" | "mergeDiff";
|
|
425
428
|
/**
|
|
@@ -597,7 +600,8 @@ interface ClientOptions {
|
|
|
597
600
|
* 렌더에 쓸 수 있다. 자세한 건 `CacheStore` 문서 참조.
|
|
598
601
|
*/
|
|
599
602
|
cacheStore?: CacheStore;
|
|
600
|
-
/** 캐시 스냅샷과 API 응답의 합성 방식.
|
|
603
|
+
/** 캐시 스냅샷과 API 응답의 합성 방식. 기본 `"replaceByApi"`(통째 교체),
|
|
604
|
+
* `"mergeDiff"`는 옵트인 — 옛 캐시 메시지를 보존·병합. */
|
|
601
605
|
cachePolicy?: CachePolicy;
|
|
602
606
|
autoReconnect?: boolean;
|
|
603
607
|
pingIntervalMs?: number;
|
|
@@ -760,8 +764,8 @@ declare function pendingFileChatMessage(p: {
|
|
|
760
764
|
* RoomStore(sdk-react)에 있던 이벤트 반영·낙관적 전송·캐시 hydrate→교체
|
|
761
765
|
* 로직의 코어 이관본. 바인딩(React·Flutter)은 이 컬렉션을 감싸는 얇은
|
|
762
766
|
* 접착층이 되고, "캐시는 렌더 힌트, 서버가 항상 이긴다" 같은 불변식은
|
|
763
|
-
* 코어가 강제한다.
|
|
764
|
-
*
|
|
767
|
+
* 코어가 강제한다. diff 병합(mergeDiff)은 `applyFresh`→`mergeFresh`로
|
|
768
|
+
* 들어온다.
|
|
765
769
|
*/
|
|
766
770
|
|
|
767
771
|
/**
|
|
@@ -854,11 +858,26 @@ declare class MessageCollection {
|
|
|
854
858
|
* 힌트일 뿐). */
|
|
855
859
|
private hydrateFromCache;
|
|
856
860
|
/**
|
|
857
|
-
* API 최신 페이지를 컬렉션에
|
|
858
|
-
* replaceByApi(
|
|
859
|
-
*
|
|
861
|
+
* API 최신 페이지를 컬렉션에 반영한다.
|
|
862
|
+
* replaceByApi(기본)는 스냅샷을 걷어내고 통째 교체하고, mergeDiff는
|
|
863
|
+
* 스냅샷의 옛 메시지를 보존·병합한다(`mergeFresh`).
|
|
860
864
|
*/
|
|
861
865
|
private applyFresh;
|
|
866
|
+
/**
|
|
867
|
+
* diff 병합 — 스냅샷의 옛 메시지를 보존해 fresh 페이지 아래에 잇는다.
|
|
868
|
+
*
|
|
869
|
+
* 판정 규칙(서버 계약, 07-23 확정):
|
|
870
|
+
* - **컷오프분**: `history_from`(히스토리 하한, unix ms)보다 오래된 캐시
|
|
871
|
+
* 메시지는 재입장 컷/대화 지우기로 볼 수 없게 된 것 → 폐기.
|
|
872
|
+
* - **삭제분**: fresh 커버 구간(id ≥ fresh 최저 id)에 있는데 fresh에
|
|
873
|
+
* 없는 캐시 메시지는 그 사이 삭제된 것(타이머 만료 포함) → 폐기.
|
|
874
|
+
* - **갭 폴백**: 캐시의 최신 메시지가 fresh와 겹치지 않으면 누락 구간이
|
|
875
|
+
* 있을 수 있으므로 병합하지 않고 통째 교체.
|
|
876
|
+
*
|
|
877
|
+
* 겹치는 구간은 항상 fresh가 정답(수정 반영) — 보존되는 건 fresh 범위
|
|
878
|
+
* **밖**의 옛 메시지뿐이라 "서버가 항상 이긴다" 불변식은 유지된다.
|
|
879
|
+
*/
|
|
880
|
+
private mergeFresh;
|
|
862
881
|
/**
|
|
863
882
|
* Send `text` optimistically: a `sending` bubble appears immediately (one
|
|
864
883
|
* microtask later — `Room.send` is async), then flips to `sent` (with the
|
|
@@ -893,6 +912,17 @@ declare class MessageCollection {
|
|
|
893
912
|
/** Unsubscribe from the room and flush the pending read watermark. The
|
|
894
913
|
* collection must not be used afterwards. */
|
|
895
914
|
dispose(): void;
|
|
915
|
+
/**
|
|
916
|
+
* 수신 프레임을 컬렉션에 넣는다.
|
|
917
|
+
*
|
|
918
|
+
* 두 가지를 방어한다 — backfill(`chat.backfill()`)은 재연결 시 앵커
|
|
919
|
+
* 이후를 **다시** 실어오므로, 이미 들고 있는 메시지가 섞여 오고 화면보다
|
|
920
|
+
* 오래된 메시지도 함께 온다:
|
|
921
|
+
* - **중복**: 같은 id가 이미 있으면 무시한다.
|
|
922
|
+
* - **순서**: 뒤에서부터 자리를 찾아 꽂는다. 실시간 프레임은 항상 마지막
|
|
923
|
+
* 메시지보다 새로우므로 비교 1번으로 끝나고(빠른 경로), 옛 메시지가
|
|
924
|
+
* 섞여 올 때만 탐색이 일어난다.
|
|
925
|
+
*/
|
|
896
926
|
private onIncoming;
|
|
897
927
|
private onUpdated;
|
|
898
928
|
private onDeleted;
|
|
@@ -946,9 +976,6 @@ declare class Room {
|
|
|
946
976
|
private readonly cacheStore;
|
|
947
977
|
private readonly cachePolicy;
|
|
948
978
|
private readonly logger;
|
|
949
|
-
/** 삭제 타이머 여부 세션 메모 — null=미확인, 값=마지막 detail() 결과. */
|
|
950
|
-
private timerSeconds;
|
|
951
|
-
private timerKnown;
|
|
952
979
|
constructor(roomId: string, http: HttpClient, ws: ReconnectingWs, readDebounceMs: number, opts?: {
|
|
953
980
|
cacheStore?: CacheStore;
|
|
954
981
|
cachePolicy?: CachePolicy;
|
|
@@ -1150,6 +1177,8 @@ declare class Room {
|
|
|
1150
1177
|
items: MessageOut[];
|
|
1151
1178
|
nextCursor: MessageIdStr | null;
|
|
1152
1179
|
hasMore: boolean;
|
|
1180
|
+
/** 히스토리 하한(unix ms). null=컷 없음 — 재입장 컷·대화 지우기가 수렴. */
|
|
1181
|
+
historyFrom: number | null;
|
|
1153
1182
|
}>;
|
|
1154
1183
|
/** 이 방의 상세 정보 (`GET /rooms/{id}`) — 이름·멤버 수·삭제 타이머 등. */
|
|
1155
1184
|
detail(): Promise<RoomOut>;
|
|
@@ -1166,11 +1195,12 @@ declare class Room {
|
|
|
1166
1195
|
items: MessageOut[];
|
|
1167
1196
|
nextCursor: MessageIdStr | null;
|
|
1168
1197
|
hasMore: boolean;
|
|
1198
|
+
historyFrom: number | null;
|
|
1169
1199
|
} | null>;
|
|
1170
1200
|
/**
|
|
1171
|
-
* 최신 페이지 스냅샷 저장. 삭제
|
|
1172
|
-
*
|
|
1173
|
-
*
|
|
1201
|
+
* 최신 페이지 스냅샷 저장. 삭제 타이머 방도 저장한다 — 서버가 만료를
|
|
1202
|
+
* 일반 `message_deleted` 프레임으로 브로드캐스트하고 히스토리에서도
|
|
1203
|
+
* 즉시 제외하므로, 만료분은 라이브 이벤트/`history_from` diff로 정리된다.
|
|
1174
1204
|
*/
|
|
1175
1205
|
private cacheWriteRecent;
|
|
1176
1206
|
/**
|
|
@@ -1367,6 +1397,16 @@ declare class NoveraChat {
|
|
|
1367
1397
|
private readDebounceMs;
|
|
1368
1398
|
private logger;
|
|
1369
1399
|
private connectedOnce;
|
|
1400
|
+
/**
|
|
1401
|
+
* 방별 "실시간 파이프라인으로 본 최대 message_id" — 재연결 핸드셰이크의
|
|
1402
|
+
* `?since=`와 backfill 시작점.
|
|
1403
|
+
*
|
|
1404
|
+
* **의도적으로 낮게 유지한다.** 히스토리 로드(REST)는 이 값을 올리지
|
|
1405
|
+
* 않는다 — 앵커가 낮으면 backfill이 이미 가진 것까지 다시 주는 쪽으로
|
|
1406
|
+
* (안전), 높으면 못 받은 것을 영영 건너뛰는 쪽으로(치명) 실패하기
|
|
1407
|
+
* 때문이다. 중복은 수신 측(`MessageCollection.onIncoming`)의 id dedupe가
|
|
1408
|
+
* 흡수하므로, "빠뜨린 갱신"으로 보고 올리지 말 것.
|
|
1409
|
+
*/
|
|
1370
1410
|
private lastMessageIdByRoom;
|
|
1371
1411
|
/** Most recently dispatched presence event key, `${userId}:${at}:${online}`.
|
|
1372
1412
|
* Used to drop the N-1 shared-room copies of a single online/offline
|
package/dist/index.d.ts
CHANGED
|
@@ -225,6 +225,7 @@ interface UnreadRoomItem {
|
|
|
225
225
|
lastMessageId?: string | null;
|
|
226
226
|
lastReadMessageId?: string | null;
|
|
227
227
|
lastMessagePreview?: string | null;
|
|
228
|
+
lastMessageAt?: number | null;
|
|
228
229
|
memberCount?: number;
|
|
229
230
|
membersPreview?: MemberPreview[];
|
|
230
231
|
}
|
|
@@ -380,11 +381,12 @@ type PushTrigger = "ALL" | "MENTION_ONLY" | "OFF";
|
|
|
380
381
|
*
|
|
381
382
|
* 원칙:
|
|
382
383
|
* 1. 캐시 = 렌더 힌트, 서버가 항상 이긴다 — 시작 시 스냅샷 즉시 렌더,
|
|
383
|
-
* API 도착 시 통째 교체(
|
|
384
|
+
* API 도착 시 기본 통째 교체(replaceByApi). 옵트인 mergeDiff는
|
|
385
|
+
* fresh보다 오래된 메시지만 보존하는 병합(서버를 덮어쓰지 않음).
|
|
384
386
|
* 2. 저장소는 SDK가 정하지 않는다 — `CacheStore` 인터페이스만 정의,
|
|
385
387
|
* 구현은 앱이 주입(localStorage/IndexedDB 등).
|
|
386
388
|
* 3. 버전 스탬프 + 통째 폐기 — 키에 스키마 버전(`nc.v1.…`), 파싱 실패
|
|
387
|
-
* 시 해당 키를 드랍.
|
|
389
|
+
* 시 해당 키를 드랍.
|
|
388
390
|
*/
|
|
389
391
|
/**
|
|
390
392
|
* 앱이 주입하는 key-value 스냅샷 저장소.
|
|
@@ -417,9 +419,10 @@ interface CacheStore {
|
|
|
417
419
|
/**
|
|
418
420
|
* 캐시 스냅샷과 API 응답을 어떻게 합칠지.
|
|
419
421
|
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
422
|
+
* - `"replaceByApi"` (기본) — 스냅샷 즉시 렌더 후 API 도착 시 통째 교체.
|
|
423
|
+
* - `"mergeDiff"` — fresh 페이지보다 오래된 캐시 메시지를 보존·병합해 더
|
|
424
|
+
* 긴 연속 히스토리 유지. `history_from`으로 컷오프분을, fresh 대조로
|
|
425
|
+
* 삭제분을 걸러내고, 캐시·fresh 사이 갭(겹침 없음)이면 통째 교체 폴백.
|
|
423
426
|
*/
|
|
424
427
|
type CachePolicy = "replaceByApi" | "mergeDiff";
|
|
425
428
|
/**
|
|
@@ -597,7 +600,8 @@ interface ClientOptions {
|
|
|
597
600
|
* 렌더에 쓸 수 있다. 자세한 건 `CacheStore` 문서 참조.
|
|
598
601
|
*/
|
|
599
602
|
cacheStore?: CacheStore;
|
|
600
|
-
/** 캐시 스냅샷과 API 응답의 합성 방식.
|
|
603
|
+
/** 캐시 스냅샷과 API 응답의 합성 방식. 기본 `"replaceByApi"`(통째 교체),
|
|
604
|
+
* `"mergeDiff"`는 옵트인 — 옛 캐시 메시지를 보존·병합. */
|
|
601
605
|
cachePolicy?: CachePolicy;
|
|
602
606
|
autoReconnect?: boolean;
|
|
603
607
|
pingIntervalMs?: number;
|
|
@@ -760,8 +764,8 @@ declare function pendingFileChatMessage(p: {
|
|
|
760
764
|
* RoomStore(sdk-react)에 있던 이벤트 반영·낙관적 전송·캐시 hydrate→교체
|
|
761
765
|
* 로직의 코어 이관본. 바인딩(React·Flutter)은 이 컬렉션을 감싸는 얇은
|
|
762
766
|
* 접착층이 되고, "캐시는 렌더 힌트, 서버가 항상 이긴다" 같은 불변식은
|
|
763
|
-
* 코어가 강제한다.
|
|
764
|
-
*
|
|
767
|
+
* 코어가 강제한다. diff 병합(mergeDiff)은 `applyFresh`→`mergeFresh`로
|
|
768
|
+
* 들어온다.
|
|
765
769
|
*/
|
|
766
770
|
|
|
767
771
|
/**
|
|
@@ -854,11 +858,26 @@ declare class MessageCollection {
|
|
|
854
858
|
* 힌트일 뿐). */
|
|
855
859
|
private hydrateFromCache;
|
|
856
860
|
/**
|
|
857
|
-
* API 최신 페이지를 컬렉션에
|
|
858
|
-
* replaceByApi(
|
|
859
|
-
*
|
|
861
|
+
* API 최신 페이지를 컬렉션에 반영한다.
|
|
862
|
+
* replaceByApi(기본)는 스냅샷을 걷어내고 통째 교체하고, mergeDiff는
|
|
863
|
+
* 스냅샷의 옛 메시지를 보존·병합한다(`mergeFresh`).
|
|
860
864
|
*/
|
|
861
865
|
private applyFresh;
|
|
866
|
+
/**
|
|
867
|
+
* diff 병합 — 스냅샷의 옛 메시지를 보존해 fresh 페이지 아래에 잇는다.
|
|
868
|
+
*
|
|
869
|
+
* 판정 규칙(서버 계약, 07-23 확정):
|
|
870
|
+
* - **컷오프분**: `history_from`(히스토리 하한, unix ms)보다 오래된 캐시
|
|
871
|
+
* 메시지는 재입장 컷/대화 지우기로 볼 수 없게 된 것 → 폐기.
|
|
872
|
+
* - **삭제분**: fresh 커버 구간(id ≥ fresh 최저 id)에 있는데 fresh에
|
|
873
|
+
* 없는 캐시 메시지는 그 사이 삭제된 것(타이머 만료 포함) → 폐기.
|
|
874
|
+
* - **갭 폴백**: 캐시의 최신 메시지가 fresh와 겹치지 않으면 누락 구간이
|
|
875
|
+
* 있을 수 있으므로 병합하지 않고 통째 교체.
|
|
876
|
+
*
|
|
877
|
+
* 겹치는 구간은 항상 fresh가 정답(수정 반영) — 보존되는 건 fresh 범위
|
|
878
|
+
* **밖**의 옛 메시지뿐이라 "서버가 항상 이긴다" 불변식은 유지된다.
|
|
879
|
+
*/
|
|
880
|
+
private mergeFresh;
|
|
862
881
|
/**
|
|
863
882
|
* Send `text` optimistically: a `sending` bubble appears immediately (one
|
|
864
883
|
* microtask later — `Room.send` is async), then flips to `sent` (with the
|
|
@@ -893,6 +912,17 @@ declare class MessageCollection {
|
|
|
893
912
|
/** Unsubscribe from the room and flush the pending read watermark. The
|
|
894
913
|
* collection must not be used afterwards. */
|
|
895
914
|
dispose(): void;
|
|
915
|
+
/**
|
|
916
|
+
* 수신 프레임을 컬렉션에 넣는다.
|
|
917
|
+
*
|
|
918
|
+
* 두 가지를 방어한다 — backfill(`chat.backfill()`)은 재연결 시 앵커
|
|
919
|
+
* 이후를 **다시** 실어오므로, 이미 들고 있는 메시지가 섞여 오고 화면보다
|
|
920
|
+
* 오래된 메시지도 함께 온다:
|
|
921
|
+
* - **중복**: 같은 id가 이미 있으면 무시한다.
|
|
922
|
+
* - **순서**: 뒤에서부터 자리를 찾아 꽂는다. 실시간 프레임은 항상 마지막
|
|
923
|
+
* 메시지보다 새로우므로 비교 1번으로 끝나고(빠른 경로), 옛 메시지가
|
|
924
|
+
* 섞여 올 때만 탐색이 일어난다.
|
|
925
|
+
*/
|
|
896
926
|
private onIncoming;
|
|
897
927
|
private onUpdated;
|
|
898
928
|
private onDeleted;
|
|
@@ -946,9 +976,6 @@ declare class Room {
|
|
|
946
976
|
private readonly cacheStore;
|
|
947
977
|
private readonly cachePolicy;
|
|
948
978
|
private readonly logger;
|
|
949
|
-
/** 삭제 타이머 여부 세션 메모 — null=미확인, 값=마지막 detail() 결과. */
|
|
950
|
-
private timerSeconds;
|
|
951
|
-
private timerKnown;
|
|
952
979
|
constructor(roomId: string, http: HttpClient, ws: ReconnectingWs, readDebounceMs: number, opts?: {
|
|
953
980
|
cacheStore?: CacheStore;
|
|
954
981
|
cachePolicy?: CachePolicy;
|
|
@@ -1150,6 +1177,8 @@ declare class Room {
|
|
|
1150
1177
|
items: MessageOut[];
|
|
1151
1178
|
nextCursor: MessageIdStr | null;
|
|
1152
1179
|
hasMore: boolean;
|
|
1180
|
+
/** 히스토리 하한(unix ms). null=컷 없음 — 재입장 컷·대화 지우기가 수렴. */
|
|
1181
|
+
historyFrom: number | null;
|
|
1153
1182
|
}>;
|
|
1154
1183
|
/** 이 방의 상세 정보 (`GET /rooms/{id}`) — 이름·멤버 수·삭제 타이머 등. */
|
|
1155
1184
|
detail(): Promise<RoomOut>;
|
|
@@ -1166,11 +1195,12 @@ declare class Room {
|
|
|
1166
1195
|
items: MessageOut[];
|
|
1167
1196
|
nextCursor: MessageIdStr | null;
|
|
1168
1197
|
hasMore: boolean;
|
|
1198
|
+
historyFrom: number | null;
|
|
1169
1199
|
} | null>;
|
|
1170
1200
|
/**
|
|
1171
|
-
* 최신 페이지 스냅샷 저장. 삭제
|
|
1172
|
-
*
|
|
1173
|
-
*
|
|
1201
|
+
* 최신 페이지 스냅샷 저장. 삭제 타이머 방도 저장한다 — 서버가 만료를
|
|
1202
|
+
* 일반 `message_deleted` 프레임으로 브로드캐스트하고 히스토리에서도
|
|
1203
|
+
* 즉시 제외하므로, 만료분은 라이브 이벤트/`history_from` diff로 정리된다.
|
|
1174
1204
|
*/
|
|
1175
1205
|
private cacheWriteRecent;
|
|
1176
1206
|
/**
|
|
@@ -1367,6 +1397,16 @@ declare class NoveraChat {
|
|
|
1367
1397
|
private readDebounceMs;
|
|
1368
1398
|
private logger;
|
|
1369
1399
|
private connectedOnce;
|
|
1400
|
+
/**
|
|
1401
|
+
* 방별 "실시간 파이프라인으로 본 최대 message_id" — 재연결 핸드셰이크의
|
|
1402
|
+
* `?since=`와 backfill 시작점.
|
|
1403
|
+
*
|
|
1404
|
+
* **의도적으로 낮게 유지한다.** 히스토리 로드(REST)는 이 값을 올리지
|
|
1405
|
+
* 않는다 — 앵커가 낮으면 backfill이 이미 가진 것까지 다시 주는 쪽으로
|
|
1406
|
+
* (안전), 높으면 못 받은 것을 영영 건너뛰는 쪽으로(치명) 실패하기
|
|
1407
|
+
* 때문이다. 중복은 수신 측(`MessageCollection.onIncoming`)의 id dedupe가
|
|
1408
|
+
* 흡수하므로, "빠뜨린 갱신"으로 보고 올리지 말 것.
|
|
1409
|
+
*/
|
|
1370
1410
|
private lastMessageIdByRoom;
|
|
1371
1411
|
/** Most recently dispatched presence event key, `${userId}:${at}:${online}`.
|
|
1372
1412
|
* Used to drop the N-1 shared-room copies of a single online/offline
|
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
|
};
|
|
@@ -777,23 +778,71 @@ var MessageCollection = class {
|
|
|
777
778
|
this.emit("hydrated");
|
|
778
779
|
}
|
|
779
780
|
/**
|
|
780
|
-
* API 최신 페이지를 컬렉션에
|
|
781
|
-
* replaceByApi(
|
|
782
|
-
*
|
|
781
|
+
* API 최신 페이지를 컬렉션에 반영한다.
|
|
782
|
+
* replaceByApi(기본)는 스냅샷을 걷어내고 통째 교체하고, mergeDiff는
|
|
783
|
+
* 스냅샷의 옛 메시지를 보존·병합한다(`mergeFresh`).
|
|
783
784
|
*/
|
|
784
785
|
applyFresh(page) {
|
|
785
|
-
if (this.policy === "mergeDiff") {
|
|
786
|
-
|
|
786
|
+
if (this.policy === "mergeDiff" && this.hydratedCount > 0) {
|
|
787
|
+
this.mergeFresh(page);
|
|
788
|
+
return;
|
|
787
789
|
}
|
|
788
790
|
if (this.hydratedCount > 0) {
|
|
789
791
|
this._messages.splice(0, this.hydratedCount);
|
|
790
792
|
this.hydratedCount = 0;
|
|
791
793
|
}
|
|
792
|
-
|
|
794
|
+
const indexById = /* @__PURE__ */ new Map();
|
|
795
|
+
this._messages.forEach((m, i) => indexById.set(m.id, i));
|
|
796
|
+
const fresh = [];
|
|
797
|
+
for (const item of page.items.map(chatMessageFromHistory)) {
|
|
798
|
+
const at = indexById.get(item.id);
|
|
799
|
+
if (at === void 0) fresh.push(item);
|
|
800
|
+
else this._messages[at] = item;
|
|
801
|
+
}
|
|
802
|
+
this._messages.unshift(...fresh);
|
|
793
803
|
this.oldestCursor = page.nextCursor;
|
|
794
804
|
this._hasMore = page.hasMore;
|
|
795
805
|
this.emit("replaced");
|
|
796
806
|
}
|
|
807
|
+
/**
|
|
808
|
+
* diff 병합 — 스냅샷의 옛 메시지를 보존해 fresh 페이지 아래에 잇는다.
|
|
809
|
+
*
|
|
810
|
+
* 판정 규칙(서버 계약, 07-23 확정):
|
|
811
|
+
* - **컷오프분**: `history_from`(히스토리 하한, unix ms)보다 오래된 캐시
|
|
812
|
+
* 메시지는 재입장 컷/대화 지우기로 볼 수 없게 된 것 → 폐기.
|
|
813
|
+
* - **삭제분**: fresh 커버 구간(id ≥ fresh 최저 id)에 있는데 fresh에
|
|
814
|
+
* 없는 캐시 메시지는 그 사이 삭제된 것(타이머 만료 포함) → 폐기.
|
|
815
|
+
* - **갭 폴백**: 캐시의 최신 메시지가 fresh와 겹치지 않으면 누락 구간이
|
|
816
|
+
* 있을 수 있으므로 병합하지 않고 통째 교체.
|
|
817
|
+
*
|
|
818
|
+
* 겹치는 구간은 항상 fresh가 정답(수정 반영) — 보존되는 건 fresh 범위
|
|
819
|
+
* **밖**의 옛 메시지뿐이라 "서버가 항상 이긴다" 불변식은 유지된다.
|
|
820
|
+
*/
|
|
821
|
+
mergeFresh(page) {
|
|
822
|
+
const cachedHead = this._messages.slice(0, this.hydratedCount);
|
|
823
|
+
this._messages.splice(0, this.hydratedCount);
|
|
824
|
+
this.hydratedCount = 0;
|
|
825
|
+
const freshIds = new Set(page.items.map((m) => m.messageId));
|
|
826
|
+
const freshOldest = page.items.length > 0 ? page.items[0].messageId : null;
|
|
827
|
+
const hf = page.historyFrom ?? null;
|
|
828
|
+
const overlaps = cachedHead.length > 0 && freshOldest != null && freshIds.has(cachedHead.at(-1).id);
|
|
829
|
+
let preserved = [];
|
|
830
|
+
if (overlaps && page.hasMore) {
|
|
831
|
+
preserved = cachedHead.filter((m) => {
|
|
832
|
+
if (freshIds.has(m.id)) return false;
|
|
833
|
+
if (!idGreater(freshOldest, m.id)) return false;
|
|
834
|
+
if (hf != null && m.createdAtMs != null && m.createdAtMs < hf) {
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
return true;
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
this._messages.unshift(...page.items.map(chatMessageFromHistory));
|
|
841
|
+
this._messages.unshift(...preserved);
|
|
842
|
+
this.oldestCursor = preserved.length > 0 ? preserved[0].id : page.nextCursor;
|
|
843
|
+
this._hasMore = page.hasMore;
|
|
844
|
+
this.emit("replaced");
|
|
845
|
+
}
|
|
797
846
|
// ── optimistic sends ────────────────────────────────────────────
|
|
798
847
|
/**
|
|
799
848
|
* Send `text` optimistically: a `sending` bubble appears immediately (one
|
|
@@ -921,8 +970,26 @@ var MessageCollection = class {
|
|
|
921
970
|
this.bus.removeAll();
|
|
922
971
|
}
|
|
923
972
|
// ── event handlers ──────────────────────────────────────────────
|
|
973
|
+
/**
|
|
974
|
+
* 수신 프레임을 컬렉션에 넣는다.
|
|
975
|
+
*
|
|
976
|
+
* 두 가지를 방어한다 — backfill(`chat.backfill()`)은 재연결 시 앵커
|
|
977
|
+
* 이후를 **다시** 실어오므로, 이미 들고 있는 메시지가 섞여 오고 화면보다
|
|
978
|
+
* 오래된 메시지도 함께 온다:
|
|
979
|
+
* - **중복**: 같은 id가 이미 있으면 무시한다.
|
|
980
|
+
* - **순서**: 뒤에서부터 자리를 찾아 꽂는다. 실시간 프레임은 항상 마지막
|
|
981
|
+
* 메시지보다 새로우므로 비교 1번으로 끝나고(빠른 경로), 옛 메시지가
|
|
982
|
+
* 섞여 올 때만 탐색이 일어난다.
|
|
983
|
+
*/
|
|
924
984
|
onIncoming(f) {
|
|
925
|
-
|
|
985
|
+
const msg = chatMessageFromLive(f);
|
|
986
|
+
let i = this._messages.length - 1;
|
|
987
|
+
for (; i >= 0; i--) {
|
|
988
|
+
const cur = this._messages[i];
|
|
989
|
+
if (cur.id === msg.id) return;
|
|
990
|
+
if (!idGreater(cur.id, msg.id)) break;
|
|
991
|
+
}
|
|
992
|
+
this._messages.splice(i + 1, 0, msg);
|
|
926
993
|
this.emit("inserted");
|
|
927
994
|
}
|
|
928
995
|
onUpdated(f) {
|
|
@@ -1106,9 +1173,6 @@ var Room = class _Room {
|
|
|
1106
1173
|
cacheStore;
|
|
1107
1174
|
cachePolicy;
|
|
1108
1175
|
logger;
|
|
1109
|
-
/** 삭제 타이머 여부 세션 메모 — null=미확인, 값=마지막 detail() 결과. */
|
|
1110
|
-
timerSeconds = null;
|
|
1111
|
-
timerKnown = false;
|
|
1112
1176
|
/**
|
|
1113
1177
|
* 이 방의 메시지 상태를 소유하는 `MessageCollection`을 만든다.
|
|
1114
1178
|
*
|
|
@@ -1586,7 +1650,8 @@ var Room = class _Room {
|
|
|
1586
1650
|
items: raw.items.map(toMessageOut),
|
|
1587
1651
|
// 파싱 성공한 응답만 캐시에 넣는다
|
|
1588
1652
|
nextCursor: raw.next_cursor,
|
|
1589
|
-
hasMore: raw.has_more
|
|
1653
|
+
hasMore: raw.has_more,
|
|
1654
|
+
historyFrom: raw.history_from ?? null
|
|
1590
1655
|
};
|
|
1591
1656
|
void this.cacheWriteRecent(raw);
|
|
1592
1657
|
return page;
|
|
@@ -1597,10 +1662,7 @@ var Room = class _Room {
|
|
|
1597
1662
|
"GET",
|
|
1598
1663
|
`/api/v1/rooms/${this.roomId}`
|
|
1599
1664
|
);
|
|
1600
|
-
|
|
1601
|
-
this.timerSeconds = out.timerSeconds;
|
|
1602
|
-
this.timerKnown = true;
|
|
1603
|
-
return out;
|
|
1665
|
+
return parseRoomOut(raw);
|
|
1604
1666
|
}
|
|
1605
1667
|
/**
|
|
1606
1668
|
* 히스토리 최신 페이지의 캐시 스냅샷 — 마지막 `listRecent()` 성공 응답의
|
|
@@ -1622,7 +1684,8 @@ var Room = class _Room {
|
|
|
1622
1684
|
return {
|
|
1623
1685
|
items: j.items.map(toMessageOut),
|
|
1624
1686
|
nextCursor: j.next_cursor,
|
|
1625
|
-
hasMore: j.has_more
|
|
1687
|
+
hasMore: j.has_more,
|
|
1688
|
+
historyFrom: j.history_from ?? null
|
|
1626
1689
|
};
|
|
1627
1690
|
} catch (err) {
|
|
1628
1691
|
this.logger?.("warn", `cache_read_failed key=${key}`, err);
|
|
@@ -1634,20 +1697,15 @@ var Room = class _Room {
|
|
|
1634
1697
|
}
|
|
1635
1698
|
}
|
|
1636
1699
|
/**
|
|
1637
|
-
* 최신 페이지 스냅샷 저장. 삭제
|
|
1638
|
-
*
|
|
1639
|
-
*
|
|
1700
|
+
* 최신 페이지 스냅샷 저장. 삭제 타이머 방도 저장한다 — 서버가 만료를
|
|
1701
|
+
* 일반 `message_deleted` 프레임으로 브로드캐스트하고 히스토리에서도
|
|
1702
|
+
* 즉시 제외하므로, 만료분은 라이브 이벤트/`history_from` diff로 정리된다.
|
|
1640
1703
|
*/
|
|
1641
1704
|
async cacheWriteRecent(raw) {
|
|
1642
1705
|
const store = this.cacheStore;
|
|
1643
1706
|
if (!store) return;
|
|
1644
1707
|
const key = CacheKeys.msgs(this.roomId);
|
|
1645
1708
|
try {
|
|
1646
|
-
if (!this.timerKnown) await this.detail();
|
|
1647
|
-
if (this.timerSeconds != null) {
|
|
1648
|
-
await store.remove(key);
|
|
1649
|
-
return;
|
|
1650
|
-
}
|
|
1651
1709
|
await store.write(key, JSON.stringify(raw));
|
|
1652
1710
|
} catch (err) {
|
|
1653
1711
|
this.logger?.("warn", `cache_write_failed key=${key}`, err);
|
|
@@ -2041,11 +2099,6 @@ var NoveraChat = class {
|
|
|
2041
2099
|
if (!opts.appId) throw new Error("appId required");
|
|
2042
2100
|
if (!opts.endpoint) throw new Error("endpoint required");
|
|
2043
2101
|
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
|
-
}
|
|
2049
2102
|
this.readDebounceMs = opts.readWatermarkDebounceMs ?? 400;
|
|
2050
2103
|
this.logger = opts.logger;
|
|
2051
2104
|
if (opts.initialLastMessageIds) {
|
|
@@ -2101,6 +2154,16 @@ var NoveraChat = class {
|
|
|
2101
2154
|
readDebounceMs;
|
|
2102
2155
|
logger;
|
|
2103
2156
|
connectedOnce = false;
|
|
2157
|
+
/**
|
|
2158
|
+
* 방별 "실시간 파이프라인으로 본 최대 message_id" — 재연결 핸드셰이크의
|
|
2159
|
+
* `?since=`와 backfill 시작점.
|
|
2160
|
+
*
|
|
2161
|
+
* **의도적으로 낮게 유지한다.** 히스토리 로드(REST)는 이 값을 올리지
|
|
2162
|
+
* 않는다 — 앵커가 낮으면 backfill이 이미 가진 것까지 다시 주는 쪽으로
|
|
2163
|
+
* (안전), 높으면 못 받은 것을 영영 건너뛰는 쪽으로(치명) 실패하기
|
|
2164
|
+
* 때문이다. 중복은 수신 측(`MessageCollection.onIncoming`)의 id dedupe가
|
|
2165
|
+
* 흡수하므로, "빠뜨린 갱신"으로 보고 올리지 말 것.
|
|
2166
|
+
*/
|
|
2104
2167
|
lastMessageIdByRoom = /* @__PURE__ */ new Map();
|
|
2105
2168
|
/** Most recently dispatched presence event key, `${userId}:${at}:${online}`.
|
|
2106
2169
|
* Used to drop the N-1 shared-room copies of a single online/offline
|
package/package.json
CHANGED
package/src/cache.ts
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
*
|
|
5
5
|
* 원칙:
|
|
6
6
|
* 1. 캐시 = 렌더 힌트, 서버가 항상 이긴다 — 시작 시 스냅샷 즉시 렌더,
|
|
7
|
-
* API 도착 시 통째 교체(
|
|
7
|
+
* API 도착 시 기본 통째 교체(replaceByApi). 옵트인 mergeDiff는
|
|
8
|
+
* fresh보다 오래된 메시지만 보존하는 병합(서버를 덮어쓰지 않음).
|
|
8
9
|
* 2. 저장소는 SDK가 정하지 않는다 — `CacheStore` 인터페이스만 정의,
|
|
9
10
|
* 구현은 앱이 주입(localStorage/IndexedDB 등).
|
|
10
11
|
* 3. 버전 스탬프 + 통째 폐기 — 키에 스키마 버전(`nc.v1.…`), 파싱 실패
|
|
11
|
-
* 시 해당 키를 드랍.
|
|
12
|
+
* 시 해당 키를 드랍.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -43,9 +44,10 @@ export interface CacheStore {
|
|
|
43
44
|
/**
|
|
44
45
|
* 캐시 스냅샷과 API 응답을 어떻게 합칠지.
|
|
45
46
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
47
|
+
* - `"replaceByApi"` (기본) — 스냅샷 즉시 렌더 후 API 도착 시 통째 교체.
|
|
48
|
+
* - `"mergeDiff"` — fresh 페이지보다 오래된 캐시 메시지를 보존·병합해 더
|
|
49
|
+
* 긴 연속 히스토리 유지. `history_from`으로 컷오프분을, fresh 대조로
|
|
50
|
+
* 삭제분을 걸러내고, 캐시·fresh 사이 갭(겹침 없음)이면 통째 교체 폴백.
|
|
49
51
|
*/
|
|
50
52
|
export type CachePolicy = "replaceByApi" | "mergeDiff";
|
|
51
53
|
|
package/src/client.ts
CHANGED
|
@@ -57,6 +57,16 @@ export class NoveraChat {
|
|
|
57
57
|
private readDebounceMs: number;
|
|
58
58
|
private logger: ClientOptions["logger"];
|
|
59
59
|
private connectedOnce = false;
|
|
60
|
+
/**
|
|
61
|
+
* 방별 "실시간 파이프라인으로 본 최대 message_id" — 재연결 핸드셰이크의
|
|
62
|
+
* `?since=`와 backfill 시작점.
|
|
63
|
+
*
|
|
64
|
+
* **의도적으로 낮게 유지한다.** 히스토리 로드(REST)는 이 값을 올리지
|
|
65
|
+
* 않는다 — 앵커가 낮으면 backfill이 이미 가진 것까지 다시 주는 쪽으로
|
|
66
|
+
* (안전), 높으면 못 받은 것을 영영 건너뛰는 쪽으로(치명) 실패하기
|
|
67
|
+
* 때문이다. 중복은 수신 측(`MessageCollection.onIncoming`)의 id dedupe가
|
|
68
|
+
* 흡수하므로, "빠뜨린 갱신"으로 보고 올리지 말 것.
|
|
69
|
+
*/
|
|
60
70
|
private lastMessageIdByRoom = new Map<string, MessageIdStr>();
|
|
61
71
|
/** Most recently dispatched presence event key, `${userId}:${at}:${online}`.
|
|
62
72
|
* Used to drop the N-1 shared-room copies of a single online/offline
|
|
@@ -68,11 +78,6 @@ export class NoveraChat {
|
|
|
68
78
|
if (!opts.appId) throw new Error("appId required");
|
|
69
79
|
if (!opts.endpoint) throw new Error("endpoint required");
|
|
70
80
|
if (!opts.tokenProvider) throw new Error("tokenProvider required");
|
|
71
|
-
if (opts.cachePolicy === "mergeDiff") {
|
|
72
|
-
throw new Error(
|
|
73
|
-
'CachePolicy "mergeDiff" 는 아직 미구현 — 현재 "replaceByApi"만 지원',
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
81
|
|
|
77
82
|
// 400ms is the read-receipt-UX sweet spot for chat apps. Shorter and
|
|
78
83
|
// we waste WS frames on every micro-scroll; longer and the "✓ read"
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* RoomStore(sdk-react)에 있던 이벤트 반영·낙관적 전송·캐시 hydrate→교체
|
|
5
5
|
* 로직의 코어 이관본. 바인딩(React·Flutter)은 이 컬렉션을 감싸는 얇은
|
|
6
6
|
* 접착층이 되고, "캐시는 렌더 힌트, 서버가 항상 이긴다" 같은 불변식은
|
|
7
|
-
* 코어가 강제한다.
|
|
8
|
-
*
|
|
7
|
+
* 코어가 강제한다. diff 병합(mergeDiff)은 `applyFresh`→`mergeFresh`로
|
|
8
|
+
* 들어온다.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type {
|
|
@@ -205,28 +205,96 @@ export class MessageCollection {
|
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
/**
|
|
208
|
-
* API 최신 페이지를 컬렉션에
|
|
209
|
-
* replaceByApi(
|
|
210
|
-
*
|
|
208
|
+
* API 최신 페이지를 컬렉션에 반영한다.
|
|
209
|
+
* replaceByApi(기본)는 스냅샷을 걷어내고 통째 교체하고, mergeDiff는
|
|
210
|
+
* 스냅샷의 옛 메시지를 보존·병합한다(`mergeFresh`).
|
|
211
211
|
*/
|
|
212
212
|
private applyFresh(page: {
|
|
213
213
|
items: MessageOut[];
|
|
214
214
|
nextCursor: MessageIdStr | null;
|
|
215
215
|
hasMore: boolean;
|
|
216
|
+
historyFrom?: number | null;
|
|
216
217
|
}): void {
|
|
217
|
-
if (this.policy === "mergeDiff") {
|
|
218
|
-
|
|
218
|
+
if (this.policy === "mergeDiff" && this.hydratedCount > 0) {
|
|
219
|
+
this.mergeFresh(page);
|
|
220
|
+
return;
|
|
219
221
|
}
|
|
220
222
|
if (this.hydratedCount > 0) {
|
|
221
223
|
this._messages.splice(0, this.hydratedCount); // 스냅샷 통째 폐기 → 서버가 이긴다
|
|
222
224
|
this.hydratedCount = 0;
|
|
223
225
|
}
|
|
224
|
-
|
|
226
|
+
// id 기준 upsert — 이미 들고 있는 메시지는 서버 버전으로 교체하고, 없는
|
|
227
|
+
// 것만 앞에 잇는다. `load()`가 두 번 불려도(React StrictMode의 mount →
|
|
228
|
+
// cleanup → mount 사이클처럼) 같은 페이지가 겹쳐 쌓이지 않는다.
|
|
229
|
+
const indexById = new Map<string, number>();
|
|
230
|
+
this._messages.forEach((m, i) => indexById.set(m.id, i));
|
|
231
|
+
const fresh: ChatMessage[] = [];
|
|
232
|
+
for (const item of page.items.map(chatMessageFromHistory)) {
|
|
233
|
+
const at = indexById.get(item.id);
|
|
234
|
+
if (at === undefined) fresh.push(item);
|
|
235
|
+
else this._messages[at] = item; // 겹치는 구간은 서버가 이긴다
|
|
236
|
+
}
|
|
237
|
+
this._messages.unshift(...fresh);
|
|
225
238
|
this.oldestCursor = page.nextCursor;
|
|
226
239
|
this._hasMore = page.hasMore;
|
|
227
240
|
this.emit("replaced");
|
|
228
241
|
}
|
|
229
242
|
|
|
243
|
+
/**
|
|
244
|
+
* diff 병합 — 스냅샷의 옛 메시지를 보존해 fresh 페이지 아래에 잇는다.
|
|
245
|
+
*
|
|
246
|
+
* 판정 규칙(서버 계약, 07-23 확정):
|
|
247
|
+
* - **컷오프분**: `history_from`(히스토리 하한, unix ms)보다 오래된 캐시
|
|
248
|
+
* 메시지는 재입장 컷/대화 지우기로 볼 수 없게 된 것 → 폐기.
|
|
249
|
+
* - **삭제분**: fresh 커버 구간(id ≥ fresh 최저 id)에 있는데 fresh에
|
|
250
|
+
* 없는 캐시 메시지는 그 사이 삭제된 것(타이머 만료 포함) → 폐기.
|
|
251
|
+
* - **갭 폴백**: 캐시의 최신 메시지가 fresh와 겹치지 않으면 누락 구간이
|
|
252
|
+
* 있을 수 있으므로 병합하지 않고 통째 교체.
|
|
253
|
+
*
|
|
254
|
+
* 겹치는 구간은 항상 fresh가 정답(수정 반영) — 보존되는 건 fresh 범위
|
|
255
|
+
* **밖**의 옛 메시지뿐이라 "서버가 항상 이긴다" 불변식은 유지된다.
|
|
256
|
+
*/
|
|
257
|
+
private mergeFresh(page: {
|
|
258
|
+
items: MessageOut[];
|
|
259
|
+
nextCursor: MessageIdStr | null;
|
|
260
|
+
hasMore: boolean;
|
|
261
|
+
historyFrom?: number | null;
|
|
262
|
+
}): void {
|
|
263
|
+
const cachedHead = this._messages.slice(0, this.hydratedCount);
|
|
264
|
+
this._messages.splice(0, this.hydratedCount);
|
|
265
|
+
this.hydratedCount = 0;
|
|
266
|
+
|
|
267
|
+
const freshIds = new Set(page.items.map((m) => m.messageId));
|
|
268
|
+
const freshOldest = page.items.length > 0 ? page.items[0]!.messageId : null;
|
|
269
|
+
const hf = page.historyFrom ?? null;
|
|
270
|
+
|
|
271
|
+
// 갭 폴백: 캐시 최신이 fresh에 없으면 연속성 보장 불가 → 통째 교체.
|
|
272
|
+
// fresh가 방 전체(hasMore=false)인데 범위 밖 캐시가 남는 경우도 유령이므로 교체.
|
|
273
|
+
const overlaps =
|
|
274
|
+
cachedHead.length > 0 &&
|
|
275
|
+
freshOldest != null &&
|
|
276
|
+
freshIds.has(cachedHead.at(-1)!.id);
|
|
277
|
+
let preserved: ChatMessage[] = [];
|
|
278
|
+
if (overlaps && page.hasMore) {
|
|
279
|
+
preserved = cachedHead.filter((m) => {
|
|
280
|
+
if (freshIds.has(m.id)) return false; // 겹침 → fresh가 대체
|
|
281
|
+
if (!idGreater(freshOldest, m.id)) return false; // fresh 구간 내 부재 → 삭제분
|
|
282
|
+
if (hf != null && m.createdAtMs != null && m.createdAtMs < hf) {
|
|
283
|
+
return false; // 컷오프분
|
|
284
|
+
}
|
|
285
|
+
return true;
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
this._messages.unshift(...page.items.map(chatMessageFromHistory));
|
|
290
|
+
this._messages.unshift(...preserved);
|
|
291
|
+
// 보존분이 있으면 스크롤백 커서를 보존분의 최저 id로 — 중복 로드 방지.
|
|
292
|
+
this.oldestCursor =
|
|
293
|
+
preserved.length > 0 ? preserved[0]!.id : page.nextCursor;
|
|
294
|
+
this._hasMore = page.hasMore;
|
|
295
|
+
this.emit("replaced");
|
|
296
|
+
}
|
|
297
|
+
|
|
230
298
|
// ── optimistic sends ────────────────────────────────────────────
|
|
231
299
|
|
|
232
300
|
/**
|
|
@@ -382,8 +450,26 @@ export class MessageCollection {
|
|
|
382
450
|
|
|
383
451
|
// ── event handlers ──────────────────────────────────────────────
|
|
384
452
|
|
|
453
|
+
/**
|
|
454
|
+
* 수신 프레임을 컬렉션에 넣는다.
|
|
455
|
+
*
|
|
456
|
+
* 두 가지를 방어한다 — backfill(`chat.backfill()`)은 재연결 시 앵커
|
|
457
|
+
* 이후를 **다시** 실어오므로, 이미 들고 있는 메시지가 섞여 오고 화면보다
|
|
458
|
+
* 오래된 메시지도 함께 온다:
|
|
459
|
+
* - **중복**: 같은 id가 이미 있으면 무시한다.
|
|
460
|
+
* - **순서**: 뒤에서부터 자리를 찾아 꽂는다. 실시간 프레임은 항상 마지막
|
|
461
|
+
* 메시지보다 새로우므로 비교 1번으로 끝나고(빠른 경로), 옛 메시지가
|
|
462
|
+
* 섞여 올 때만 탐색이 일어난다.
|
|
463
|
+
*/
|
|
385
464
|
private onIncoming(f: WsChatReceive): void {
|
|
386
|
-
|
|
465
|
+
const msg = chatMessageFromLive(f);
|
|
466
|
+
let i = this._messages.length - 1;
|
|
467
|
+
for (; i >= 0; i--) {
|
|
468
|
+
const cur = this._messages[i]!;
|
|
469
|
+
if (cur.id === msg.id) return; // 이미 있음 — 재전송분
|
|
470
|
+
if (!idGreater(cur.id, msg.id)) break; // 여기가 삽입 자리
|
|
471
|
+
}
|
|
472
|
+
this._messages.splice(i + 1, 0, msg);
|
|
387
473
|
this.emit("inserted");
|
|
388
474
|
}
|
|
389
475
|
|
package/src/features/room.ts
CHANGED
|
@@ -205,10 +205,6 @@ export class Room {
|
|
|
205
205
|
private readonly cacheStore: CacheStore | null;
|
|
206
206
|
private readonly cachePolicy: CachePolicy;
|
|
207
207
|
private readonly logger: ClientOptions["logger"];
|
|
208
|
-
/** 삭제 타이머 여부 세션 메모 — null=미확인, 값=마지막 detail() 결과. */
|
|
209
|
-
private timerSeconds: number | null = null;
|
|
210
|
-
private timerKnown = false;
|
|
211
|
-
|
|
212
208
|
constructor(
|
|
213
209
|
public readonly roomId: string,
|
|
214
210
|
private http: HttpClient,
|
|
@@ -827,14 +823,18 @@ export class Room {
|
|
|
827
823
|
items: MessageOut[];
|
|
828
824
|
nextCursor: MessageIdStr | null;
|
|
829
825
|
hasMore: boolean;
|
|
826
|
+
/** 히스토리 하한(unix ms). null=컷 없음 — 재입장 컷·대화 지우기가 수렴. */
|
|
827
|
+
historyFrom: number | null;
|
|
830
828
|
}> {
|
|
831
829
|
const raw = await this.http.request<{
|
|
832
830
|
items: unknown[]; next_cursor: string | null; has_more: boolean;
|
|
831
|
+
history_from?: number | null;
|
|
833
832
|
}>("GET", `/api/v1/rooms/${this.roomId}/messages`, undefined, { limit });
|
|
834
833
|
const page = {
|
|
835
834
|
items: raw.items.map(toMessageOut), // 파싱 성공한 응답만 캐시에 넣는다
|
|
836
835
|
nextCursor: raw.next_cursor,
|
|
837
836
|
hasMore: raw.has_more,
|
|
837
|
+
historyFrom: raw.history_from ?? null,
|
|
838
838
|
};
|
|
839
839
|
void this.cacheWriteRecent(raw);
|
|
840
840
|
return page;
|
|
@@ -845,10 +845,7 @@ export class Room {
|
|
|
845
845
|
const raw = await this.http.request<unknown>(
|
|
846
846
|
"GET", `/api/v1/rooms/${this.roomId}`,
|
|
847
847
|
);
|
|
848
|
-
|
|
849
|
-
this.timerSeconds = out.timerSeconds;
|
|
850
|
-
this.timerKnown = true;
|
|
851
|
-
return out;
|
|
848
|
+
return parseRoomOut(raw);
|
|
852
849
|
}
|
|
853
850
|
|
|
854
851
|
/**
|
|
@@ -864,6 +861,7 @@ export class Room {
|
|
|
864
861
|
items: MessageOut[];
|
|
865
862
|
nextCursor: MessageIdStr | null;
|
|
866
863
|
hasMore: boolean;
|
|
864
|
+
historyFrom: number | null;
|
|
867
865
|
} | null> {
|
|
868
866
|
const store = this.cacheStore;
|
|
869
867
|
if (!store) return null;
|
|
@@ -873,11 +871,13 @@ export class Room {
|
|
|
873
871
|
if (raw == null) return null;
|
|
874
872
|
const j = JSON.parse(raw) as {
|
|
875
873
|
items: unknown[]; next_cursor: string | null; has_more: boolean;
|
|
874
|
+
history_from?: number | null;
|
|
876
875
|
};
|
|
877
876
|
return {
|
|
878
877
|
items: j.items.map(toMessageOut),
|
|
879
878
|
nextCursor: j.next_cursor,
|
|
880
879
|
hasMore: j.has_more,
|
|
880
|
+
historyFrom: j.history_from ?? null,
|
|
881
881
|
};
|
|
882
882
|
} catch (err) {
|
|
883
883
|
this.logger?.("warn", `cache_read_failed key=${key}`, err);
|
|
@@ -891,23 +891,17 @@ export class Room {
|
|
|
891
891
|
}
|
|
892
892
|
|
|
893
893
|
/**
|
|
894
|
-
* 최신 페이지 스냅샷 저장. 삭제
|
|
895
|
-
*
|
|
896
|
-
*
|
|
894
|
+
* 최신 페이지 스냅샷 저장. 삭제 타이머 방도 저장한다 — 서버가 만료를
|
|
895
|
+
* 일반 `message_deleted` 프레임으로 브로드캐스트하고 히스토리에서도
|
|
896
|
+
* 즉시 제외하므로, 만료분은 라이브 이벤트/`history_from` diff로 정리된다.
|
|
897
897
|
*/
|
|
898
898
|
private async cacheWriteRecent(raw: unknown): Promise<void> {
|
|
899
899
|
const store = this.cacheStore;
|
|
900
900
|
if (!store) return;
|
|
901
901
|
const key = CacheKeys.msgs(this.roomId);
|
|
902
902
|
try {
|
|
903
|
-
if (!this.timerKnown) await this.detail(); // 세션당 1회 — 이후는 메모 재사용
|
|
904
|
-
if (this.timerSeconds != null) {
|
|
905
|
-
await store.remove(key);
|
|
906
|
-
return;
|
|
907
|
-
}
|
|
908
903
|
await store.write(key, JSON.stringify(raw));
|
|
909
904
|
} catch (err) {
|
|
910
|
-
// 타이머 확인 실패 포함 — 확신 없으면 저장하지 않는 쪽으로 기운다.
|
|
911
905
|
this.logger?.("warn", `cache_write_failed key=${key}`, err);
|
|
912
906
|
}
|
|
913
907
|
}
|
package/src/generated/rest.ts
CHANGED
|
@@ -44,6 +44,7 @@ export interface UnreadRoomItem {
|
|
|
44
44
|
lastMessageId?: string | null;
|
|
45
45
|
lastReadMessageId?: string | null;
|
|
46
46
|
lastMessagePreview?: string | null;
|
|
47
|
+
lastMessageAt?: number | null;
|
|
47
48
|
memberCount?: number;
|
|
48
49
|
membersPreview?: MemberPreview[];
|
|
49
50
|
}
|
|
@@ -57,6 +58,7 @@ export function parseUnreadRoomItem(j: any): UnreadRoomItem {
|
|
|
57
58
|
lastMessageId: j["last_message_id"],
|
|
58
59
|
lastReadMessageId: j["last_read_message_id"],
|
|
59
60
|
lastMessagePreview: j["last_message_preview"],
|
|
61
|
+
lastMessageAt: j["last_message_at"],
|
|
60
62
|
memberCount: j["member_count"],
|
|
61
63
|
membersPreview: j["members_preview"] == null ? undefined : (j["members_preview"] as unknown[]).map((e: any) => parseMemberPreview(e)),
|
|
62
64
|
} as UnreadRoomItem;
|
|
@@ -155,6 +157,7 @@ export interface MessageListResponse {
|
|
|
155
157
|
items: MessageOut[];
|
|
156
158
|
nextCursor?: string | null;
|
|
157
159
|
hasMore: boolean;
|
|
160
|
+
historyFrom?: number | null;
|
|
158
161
|
}
|
|
159
162
|
|
|
160
163
|
export function parseMessageListResponse(j: any): MessageListResponse {
|
|
@@ -162,6 +165,7 @@ export function parseMessageListResponse(j: any): MessageListResponse {
|
|
|
162
165
|
items: (j["items"] as unknown[]).map((e: any) => parseMessageOut(e)),
|
|
163
166
|
nextCursor: j["next_cursor"],
|
|
164
167
|
hasMore: j["has_more"],
|
|
168
|
+
historyFrom: j["history_from"],
|
|
165
169
|
} as MessageListResponse;
|
|
166
170
|
}
|
|
167
171
|
|
|
@@ -489,6 +493,20 @@ export function parseJoinRequestCreateResponse(j: any): JoinRequestCreateRespons
|
|
|
489
493
|
} as JoinRequestCreateResponse;
|
|
490
494
|
}
|
|
491
495
|
|
|
496
|
+
export interface RoomFilesResponse {
|
|
497
|
+
items: RoomFileItem[];
|
|
498
|
+
nextCursor?: string | null;
|
|
499
|
+
hasMore?: boolean;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export function parseRoomFilesResponse(j: any): RoomFilesResponse {
|
|
503
|
+
return {
|
|
504
|
+
items: (j["items"] as unknown[]).map((e: any) => parseRoomFileItem(e)),
|
|
505
|
+
nextCursor: j["next_cursor"],
|
|
506
|
+
hasMore: j["has_more"],
|
|
507
|
+
} as RoomFilesResponse;
|
|
508
|
+
}
|
|
509
|
+
|
|
492
510
|
/** Minimal sender identity — no friend/block/profile-url fluff. */
|
|
493
511
|
export interface SenderMini {
|
|
494
512
|
userId: string;
|
|
@@ -527,3 +545,49 @@ export type JoinPolicy = "OPEN" | "APPROVAL_REQUIRED";
|
|
|
527
545
|
export type MemberRole = "OPERATOR" | "MEMBER" | "KICKED";
|
|
528
546
|
|
|
529
547
|
export type PushTrigger = "ALL" | "MENTION_ONLY" | "OFF";
|
|
548
|
+
|
|
549
|
+
/** 방에서 공유된 파일 1건 — 전달(forward)이면 메시지마다 한 번씩. */
|
|
550
|
+
export interface RoomFileItem {
|
|
551
|
+
messageId: string;
|
|
552
|
+
senderId: string;
|
|
553
|
+
createdAtMs: number;
|
|
554
|
+
file: RoomFileInfo;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function parseRoomFileItem(j: any): RoomFileItem {
|
|
558
|
+
return {
|
|
559
|
+
messageId: j["message_id"],
|
|
560
|
+
senderId: j["sender_id"],
|
|
561
|
+
createdAtMs: j["created_at_ms"],
|
|
562
|
+
file: parseRoomFileInfo(j["file"]),
|
|
563
|
+
} as RoomFileItem;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** 한 파일의 메타 (rooms/{id}/files 항목의 file 필드). */
|
|
567
|
+
export interface RoomFileInfo {
|
|
568
|
+
id: string;
|
|
569
|
+
kind: string;
|
|
570
|
+
mime?: string | null;
|
|
571
|
+
sizeBytes?: number | null;
|
|
572
|
+
originalName?: string | null;
|
|
573
|
+
width?: number | null;
|
|
574
|
+
height?: number | null;
|
|
575
|
+
durationSec?: number | null;
|
|
576
|
+
thumbnailStatus?: string | null;
|
|
577
|
+
forwardBlocked?: boolean;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export function parseRoomFileInfo(j: any): RoomFileInfo {
|
|
581
|
+
return {
|
|
582
|
+
id: j["id"],
|
|
583
|
+
kind: j["kind"],
|
|
584
|
+
mime: j["mime"],
|
|
585
|
+
sizeBytes: j["size_bytes"],
|
|
586
|
+
originalName: j["original_name"],
|
|
587
|
+
width: j["width"],
|
|
588
|
+
height: j["height"],
|
|
589
|
+
durationSec: j["duration_sec"],
|
|
590
|
+
thumbnailStatus: j["thumbnail_status"],
|
|
591
|
+
forwardBlocked: j["forward_blocked"],
|
|
592
|
+
} as RoomFileInfo;
|
|
593
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -251,7 +251,8 @@ export interface ClientOptions {
|
|
|
251
251
|
* 렌더에 쓸 수 있다. 자세한 건 `CacheStore` 문서 참조.
|
|
252
252
|
*/
|
|
253
253
|
cacheStore?: CacheStore;
|
|
254
|
-
/** 캐시 스냅샷과 API 응답의 합성 방식.
|
|
254
|
+
/** 캐시 스냅샷과 API 응답의 합성 방식. 기본 `"replaceByApi"`(통째 교체),
|
|
255
|
+
* `"mergeDiff"`는 옵트인 — 옛 캐시 메시지를 보존·병합. */
|
|
255
256
|
cachePolicy?: CachePolicy;
|
|
256
257
|
autoReconnect?: boolean; // default true
|
|
257
258
|
pingIntervalMs?: number; // default 20_000
|