@noverachat/sdk-react 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +2 -0
- package/README.md +2 -0
- package/dist/index.cjs +51 -369
- package/dist/index.d.cts +28 -92
- package/dist/index.d.ts +28 -92
- package/dist/index.js +51 -364
- package/package.json +2 -2
- package/src/chat-message.ts +10 -208
- package/src/room-store.ts +57 -293
package/src/chat-message.ts
CHANGED
|
@@ -1,209 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
// ChatMessage 뷰모델은 코어(@noverachat/sdk-web)로 이동했다 —
|
|
2
|
+
// MessageCollection이 이 모양으로 상태를 소유하기 때문. 이 파일은 기존
|
|
3
|
+
// import 경로(`src/chat-message.ts`)를 깨지 않기 위한 전달 shim이다.
|
|
4
|
+
export {
|
|
5
|
+
chatMessageFromHistory,
|
|
6
|
+
chatMessageFromLive,
|
|
7
|
+
pendingChatMessage,
|
|
8
|
+
pendingFileChatMessage,
|
|
9
|
+
type ChatMessage,
|
|
10
|
+
type ChatMessageStatus,
|
|
7
11
|
} from "@noverachat/sdk-web";
|
|
8
|
-
|
|
9
|
-
/** Delivery status of an outgoing (optimistic) message. */
|
|
10
|
-
export type ChatMessageStatus = "sending" | "sent" | "failed";
|
|
11
|
-
|
|
12
|
-
// Snake_case inline file shape as it rides on WS frames. Not exported from
|
|
13
|
-
// the sdk-web index by name — derive it structurally from the frame type.
|
|
14
|
-
type FileInlineWire = NonNullable<WsChatReceive["file"]>;
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* A single message as the UI wants it — one uniform shape over the SDK's
|
|
18
|
-
* three sources: `MessageOut` (history/REST), `WsChatReceive` (live/WS), and
|
|
19
|
-
* an optimistic bubble we just sent.
|
|
20
|
-
*
|
|
21
|
-
* Treat instances as immutable — `RoomStore` evolves them by replacement,
|
|
22
|
-
* never mutation.
|
|
23
|
-
*/
|
|
24
|
-
export interface ChatMessage {
|
|
25
|
-
/** The server message id, or the `tempId` while still `sending`. */
|
|
26
|
-
id: string;
|
|
27
|
-
/** Author user id. `null` means the current user (an optimistic bubble). */
|
|
28
|
-
senderId: string | null;
|
|
29
|
-
content: string | null;
|
|
30
|
-
/** Creation time in unix milliseconds. */
|
|
31
|
-
createdAtMs: number | null;
|
|
32
|
-
status: ChatMessageStatus;
|
|
33
|
-
/** `TEXT | FILE | EMOTICON | ADMIN | SYSTEM` — UIs use this to render
|
|
34
|
-
* system/admin lines (centered notices) differently from chat bubbles. */
|
|
35
|
-
messageType: string;
|
|
36
|
-
/** Sender identity (nickname / profile image) when the server included it.
|
|
37
|
-
* Null for optimistic bubbles and live frames (which carry only
|
|
38
|
-
* `senderId`) — UIs typically resolve the profile from their member list. */
|
|
39
|
-
sender: SenderMini | null;
|
|
40
|
-
/** App-defined subtype (e.g. a message-priority convention). */
|
|
41
|
-
customType: string | null;
|
|
42
|
-
/** Snowflake id (string) of the attached file, when this is a file message. */
|
|
43
|
-
fileId: string | null;
|
|
44
|
-
/** Inline metadata of the attached file — enough to render a placeholder
|
|
45
|
-
* without a round-trip. May be null even when `fileId` is set; fetch via
|
|
46
|
-
* `chat.getFileMeta` then. */
|
|
47
|
-
file: FileInline | null;
|
|
48
|
-
/** Multi-file bundle (`file_group`). Null for single-file / text messages.
|
|
49
|
-
* Check `files?.length` first and fall back to the `file` singleton. */
|
|
50
|
-
files: FileInline[] | null;
|
|
51
|
-
/** Id of the message this one replies to, if any. */
|
|
52
|
-
replyToId: string | null;
|
|
53
|
-
/** Emoji reactions grouped by key. Kept live by `RoomStore` from the
|
|
54
|
-
* reaction WS events. */
|
|
55
|
-
reactions: ReactionEntry[];
|
|
56
|
-
/** Sender-attached custom metadata (`data._customMeta`). */
|
|
57
|
-
customMeta: Record<string, unknown> | null;
|
|
58
|
-
/** How many times the message was edited. `> 0` → show an "edited" mark. */
|
|
59
|
-
editedCount: number;
|
|
60
|
-
/** Upload progress in [0, 1] while an optimistic FILE message's bytes are
|
|
61
|
-
* still going up to S3. Null once committed (or for non-file messages). */
|
|
62
|
-
uploadProgress: number | null;
|
|
63
|
-
/** The optimistic temp id, kept for ack reconciliation. Null once confirmed. */
|
|
64
|
-
tempId: string | null;
|
|
65
|
-
isDeleted: boolean;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function fileInlineFromWire(w: FileInlineWire): FileInline {
|
|
69
|
-
return {
|
|
70
|
-
id: w.id,
|
|
71
|
-
kind: w.kind,
|
|
72
|
-
mime: w.mime,
|
|
73
|
-
size: w.size,
|
|
74
|
-
name: w.name ?? null,
|
|
75
|
-
width: w.width ?? null,
|
|
76
|
-
height: w.height ?? null,
|
|
77
|
-
durationSec: w.duration_sec ?? null,
|
|
78
|
-
thumbnailStatus: w.thumbnail_status,
|
|
79
|
-
forwardBlocked: Boolean(w.forward_blocked),
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Build a `ChatMessage` from server history (REST). */
|
|
84
|
-
export function chatMessageFromHistory(m: MessageOut): ChatMessage {
|
|
85
|
-
return {
|
|
86
|
-
id: m.messageId,
|
|
87
|
-
senderId: m.senderId ?? null,
|
|
88
|
-
content: m.content ?? null,
|
|
89
|
-
createdAtMs: m.createdAtMs,
|
|
90
|
-
status: "sent",
|
|
91
|
-
messageType: m.messageType,
|
|
92
|
-
sender: m.sender ?? null,
|
|
93
|
-
customType: m.customType ?? null,
|
|
94
|
-
fileId: m.fileId ?? null,
|
|
95
|
-
file: m.file ?? null,
|
|
96
|
-
files: m.files ?? null,
|
|
97
|
-
replyToId: m.replyToId ?? null,
|
|
98
|
-
reactions: m.reactions ?? [],
|
|
99
|
-
customMeta: m.customMeta ?? null,
|
|
100
|
-
editedCount: m.editedCount ?? 0,
|
|
101
|
-
uploadProgress: null,
|
|
102
|
-
tempId: null,
|
|
103
|
-
isDeleted: m.deletedAt != null,
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Build a `ChatMessage` from a live inbound frame (WS). */
|
|
108
|
-
export function chatMessageFromLive(f: WsChatReceive): ChatMessage {
|
|
109
|
-
const data = f.data ?? null;
|
|
110
|
-
const customMeta =
|
|
111
|
-
data && typeof data === "object" && "_customMeta" in data
|
|
112
|
-
? ((data as Record<string, unknown>)._customMeta as
|
|
113
|
-
| Record<string, unknown>
|
|
114
|
-
| null)
|
|
115
|
-
: null;
|
|
116
|
-
return {
|
|
117
|
-
id: f.message_id,
|
|
118
|
-
senderId: f.sender_id,
|
|
119
|
-
content: f.content ?? null,
|
|
120
|
-
createdAtMs: f.created_at,
|
|
121
|
-
status: "sent",
|
|
122
|
-
messageType: f.message_type,
|
|
123
|
-
sender: null,
|
|
124
|
-
customType: f.custom_type ?? null,
|
|
125
|
-
fileId: f.file_id ?? null,
|
|
126
|
-
file: f.file ? fileInlineFromWire(f.file) : null,
|
|
127
|
-
files: f.files ? f.files.map(fileInlineFromWire) : null,
|
|
128
|
-
replyToId: f.reply_to_id ?? null,
|
|
129
|
-
reactions: [],
|
|
130
|
-
customMeta,
|
|
131
|
-
editedCount: 0,
|
|
132
|
-
uploadProgress: null,
|
|
133
|
-
tempId: null,
|
|
134
|
-
isDeleted: false,
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** An optimistic bubble for a message we just sent, before the server acks. */
|
|
139
|
-
export function pendingChatMessage(p: {
|
|
140
|
-
tempId: string;
|
|
141
|
-
content: string;
|
|
142
|
-
replyToId?: string;
|
|
143
|
-
customType?: string;
|
|
144
|
-
customMeta?: Record<string, unknown>;
|
|
145
|
-
}): ChatMessage {
|
|
146
|
-
return {
|
|
147
|
-
id: p.tempId,
|
|
148
|
-
senderId: null,
|
|
149
|
-
content: p.content,
|
|
150
|
-
createdAtMs: Date.now(),
|
|
151
|
-
status: "sending",
|
|
152
|
-
messageType: "TEXT",
|
|
153
|
-
sender: null,
|
|
154
|
-
customType: p.customType ?? null,
|
|
155
|
-
fileId: null,
|
|
156
|
-
file: null,
|
|
157
|
-
files: null,
|
|
158
|
-
replyToId: p.replyToId ?? null,
|
|
159
|
-
reactions: [],
|
|
160
|
-
customMeta: p.customMeta ?? null,
|
|
161
|
-
editedCount: 0,
|
|
162
|
-
uploadProgress: null,
|
|
163
|
-
tempId: p.tempId,
|
|
164
|
-
isDeleted: false,
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/** An optimistic bubble for a FILE message whose bytes are still uploading.
|
|
169
|
-
* `uploadProgress` starts at 0 and is advanced by `RoomStore`. */
|
|
170
|
-
export function pendingFileChatMessage(p: {
|
|
171
|
-
tempId: string;
|
|
172
|
-
name: string;
|
|
173
|
-
mime?: string;
|
|
174
|
-
size?: number;
|
|
175
|
-
}): ChatMessage {
|
|
176
|
-
const mime = p.mime ?? "application/octet-stream";
|
|
177
|
-
return {
|
|
178
|
-
id: p.tempId,
|
|
179
|
-
senderId: null,
|
|
180
|
-
content: null,
|
|
181
|
-
createdAtMs: Date.now(),
|
|
182
|
-
status: "sending",
|
|
183
|
-
messageType: "FILE",
|
|
184
|
-
sender: null,
|
|
185
|
-
customType: null,
|
|
186
|
-
fileId: null,
|
|
187
|
-
file: {
|
|
188
|
-
id: "",
|
|
189
|
-
kind: mime.startsWith("image/")
|
|
190
|
-
? "image"
|
|
191
|
-
: mime.startsWith("video/")
|
|
192
|
-
? "video"
|
|
193
|
-
: "file",
|
|
194
|
-
mime,
|
|
195
|
-
size: p.size ?? 0,
|
|
196
|
-
name: p.name,
|
|
197
|
-
thumbnailStatus: "pending",
|
|
198
|
-
forwardBlocked: false,
|
|
199
|
-
},
|
|
200
|
-
files: null,
|
|
201
|
-
replyToId: null,
|
|
202
|
-
reactions: [],
|
|
203
|
-
customMeta: null,
|
|
204
|
-
editedCount: 0,
|
|
205
|
-
uploadProgress: 0,
|
|
206
|
-
tempId: p.tempId,
|
|
207
|
-
isDeleted: false,
|
|
208
|
-
};
|
|
209
|
-
}
|
package/src/room-store.ts
CHANGED
|
@@ -1,26 +1,12 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
DeleteScope,
|
|
3
|
+
MessageCollection,
|
|
3
4
|
NoveraChat,
|
|
4
|
-
ReactionEntry,
|
|
5
5
|
Room,
|
|
6
6
|
SendFileOptions,
|
|
7
|
-
WsChatReceive,
|
|
8
|
-
WsMessageDeleted,
|
|
9
|
-
WsMessagesCleared,
|
|
10
|
-
WsMessageUpdated,
|
|
11
|
-
WsReactionAdded,
|
|
12
|
-
WsReactionRemoved,
|
|
13
|
-
WsSyncTick,
|
|
14
7
|
} from "@noverachat/sdk-web";
|
|
15
8
|
|
|
16
|
-
import {
|
|
17
|
-
chatMessageFromHistory,
|
|
18
|
-
chatMessageFromLive,
|
|
19
|
-
pendingChatMessage,
|
|
20
|
-
pendingFileChatMessage,
|
|
21
|
-
type ChatMessage,
|
|
22
|
-
} from "./chat-message.js";
|
|
23
|
-
import { idGreater } from "./internal/compare-id.js";
|
|
9
|
+
import { type ChatMessage, chatMessageFromHistory } from "./chat-message.js";
|
|
24
10
|
|
|
25
11
|
/** Immutable snapshot handed to React via `useSyncExternalStore`. A new
|
|
26
12
|
* object (new array/record identities) is produced on every change. */
|
|
@@ -34,22 +20,21 @@ export interface RoomSnapshot {
|
|
|
34
20
|
readWatermarks: Readonly<Record<string, string>>;
|
|
35
21
|
}
|
|
36
22
|
|
|
37
|
-
let uploadSeq = 0;
|
|
38
|
-
|
|
39
23
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
24
|
+
* React-side glue over the core `MessageCollection` — the message state
|
|
25
|
+
* (event folding, optimistic sends, cache hydrate→replace) is owned by the
|
|
26
|
+
* core; this store bridges the collection's change events into
|
|
27
|
+
* `useSyncExternalStore`-shaped immutable snapshots. Framework-agnostic on
|
|
28
|
+
* purpose so it can also be used outside hooks (tests, non-React glue).
|
|
43
29
|
*
|
|
44
|
-
* Lifecycle: construct → `attach()` (
|
|
45
|
-
*
|
|
46
|
-
* three for you.
|
|
30
|
+
* Lifecycle: construct → `attach()` (idempotent) → `dispose()` (flush read
|
|
31
|
+
* watermark + detach page listeners). `useMessages` does all three for you.
|
|
47
32
|
*
|
|
48
33
|
* Holds `(chat, roomId)` rather than a `Room` instance and re-resolves the
|
|
49
34
|
* live facade on every use — `chat.disconnect()` drops its Room facades, so
|
|
50
35
|
* a remounted provider (React StrictMode simulates this) would otherwise
|
|
51
|
-
* leave the store
|
|
52
|
-
* dispatches.
|
|
36
|
+
* leave the store bound to an orphaned Room that no longer receives
|
|
37
|
+
* dispatches. The backing collection is recreated when the facade changes.
|
|
53
38
|
*
|
|
54
39
|
* ```ts
|
|
55
40
|
* const store = new RoomStore(chat, "room_123");
|
|
@@ -63,13 +48,9 @@ export class RoomStore {
|
|
|
63
48
|
private readonly chat: NoveraChat;
|
|
64
49
|
readonly roomId: string;
|
|
65
50
|
private readonly listeners = new Set<() => void>();
|
|
66
|
-
private readonly unsubs: Array<() => void> = [];
|
|
67
51
|
|
|
68
|
-
private
|
|
69
|
-
private
|
|
70
|
-
private hasMore = false;
|
|
71
|
-
private oldestCursor: string | null = null;
|
|
72
|
-
private loadingMore = false;
|
|
52
|
+
private col: MessageCollection | null = null;
|
|
53
|
+
private colUnsub: (() => void) | null = null;
|
|
73
54
|
private historyRequested = false;
|
|
74
55
|
private snapshot: RoomSnapshot;
|
|
75
56
|
private attached = false;
|
|
@@ -91,22 +72,26 @@ export class RoomStore {
|
|
|
91
72
|
return this.chat.room(this.roomId);
|
|
92
73
|
}
|
|
93
74
|
|
|
94
|
-
/**
|
|
95
|
-
*
|
|
75
|
+
/** The backing collection, bound to the CURRENT facade. Recreated when
|
|
76
|
+
* the facade was dropped (post-`chat.disconnect()`) — the old one would
|
|
77
|
+
* never receive dispatches again. */
|
|
78
|
+
private collection(): MessageCollection {
|
|
79
|
+
const facade = this.room;
|
|
80
|
+
if (this.col && this.col.room === facade) return this.col;
|
|
81
|
+
this.colUnsub?.();
|
|
82
|
+
this.col = facade.collection();
|
|
83
|
+
this.colUnsub = this.col.on("change", () => this.rebuild());
|
|
84
|
+
this.rebuild();
|
|
85
|
+
return this.col;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Subscribe page lifecycle + ensure the collection is live. Safe to call
|
|
89
|
+
* repeatedly (no-op while attached) — matches React 18/19 StrictMode's
|
|
96
90
|
* mount → cleanup → mount effect cycle. */
|
|
97
91
|
attach(): void {
|
|
98
92
|
if (this.attached) return;
|
|
99
93
|
this.attached = true;
|
|
100
|
-
|
|
101
|
-
this.unsubs.push(
|
|
102
|
-
room.on("message", (f) => this.onIncoming(f)),
|
|
103
|
-
room.on("messageUpdated", (f) => this.onUpdated(f)),
|
|
104
|
-
room.on("messageDeleted", (f) => this.onDeleted(f)),
|
|
105
|
-
room.on("messagesCleared", (f) => this.onCleared(f)),
|
|
106
|
-
room.on("reactionAdded", (f) => this.onReactionAdded(f)),
|
|
107
|
-
room.on("reactionRemoved", (f) => this.onReactionRemoved(f)),
|
|
108
|
-
room.on("syncTick", (f) => this.onSyncTick(f)),
|
|
109
|
-
);
|
|
94
|
+
this.collection();
|
|
110
95
|
// Flush the pending read watermark when the tab goes to the background,
|
|
111
96
|
// so the last-read position isn't lost to the debounce window.
|
|
112
97
|
if (typeof document !== "undefined") {
|
|
@@ -117,13 +102,13 @@ export class RoomStore {
|
|
|
117
102
|
}
|
|
118
103
|
}
|
|
119
104
|
|
|
120
|
-
/**
|
|
121
|
-
*
|
|
105
|
+
/** Detach page listeners and flush the pending read watermark. The
|
|
106
|
+
* collection (and its state) survives, so a later `attach()` resumes
|
|
107
|
+
* cleanly — StrictMode's mount → cleanup → mount cycle keeps messages. */
|
|
122
108
|
dispose(): void {
|
|
123
109
|
if (!this.attached) return;
|
|
124
110
|
this.attached = false;
|
|
125
111
|
this.room.flushMarkRead();
|
|
126
|
-
for (const u of this.unsubs.splice(0)) u();
|
|
127
112
|
if (typeof document !== "undefined") {
|
|
128
113
|
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
|
129
114
|
}
|
|
@@ -142,11 +127,13 @@ export class RoomStore {
|
|
|
142
127
|
|
|
143
128
|
readonly getSnapshot = (): RoomSnapshot => this.snapshot;
|
|
144
129
|
|
|
145
|
-
private
|
|
130
|
+
private rebuild(): void {
|
|
131
|
+
const col = this.col;
|
|
132
|
+
if (!col) return;
|
|
146
133
|
this.snapshot = {
|
|
147
|
-
messages: [...
|
|
148
|
-
hasMore:
|
|
149
|
-
readWatermarks: { ...
|
|
134
|
+
messages: [...col.messages],
|
|
135
|
+
hasMore: col.hasMore,
|
|
136
|
+
readWatermarks: { ...col.readWatermarks },
|
|
150
137
|
};
|
|
151
138
|
for (const fn of this.listeners) fn();
|
|
152
139
|
}
|
|
@@ -155,52 +142,33 @@ export class RoomStore {
|
|
|
155
142
|
|
|
156
143
|
/** Whether `userId` has read `messageId` according to the latest watermark. */
|
|
157
144
|
isReadBy(userId: string, messageId: string): boolean {
|
|
158
|
-
|
|
159
|
-
if (!w) return false;
|
|
160
|
-
return !idGreater(messageId, w);
|
|
145
|
+
return this.collection().isReadBy(userId, messageId);
|
|
161
146
|
}
|
|
162
147
|
|
|
163
148
|
/** How many of the given users have read `messageId`. Pass the room's
|
|
164
149
|
* member ids (minus the sender) to get a KakaoTalk-style unread count:
|
|
165
150
|
* `members.length - readCount(...)`. */
|
|
166
151
|
readCount(messageId: string, userIds: Iterable<string>): number {
|
|
167
|
-
|
|
168
|
-
for (const u of userIds) if (this.isReadBy(u, messageId)) n++;
|
|
169
|
-
return n;
|
|
152
|
+
return this.collection().readCount(messageId, userIds);
|
|
170
153
|
}
|
|
171
154
|
|
|
172
155
|
// ── history ─────────────────────────────────────────────────────
|
|
173
156
|
|
|
174
|
-
/** Load the most recent page of history
|
|
175
|
-
*
|
|
176
|
-
*
|
|
157
|
+
/** Load the most recent page of history. No-op after the first call
|
|
158
|
+
* (StrictMode double-mount safe) — use `loadMore` for scrollback.
|
|
159
|
+
* 캐시(`ClientOptions.cacheStore`)가 켜져 있으면 스냅샷 즉시 렌더 →
|
|
160
|
+
* API 도착 시 통째 교체가 컬렉션 안에서 일어난다. */
|
|
177
161
|
async loadHistory(limit = 50): Promise<void> {
|
|
178
162
|
if (this.historyRequested) return;
|
|
179
163
|
this.historyRequested = true;
|
|
180
|
-
|
|
181
|
-
this.messages.unshift(...page.items.map(chatMessageFromHistory));
|
|
182
|
-
this.oldestCursor = page.nextCursor;
|
|
183
|
-
this.hasMore = page.hasMore;
|
|
184
|
-
this.emit();
|
|
164
|
+
await this.collection().load(limit);
|
|
185
165
|
}
|
|
186
166
|
|
|
187
167
|
/** Scrollback: load the page of messages older than what's on screen and
|
|
188
168
|
* prepend it. No-op while a previous call is in flight or when `hasMore`
|
|
189
169
|
* is false. Returns the number of messages added. */
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (this.loadingMore || !this.hasMore || cursor == null) return 0;
|
|
193
|
-
this.loadingMore = true;
|
|
194
|
-
try {
|
|
195
|
-
const page = await this.room.listBefore(cursor, limit);
|
|
196
|
-
this.messages.unshift(...page.items.map(chatMessageFromHistory));
|
|
197
|
-
this.oldestCursor = page.nextCursor;
|
|
198
|
-
this.hasMore = page.hasMore;
|
|
199
|
-
this.emit();
|
|
200
|
-
return page.items.length;
|
|
201
|
-
} finally {
|
|
202
|
-
this.loadingMore = false;
|
|
203
|
-
}
|
|
170
|
+
loadMore(limit = 50): Promise<number> {
|
|
171
|
+
return this.collection().loadMore(limit);
|
|
204
172
|
}
|
|
205
173
|
|
|
206
174
|
// ── outgoing ────────────────────────────────────────────────────
|
|
@@ -218,123 +186,37 @@ export class RoomStore {
|
|
|
218
186
|
customMeta?: Record<string, unknown>;
|
|
219
187
|
},
|
|
220
188
|
): void {
|
|
221
|
-
|
|
222
|
-
let tempId: string;
|
|
223
|
-
let ackPromise: Promise<string>;
|
|
224
|
-
try {
|
|
225
|
-
const sent = await this.room.send({
|
|
226
|
-
content: text,
|
|
227
|
-
...(opts?.replyToId ? { replyToId: opts.replyToId } : {}),
|
|
228
|
-
...(opts?.customType ? { customType: opts.customType } : {}),
|
|
229
|
-
...(opts?.customMeta ? { customMeta: opts.customMeta } : {}),
|
|
230
|
-
});
|
|
231
|
-
tempId = sent.tempId;
|
|
232
|
-
ackPromise = sent.messageId;
|
|
233
|
-
} catch {
|
|
234
|
-
// WS not open — surface a failed bubble rather than throwing into
|
|
235
|
-
// the void (send() is fire-and-forget).
|
|
236
|
-
const localId = `failed_${Date.now()}_${uploadSeq++}`;
|
|
237
|
-
this.messages.push({
|
|
238
|
-
...pendingChatMessage({ tempId: localId, content: text, ...opts }),
|
|
239
|
-
status: "failed",
|
|
240
|
-
});
|
|
241
|
-
this.emit();
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
this.messages.push(
|
|
245
|
-
pendingChatMessage({ tempId, content: text, ...opts }),
|
|
246
|
-
);
|
|
247
|
-
this.emit();
|
|
248
|
-
this.bindAck(tempId, ackPromise);
|
|
249
|
-
})();
|
|
189
|
+
this.collection().send(text, opts);
|
|
250
190
|
}
|
|
251
191
|
|
|
252
192
|
/** Send a file optimistically. A FILE bubble with `uploadProgress`
|
|
253
193
|
* appears immediately; progress advances during the S3 upload, then the
|
|
254
194
|
* bubble flips to `sent` on ack (or `failed`). */
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
// exists after the upload commits (sendFile resolves then).
|
|
258
|
-
const localId = `upload_${Date.now()}_${uploadSeq++}`;
|
|
259
|
-
this.messages.push(
|
|
260
|
-
pendingFileChatMessage({
|
|
261
|
-
tempId: localId,
|
|
262
|
-
name: opts?.name ?? file.name,
|
|
263
|
-
mime: file.type,
|
|
264
|
-
size: file.size,
|
|
265
|
-
}),
|
|
266
|
-
);
|
|
267
|
-
this.emit();
|
|
268
|
-
|
|
269
|
-
try {
|
|
270
|
-
const sent = await this.room.sendFile(file, {
|
|
271
|
-
...(opts ?? {}),
|
|
272
|
-
onProgress: (p) => {
|
|
273
|
-
this.mutateByTempId(localId, (m) => ({
|
|
274
|
-
...m,
|
|
275
|
-
uploadProgress: p.ratio,
|
|
276
|
-
}));
|
|
277
|
-
opts?.onProgress?.(p);
|
|
278
|
-
},
|
|
279
|
-
});
|
|
280
|
-
// Upload committed — bind the bubble to the real file id and ack.
|
|
281
|
-
this.mutateByTempId(localId, (m) => ({
|
|
282
|
-
...m,
|
|
283
|
-
fileId: sent.fileId,
|
|
284
|
-
file: m.file ? { ...m.file, id: sent.fileId } : m.file,
|
|
285
|
-
uploadProgress: null,
|
|
286
|
-
}));
|
|
287
|
-
const realId = await sent.messageId;
|
|
288
|
-
this.mutateByTempId(localId, (m) => ({
|
|
289
|
-
...m,
|
|
290
|
-
id: realId,
|
|
291
|
-
status: "sent",
|
|
292
|
-
}));
|
|
293
|
-
} catch {
|
|
294
|
-
this.mutateByTempId(localId, (m) => ({
|
|
295
|
-
...m,
|
|
296
|
-
status: "failed",
|
|
297
|
-
uploadProgress: null,
|
|
298
|
-
}));
|
|
299
|
-
}
|
|
195
|
+
sendFile(file: File, opts?: SendFileOptions): Promise<void> {
|
|
196
|
+
return this.collection().sendFile(file, opts);
|
|
300
197
|
}
|
|
301
198
|
|
|
302
199
|
/** Edit a message's content (server-side; peers get `message_updated`).
|
|
303
200
|
* The local copy updates optimistically. */
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
this.mutateById(messageId, (m) => ({
|
|
307
|
-
...m,
|
|
308
|
-
content,
|
|
309
|
-
editedCount: m.editedCount + 1,
|
|
310
|
-
}));
|
|
201
|
+
edit(messageId: string, content: string): Promise<void> {
|
|
202
|
+
return this.collection().edit(messageId, content);
|
|
311
203
|
}
|
|
312
204
|
|
|
313
205
|
/** Delete a message. `scope: "ALL"` (default) removes it for everyone;
|
|
314
206
|
* `"MY"` hides it only for the caller. Local copy flips to deleted. */
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
this.mutateById(messageId, (m) => ({ ...m, isDeleted: true }));
|
|
207
|
+
delete(messageId: string, scope: DeleteScope = "ALL"): Promise<void> {
|
|
208
|
+
return this.collection().delete(messageId, scope);
|
|
318
209
|
}
|
|
319
210
|
|
|
320
211
|
/** Add/remove the caller's reaction. Pass `myUserId` so the toggle can
|
|
321
212
|
* tell whether the caller already reacted with `key`. The authoritative
|
|
322
213
|
* state arrives back via the reaction WS events. */
|
|
323
|
-
|
|
214
|
+
toggleReaction(
|
|
324
215
|
messageId: string,
|
|
325
216
|
key: string,
|
|
326
217
|
myUserId: string,
|
|
327
218
|
): Promise<void> {
|
|
328
|
-
|
|
329
|
-
const mine =
|
|
330
|
-
m?.reactions.some(
|
|
331
|
-
(r) => r.key === key && r.userIds.includes(myUserId),
|
|
332
|
-
) ?? false;
|
|
333
|
-
if (mine) {
|
|
334
|
-
await this.room.unreact(messageId, key);
|
|
335
|
-
} else {
|
|
336
|
-
await this.room.react(messageId, key);
|
|
337
|
-
}
|
|
219
|
+
return this.collection().toggleReaction(messageId, key, myUserId);
|
|
338
220
|
}
|
|
339
221
|
|
|
340
222
|
/** Search this room's messages (server-side, content substring match).
|
|
@@ -346,92 +228,7 @@ export class RoomStore {
|
|
|
346
228
|
|
|
347
229
|
/** Mark `messageId` read (debounced inside the SDK). */
|
|
348
230
|
markRead(messageId: string): void {
|
|
349
|
-
this.
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// ── event handlers ──────────────────────────────────────────────
|
|
353
|
-
|
|
354
|
-
private onIncoming(f: WsChatReceive): void {
|
|
355
|
-
this.messages.push(chatMessageFromLive(f));
|
|
356
|
-
this.emit();
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
private onUpdated(f: WsMessageUpdated): void {
|
|
360
|
-
this.mutateById(f.message_id, (m) => ({
|
|
361
|
-
...m,
|
|
362
|
-
content: f.content ?? m.content,
|
|
363
|
-
editedCount: m.editedCount + 1,
|
|
364
|
-
}));
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
private onDeleted(f: WsMessageDeleted): void {
|
|
368
|
-
this.mutateById(f.message_id, (m) => ({ ...m, isDeleted: true }));
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
private onCleared(f: WsMessagesCleared): void {
|
|
372
|
-
// Operator wipe up to (and including) up_to_message_id — flip local
|
|
373
|
-
// copies. A null cutoff means the whole room was wiped.
|
|
374
|
-
const upTo = f.up_to_message_id;
|
|
375
|
-
let changed = false;
|
|
376
|
-
this.messages = this.messages.map((m) => {
|
|
377
|
-
if (!m.isDeleted && (upTo == null || !idGreater(m.id, upTo))) {
|
|
378
|
-
changed = true;
|
|
379
|
-
return { ...m, isDeleted: true };
|
|
380
|
-
}
|
|
381
|
-
return m;
|
|
382
|
-
});
|
|
383
|
-
if (changed) this.emit();
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
private onReactionAdded(f: WsReactionAdded): void {
|
|
387
|
-
this.mutateById(f.message_id, (m) => {
|
|
388
|
-
const next: ReactionEntry[] = m.reactions.map((r) =>
|
|
389
|
-
r.key === f.reaction_key
|
|
390
|
-
? {
|
|
391
|
-
key: r.key,
|
|
392
|
-
userIds: [...new Set([...r.userIds, f.user_id])],
|
|
393
|
-
updatedAt: f.updated_at,
|
|
394
|
-
}
|
|
395
|
-
: r,
|
|
396
|
-
);
|
|
397
|
-
if (!next.some((r) => r.key === f.reaction_key)) {
|
|
398
|
-
next.push({
|
|
399
|
-
key: f.reaction_key,
|
|
400
|
-
userIds: [f.user_id],
|
|
401
|
-
updatedAt: f.updated_at,
|
|
402
|
-
});
|
|
403
|
-
}
|
|
404
|
-
return { ...m, reactions: next };
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
private onReactionRemoved(f: WsReactionRemoved): void {
|
|
409
|
-
this.mutateById(f.message_id, (m) => {
|
|
410
|
-
const next: ReactionEntry[] = [];
|
|
411
|
-
for (const r of m.reactions) {
|
|
412
|
-
if (r.key !== f.reaction_key) {
|
|
413
|
-
next.push(r);
|
|
414
|
-
continue;
|
|
415
|
-
}
|
|
416
|
-
const users = r.userIds.filter((u) => u !== f.user_id);
|
|
417
|
-
if (users.length > 0) {
|
|
418
|
-
next.push({ key: r.key, userIds: users, updatedAt: f.updated_at });
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
return { ...m, reactions: next };
|
|
422
|
-
});
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
private onSyncTick(f: WsSyncTick): void {
|
|
426
|
-
let changed = false;
|
|
427
|
-
for (const [userId, mid] of Object.entries(f.read_states)) {
|
|
428
|
-
const cur = this.readWatermarks[userId];
|
|
429
|
-
if (!cur || idGreater(mid, cur)) {
|
|
430
|
-
this.readWatermarks[userId] = mid;
|
|
431
|
-
changed = true;
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
if (changed) this.emit();
|
|
231
|
+
this.collection().markRead(messageId);
|
|
435
232
|
}
|
|
436
233
|
|
|
437
234
|
private onVisibilityChange = (): void => {
|
|
@@ -439,37 +236,4 @@ export class RoomStore {
|
|
|
439
236
|
this.room.flushMarkRead();
|
|
440
237
|
}
|
|
441
238
|
};
|
|
442
|
-
|
|
443
|
-
// ── helpers ─────────────────────────────────────────────────────
|
|
444
|
-
|
|
445
|
-
private bindAck(tempId: string, messageId: Promise<string>): void {
|
|
446
|
-
messageId
|
|
447
|
-
.then((realId) => {
|
|
448
|
-
this.mutateByTempId(tempId, (m) => ({
|
|
449
|
-
...m,
|
|
450
|
-
id: realId,
|
|
451
|
-
status: "sent",
|
|
452
|
-
}));
|
|
453
|
-
})
|
|
454
|
-
.catch(() => {
|
|
455
|
-
this.mutateByTempId(tempId, (m) => ({ ...m, status: "failed" }));
|
|
456
|
-
});
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
private mutateById(id: string, f: (m: ChatMessage) => ChatMessage): void {
|
|
460
|
-
const i = this.messages.findIndex((m) => m.id === id);
|
|
461
|
-
if (i === -1) return;
|
|
462
|
-
this.messages[i] = f(this.messages[i]!);
|
|
463
|
-
this.emit();
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
private mutateByTempId(
|
|
467
|
-
tempId: string,
|
|
468
|
-
f: (m: ChatMessage) => ChatMessage,
|
|
469
|
-
): void {
|
|
470
|
-
const i = this.messages.findIndex((m) => m.tempId === tempId);
|
|
471
|
-
if (i === -1) return;
|
|
472
|
-
this.messages[i] = f(this.messages[i]!);
|
|
473
|
-
this.emit();
|
|
474
|
-
}
|
|
475
239
|
}
|