@noverachat/sdk-react 0.3.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/LICENSE +21 -0
- package/README.ko.md +99 -0
- package/README.md +99 -0
- package/dist/index.cjs +1192 -0
- package/dist/index.d.cts +675 -0
- package/dist/index.d.ts +675 -0
- package/dist/index.js +1154 -0
- package/package.json +42 -0
- package/src/chat-message.ts +209 -0
- package/src/context.tsx +72 -0
- package/src/index.ts +83 -0
- package/src/internal/compare-id.ts +17 -0
- package/src/member-list-store.ts +116 -0
- package/src/room-files-store.ts +130 -0
- package/src/room-list-store.ts +227 -0
- package/src/room-settings-store.ts +200 -0
- package/src/room-store.ts +475 -0
- package/src/use-member-list.ts +46 -0
- package/src/use-messages.ts +62 -0
- package/src/use-room-files.ts +53 -0
- package/src/use-room-list.ts +57 -0
- package/src/use-room-settings.ts +47 -0
- package/src/use-room.ts +21 -0
- package/src/use-typing.ts +85 -0
- package/src/use-unread.ts +65 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1154 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
export * from "@noverachat/sdk-web";
|
|
3
|
+
|
|
4
|
+
// src/chat-message.ts
|
|
5
|
+
function fileInlineFromWire(w) {
|
|
6
|
+
return {
|
|
7
|
+
id: w.id,
|
|
8
|
+
kind: w.kind,
|
|
9
|
+
mime: w.mime,
|
|
10
|
+
size: w.size,
|
|
11
|
+
name: w.name ?? null,
|
|
12
|
+
width: w.width ?? null,
|
|
13
|
+
height: w.height ?? null,
|
|
14
|
+
durationSec: w.duration_sec ?? null,
|
|
15
|
+
thumbnailStatus: w.thumbnail_status,
|
|
16
|
+
forwardBlocked: Boolean(w.forward_blocked)
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function chatMessageFromHistory(m) {
|
|
20
|
+
return {
|
|
21
|
+
id: m.messageId,
|
|
22
|
+
senderId: m.senderId ?? null,
|
|
23
|
+
content: m.content ?? null,
|
|
24
|
+
createdAtMs: m.createdAtMs,
|
|
25
|
+
status: "sent",
|
|
26
|
+
messageType: m.messageType,
|
|
27
|
+
sender: m.sender ?? null,
|
|
28
|
+
customType: m.customType ?? null,
|
|
29
|
+
fileId: m.fileId ?? null,
|
|
30
|
+
file: m.file ?? null,
|
|
31
|
+
files: m.files ?? null,
|
|
32
|
+
replyToId: m.replyToId ?? null,
|
|
33
|
+
reactions: m.reactions ?? [],
|
|
34
|
+
customMeta: m.customMeta ?? null,
|
|
35
|
+
editedCount: m.editedCount ?? 0,
|
|
36
|
+
uploadProgress: null,
|
|
37
|
+
tempId: null,
|
|
38
|
+
isDeleted: m.deletedAt != null
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function chatMessageFromLive(f) {
|
|
42
|
+
const data = f.data ?? null;
|
|
43
|
+
const customMeta = data && typeof data === "object" && "_customMeta" in data ? data._customMeta : null;
|
|
44
|
+
return {
|
|
45
|
+
id: f.message_id,
|
|
46
|
+
senderId: f.sender_id,
|
|
47
|
+
content: f.content ?? null,
|
|
48
|
+
createdAtMs: f.created_at,
|
|
49
|
+
status: "sent",
|
|
50
|
+
messageType: f.message_type,
|
|
51
|
+
sender: null,
|
|
52
|
+
customType: f.custom_type ?? null,
|
|
53
|
+
fileId: f.file_id ?? null,
|
|
54
|
+
file: f.file ? fileInlineFromWire(f.file) : null,
|
|
55
|
+
files: f.files ? f.files.map(fileInlineFromWire) : null,
|
|
56
|
+
replyToId: f.reply_to_id ?? null,
|
|
57
|
+
reactions: [],
|
|
58
|
+
customMeta,
|
|
59
|
+
editedCount: 0,
|
|
60
|
+
uploadProgress: null,
|
|
61
|
+
tempId: null,
|
|
62
|
+
isDeleted: false
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function pendingChatMessage(p) {
|
|
66
|
+
return {
|
|
67
|
+
id: p.tempId,
|
|
68
|
+
senderId: null,
|
|
69
|
+
content: p.content,
|
|
70
|
+
createdAtMs: Date.now(),
|
|
71
|
+
status: "sending",
|
|
72
|
+
messageType: "TEXT",
|
|
73
|
+
sender: null,
|
|
74
|
+
customType: p.customType ?? null,
|
|
75
|
+
fileId: null,
|
|
76
|
+
file: null,
|
|
77
|
+
files: null,
|
|
78
|
+
replyToId: p.replyToId ?? null,
|
|
79
|
+
reactions: [],
|
|
80
|
+
customMeta: p.customMeta ?? null,
|
|
81
|
+
editedCount: 0,
|
|
82
|
+
uploadProgress: null,
|
|
83
|
+
tempId: p.tempId,
|
|
84
|
+
isDeleted: false
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function pendingFileChatMessage(p) {
|
|
88
|
+
const mime = p.mime ?? "application/octet-stream";
|
|
89
|
+
return {
|
|
90
|
+
id: p.tempId,
|
|
91
|
+
senderId: null,
|
|
92
|
+
content: null,
|
|
93
|
+
createdAtMs: Date.now(),
|
|
94
|
+
status: "sending",
|
|
95
|
+
messageType: "FILE",
|
|
96
|
+
sender: null,
|
|
97
|
+
customType: null,
|
|
98
|
+
fileId: null,
|
|
99
|
+
file: {
|
|
100
|
+
id: "",
|
|
101
|
+
kind: mime.startsWith("image/") ? "image" : mime.startsWith("video/") ? "video" : "file",
|
|
102
|
+
mime,
|
|
103
|
+
size: p.size ?? 0,
|
|
104
|
+
name: p.name,
|
|
105
|
+
thumbnailStatus: "pending",
|
|
106
|
+
forwardBlocked: false
|
|
107
|
+
},
|
|
108
|
+
files: null,
|
|
109
|
+
replyToId: null,
|
|
110
|
+
reactions: [],
|
|
111
|
+
customMeta: null,
|
|
112
|
+
editedCount: 0,
|
|
113
|
+
uploadProgress: 0,
|
|
114
|
+
tempId: p.tempId,
|
|
115
|
+
isDeleted: false
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/context.tsx
|
|
120
|
+
import {
|
|
121
|
+
createContext,
|
|
122
|
+
useContext,
|
|
123
|
+
useEffect,
|
|
124
|
+
useState
|
|
125
|
+
} from "react";
|
|
126
|
+
import { NoveraChat } from "@noverachat/sdk-web";
|
|
127
|
+
import { jsx } from "react/jsx-runtime";
|
|
128
|
+
var NoveraChatContext = createContext(null);
|
|
129
|
+
function NoveraChatProvider(props) {
|
|
130
|
+
const [chat] = useState(() => new NoveraChat(props.options));
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
void chat.connect();
|
|
133
|
+
return () => chat.disconnect();
|
|
134
|
+
}, [chat]);
|
|
135
|
+
return /* @__PURE__ */ jsx(NoveraChatContext.Provider, { value: chat, children: props.children });
|
|
136
|
+
}
|
|
137
|
+
function useNoveraChat() {
|
|
138
|
+
const chat = useContext(NoveraChatContext);
|
|
139
|
+
if (!chat) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
"useNoveraChat() called with no NoveraChatProvider in the tree."
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return chat;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// src/member-list-store.ts
|
|
148
|
+
var MemberListStore = class {
|
|
149
|
+
chat;
|
|
150
|
+
roomId;
|
|
151
|
+
listeners = /* @__PURE__ */ new Set();
|
|
152
|
+
members = [];
|
|
153
|
+
unsubs = [];
|
|
154
|
+
reloadTimer = null;
|
|
155
|
+
snapshot = {
|
|
156
|
+
members: [],
|
|
157
|
+
operators: [],
|
|
158
|
+
activeMemberIds: []
|
|
159
|
+
};
|
|
160
|
+
attached = false;
|
|
161
|
+
constructor(chat, roomId) {
|
|
162
|
+
this.chat = chat;
|
|
163
|
+
this.roomId = roomId;
|
|
164
|
+
}
|
|
165
|
+
attach() {
|
|
166
|
+
if (this.attached) return;
|
|
167
|
+
this.attached = true;
|
|
168
|
+
this.unsubs.push(
|
|
169
|
+
this.chat.on("roomEvent", (e) => {
|
|
170
|
+
if (e.room_id === this.roomId) this.scheduleReload();
|
|
171
|
+
}),
|
|
172
|
+
this.chat.on("presence", (f) => this.onPresence(f))
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
dispose() {
|
|
176
|
+
if (!this.attached) return;
|
|
177
|
+
this.attached = false;
|
|
178
|
+
if (this.reloadTimer) clearTimeout(this.reloadTimer);
|
|
179
|
+
this.reloadTimer = null;
|
|
180
|
+
for (const u of this.unsubs.splice(0)) u();
|
|
181
|
+
}
|
|
182
|
+
// ── external-store contract ─────────────────────────────────────
|
|
183
|
+
subscribe = (fn) => {
|
|
184
|
+
this.listeners.add(fn);
|
|
185
|
+
return () => this.listeners.delete(fn);
|
|
186
|
+
};
|
|
187
|
+
getSnapshot = () => this.snapshot;
|
|
188
|
+
emit() {
|
|
189
|
+
this.snapshot = {
|
|
190
|
+
members: [...this.members],
|
|
191
|
+
operators: this.members.filter((m) => m.role === "OPERATOR"),
|
|
192
|
+
activeMemberIds: this.members.filter((m) => m.role !== "KICKED").map((m) => m.userId)
|
|
193
|
+
};
|
|
194
|
+
for (const fn of this.listeners) fn();
|
|
195
|
+
}
|
|
196
|
+
/** Whether `userId` is an OPERATOR of this room. */
|
|
197
|
+
isOperator(userId) {
|
|
198
|
+
return this.members.some(
|
|
199
|
+
(m) => m.userId === userId && m.role === "OPERATOR"
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
/** Fetch the member list. Also called automatically on membership
|
|
203
|
+
* events. */
|
|
204
|
+
async load() {
|
|
205
|
+
const list = await this.chat.room(this.roomId).listMembers();
|
|
206
|
+
if (!this.attached) return;
|
|
207
|
+
this.members = list;
|
|
208
|
+
this.emit();
|
|
209
|
+
}
|
|
210
|
+
// ── internals ───────────────────────────────────────────────────
|
|
211
|
+
onPresence(f) {
|
|
212
|
+
const i = this.members.findIndex((m) => m.userId === f.user_id);
|
|
213
|
+
if (i === -1) return;
|
|
214
|
+
this.members[i] = { ...this.members[i], isOnline: f.online };
|
|
215
|
+
this.emit();
|
|
216
|
+
}
|
|
217
|
+
scheduleReload() {
|
|
218
|
+
if (this.reloadTimer) clearTimeout(this.reloadTimer);
|
|
219
|
+
this.reloadTimer = setTimeout(() => {
|
|
220
|
+
this.reloadTimer = null;
|
|
221
|
+
if (this.attached) void this.load();
|
|
222
|
+
}, 300);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// src/room-files-store.ts
|
|
227
|
+
var RoomFilesStore = class {
|
|
228
|
+
chat;
|
|
229
|
+
roomId;
|
|
230
|
+
/** Page size for both `refresh` and `loadMore`. */
|
|
231
|
+
pageSize;
|
|
232
|
+
listeners = /* @__PURE__ */ new Set();
|
|
233
|
+
items = [];
|
|
234
|
+
cursor = null;
|
|
235
|
+
hasMore = false;
|
|
236
|
+
loading = false;
|
|
237
|
+
attached = false;
|
|
238
|
+
snapshot = {
|
|
239
|
+
items: [],
|
|
240
|
+
media: [],
|
|
241
|
+
hasMore: false,
|
|
242
|
+
isLoading: false
|
|
243
|
+
};
|
|
244
|
+
constructor(chat, roomId, pageSize = 50) {
|
|
245
|
+
this.chat = chat;
|
|
246
|
+
this.roomId = roomId;
|
|
247
|
+
this.pageSize = pageSize;
|
|
248
|
+
}
|
|
249
|
+
/** No realtime subscriptions — kept for lifecycle symmetry with the
|
|
250
|
+
* other stores (guards in-flight results after unmount). */
|
|
251
|
+
attach() {
|
|
252
|
+
this.attached = true;
|
|
253
|
+
}
|
|
254
|
+
dispose() {
|
|
255
|
+
this.attached = false;
|
|
256
|
+
}
|
|
257
|
+
// ── external-store contract ─────────────────────────────────────
|
|
258
|
+
subscribe = (fn) => {
|
|
259
|
+
this.listeners.add(fn);
|
|
260
|
+
return () => this.listeners.delete(fn);
|
|
261
|
+
};
|
|
262
|
+
getSnapshot = () => this.snapshot;
|
|
263
|
+
emit() {
|
|
264
|
+
this.snapshot = {
|
|
265
|
+
items: [...this.items],
|
|
266
|
+
media: this.items.filter(
|
|
267
|
+
(it) => it.file.kind === "image" || it.file.kind === "video"
|
|
268
|
+
),
|
|
269
|
+
hasMore: this.hasMore,
|
|
270
|
+
isLoading: this.loading
|
|
271
|
+
};
|
|
272
|
+
for (const fn of this.listeners) fn();
|
|
273
|
+
}
|
|
274
|
+
// ── actions ─────────────────────────────────────────────────────
|
|
275
|
+
/** Reload from the newest page (clears what was loaded). */
|
|
276
|
+
async refresh() {
|
|
277
|
+
if (this.loading) return;
|
|
278
|
+
this.loading = true;
|
|
279
|
+
this.emit();
|
|
280
|
+
try {
|
|
281
|
+
const page = await this.chat.room(this.roomId).listFiles({ limit: this.pageSize });
|
|
282
|
+
if (!this.attached) return;
|
|
283
|
+
this.items = page.items;
|
|
284
|
+
this.cursor = page.nextCursor;
|
|
285
|
+
this.hasMore = page.hasMore;
|
|
286
|
+
} finally {
|
|
287
|
+
this.loading = false;
|
|
288
|
+
if (this.attached) this.emit();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/** Fetch the next older page and append it. No-op while loading or when
|
|
292
|
+
* `hasMore` is false. Returns the number of entries added. */
|
|
293
|
+
async loadMore() {
|
|
294
|
+
const cursor = this.cursor;
|
|
295
|
+
if (this.loading || !this.hasMore || cursor == null) return 0;
|
|
296
|
+
this.loading = true;
|
|
297
|
+
this.emit();
|
|
298
|
+
try {
|
|
299
|
+
const page = await this.chat.room(this.roomId).listFiles({ limit: this.pageSize, before: cursor });
|
|
300
|
+
if (!this.attached) return 0;
|
|
301
|
+
this.items = [...this.items, ...page.items];
|
|
302
|
+
this.cursor = page.nextCursor;
|
|
303
|
+
this.hasMore = page.hasMore;
|
|
304
|
+
return page.items.length;
|
|
305
|
+
} finally {
|
|
306
|
+
this.loading = false;
|
|
307
|
+
if (this.attached) this.emit();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// src/room-list-store.ts
|
|
313
|
+
function itemFromUnread(r) {
|
|
314
|
+
const members = r.membersPreview ?? [];
|
|
315
|
+
return {
|
|
316
|
+
roomId: r.roomId,
|
|
317
|
+
name: r.name,
|
|
318
|
+
roomType: r.roomType ?? null,
|
|
319
|
+
unreadCount: r.unreadCount,
|
|
320
|
+
lastMessageId: r.lastMessageId ?? null,
|
|
321
|
+
lastMessagePreview: r.lastMessagePreview ?? null,
|
|
322
|
+
memberCount: r.memberCount ?? 0,
|
|
323
|
+
members,
|
|
324
|
+
lastMessageAtMs: null,
|
|
325
|
+
anyMemberOnline: members.some((m) => m.isOnline === true)
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
var RoomListStore = class {
|
|
329
|
+
chat;
|
|
330
|
+
opts;
|
|
331
|
+
listeners = /* @__PURE__ */ new Set();
|
|
332
|
+
rooms = [];
|
|
333
|
+
index = /* @__PURE__ */ new Map();
|
|
334
|
+
// roomId → rooms position
|
|
335
|
+
roomSubs = [];
|
|
336
|
+
clientSub = null;
|
|
337
|
+
reloadTimer = null;
|
|
338
|
+
snapshot = { rooms: [], totalUnread: 0 };
|
|
339
|
+
attached = false;
|
|
340
|
+
constructor(chat, opts = {}) {
|
|
341
|
+
this.chat = chat;
|
|
342
|
+
this.opts = opts;
|
|
343
|
+
}
|
|
344
|
+
/** Subscribe to client-level room events. Idempotent. */
|
|
345
|
+
attach() {
|
|
346
|
+
if (this.attached) return;
|
|
347
|
+
this.attached = true;
|
|
348
|
+
this.clientSub = this.chat.on("roomEvent", () => this.scheduleReload());
|
|
349
|
+
}
|
|
350
|
+
/** Unsubscribe everything (list state is kept for a later `attach`). */
|
|
351
|
+
dispose() {
|
|
352
|
+
if (!this.attached) return;
|
|
353
|
+
this.attached = false;
|
|
354
|
+
if (this.reloadTimer) clearTimeout(this.reloadTimer);
|
|
355
|
+
this.reloadTimer = null;
|
|
356
|
+
for (const u of this.roomSubs.splice(0)) u();
|
|
357
|
+
this.clientSub?.();
|
|
358
|
+
this.clientSub = null;
|
|
359
|
+
}
|
|
360
|
+
// ── external-store contract ─────────────────────────────────────
|
|
361
|
+
subscribe = (fn) => {
|
|
362
|
+
this.listeners.add(fn);
|
|
363
|
+
return () => this.listeners.delete(fn);
|
|
364
|
+
};
|
|
365
|
+
getSnapshot = () => this.snapshot;
|
|
366
|
+
emit() {
|
|
367
|
+
this.snapshot = {
|
|
368
|
+
rooms: [...this.rooms],
|
|
369
|
+
totalUnread: this.rooms.reduce((n, r) => n + r.unreadCount, 0)
|
|
370
|
+
};
|
|
371
|
+
for (const fn of this.listeners) fn();
|
|
372
|
+
}
|
|
373
|
+
// ── actions ─────────────────────────────────────────────────────
|
|
374
|
+
/** Reload the room list and re-hook the realtime subscriptions. */
|
|
375
|
+
async load() {
|
|
376
|
+
const summary = await this.chat.unreadSummary();
|
|
377
|
+
if (!this.attached) return;
|
|
378
|
+
this.rooms = summary.rooms.map(itemFromUnread);
|
|
379
|
+
this.reindex();
|
|
380
|
+
this.resubscribe();
|
|
381
|
+
this.emit();
|
|
382
|
+
}
|
|
383
|
+
/** Hide a room (카톡 "숨기기") — optimistically remove it from the list
|
|
384
|
+
* and send `hide` to the server. On failure the next `load` brings it
|
|
385
|
+
* back; new activity may re-surface it per server policy (arrives via
|
|
386
|
+
* roomEvent → reload). */
|
|
387
|
+
async hideRoom(roomId) {
|
|
388
|
+
const i = this.index.get(roomId);
|
|
389
|
+
if (i !== void 0) {
|
|
390
|
+
this.rooms.splice(i, 1);
|
|
391
|
+
this.reindex();
|
|
392
|
+
this.emit();
|
|
393
|
+
}
|
|
394
|
+
await this.chat.room(roomId).hide();
|
|
395
|
+
}
|
|
396
|
+
/** Undo `hideRoom` — send `unhide` and reload the list. */
|
|
397
|
+
async unhideRoom(roomId) {
|
|
398
|
+
await this.chat.room(roomId).unhide();
|
|
399
|
+
await this.load();
|
|
400
|
+
}
|
|
401
|
+
/** Mark a room read — drop its badge to 0 and advance the client's
|
|
402
|
+
* last-seen watermark. */
|
|
403
|
+
markRead(roomId) {
|
|
404
|
+
const i = this.index.get(roomId);
|
|
405
|
+
if (i === void 0) return;
|
|
406
|
+
const item = this.rooms[i];
|
|
407
|
+
if (item.lastMessageId) this.chat.setLastSeen(roomId, item.lastMessageId);
|
|
408
|
+
if (item.unreadCount !== 0) {
|
|
409
|
+
this.rooms[i] = { ...item, unreadCount: 0 };
|
|
410
|
+
this.emit();
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// ── internals ───────────────────────────────────────────────────
|
|
414
|
+
reindex() {
|
|
415
|
+
this.index = new Map(this.rooms.map((r, i) => [r.roomId, i]));
|
|
416
|
+
}
|
|
417
|
+
resubscribe() {
|
|
418
|
+
for (const u of this.roomSubs.splice(0)) u();
|
|
419
|
+
for (const item of this.rooms) {
|
|
420
|
+
const room = this.chat.room(item.roomId);
|
|
421
|
+
this.roomSubs.push(
|
|
422
|
+
room.on("message", (m) => this.onIncoming(item.roomId, m)),
|
|
423
|
+
room.on("syncTick", () => this.scheduleReload()),
|
|
424
|
+
room.on("messagesCleared", () => this.scheduleReload())
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
onIncoming(roomId, m) {
|
|
429
|
+
const i = this.index.get(roomId);
|
|
430
|
+
if (i === void 0) return;
|
|
431
|
+
const cur = this.rooms[i];
|
|
432
|
+
const visible = this.opts.isRoomVisible?.(roomId) ?? false;
|
|
433
|
+
const preview = this.opts.previewBuilder?.(m) ?? m.content ?? (m.file_id || m.file || m.files && m.files.length > 0 ? "\u{1F4CE}" : "");
|
|
434
|
+
const updated = {
|
|
435
|
+
...cur,
|
|
436
|
+
unreadCount: visible ? cur.unreadCount : cur.unreadCount + 1,
|
|
437
|
+
lastMessageId: m.message_id,
|
|
438
|
+
lastMessagePreview: preview,
|
|
439
|
+
lastMessageAtMs: m.created_at
|
|
440
|
+
};
|
|
441
|
+
this.rooms.splice(i, 1);
|
|
442
|
+
this.rooms.unshift(updated);
|
|
443
|
+
this.reindex();
|
|
444
|
+
this.emit();
|
|
445
|
+
}
|
|
446
|
+
scheduleReload() {
|
|
447
|
+
if (this.reloadTimer) clearTimeout(this.reloadTimer);
|
|
448
|
+
this.reloadTimer = setTimeout(() => {
|
|
449
|
+
this.reloadTimer = null;
|
|
450
|
+
if (this.attached) void this.load();
|
|
451
|
+
}, 500);
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
// src/room-settings-store.ts
|
|
456
|
+
var RoomSettingsStore = class {
|
|
457
|
+
chat;
|
|
458
|
+
roomId;
|
|
459
|
+
listeners = /* @__PURE__ */ new Set();
|
|
460
|
+
invites = [];
|
|
461
|
+
joinRequests = [];
|
|
462
|
+
attached = false;
|
|
463
|
+
snapshot = { invites: [], joinRequests: [] };
|
|
464
|
+
constructor(chat, roomId) {
|
|
465
|
+
this.chat = chat;
|
|
466
|
+
this.roomId = roomId;
|
|
467
|
+
}
|
|
468
|
+
get room() {
|
|
469
|
+
return this.chat.room(this.roomId);
|
|
470
|
+
}
|
|
471
|
+
/** No realtime subscriptions — kept for lifecycle symmetry with the
|
|
472
|
+
* other stores (guards in-flight results after unmount). */
|
|
473
|
+
attach() {
|
|
474
|
+
this.attached = true;
|
|
475
|
+
}
|
|
476
|
+
dispose() {
|
|
477
|
+
this.attached = false;
|
|
478
|
+
}
|
|
479
|
+
// ── external-store contract ─────────────────────────────────────
|
|
480
|
+
subscribe = (fn) => {
|
|
481
|
+
this.listeners.add(fn);
|
|
482
|
+
return () => this.listeners.delete(fn);
|
|
483
|
+
};
|
|
484
|
+
getSnapshot = () => this.snapshot;
|
|
485
|
+
emit() {
|
|
486
|
+
this.snapshot = {
|
|
487
|
+
invites: [...this.invites],
|
|
488
|
+
joinRequests: [...this.joinRequests]
|
|
489
|
+
};
|
|
490
|
+
for (const fn of this.listeners) fn();
|
|
491
|
+
}
|
|
492
|
+
// ── notifications ───────────────────────────────────────────────
|
|
493
|
+
/** Convenience mute toggle: `true` → `OFF`, `false` → `ALL`. */
|
|
494
|
+
setMuted(muted) {
|
|
495
|
+
return this.room.setPushTrigger(muted ? "OFF" : "ALL");
|
|
496
|
+
}
|
|
497
|
+
/** Fine-grained per-room push trigger: `ALL` | `MENTION_ONLY` | `OFF`. */
|
|
498
|
+
setPushTrigger(trigger) {
|
|
499
|
+
return this.room.setPushTrigger(trigger);
|
|
500
|
+
}
|
|
501
|
+
// ── invite links ────────────────────────────────────────────────
|
|
502
|
+
/** Fetch the invite list (operator-only server-side). */
|
|
503
|
+
async loadInvites() {
|
|
504
|
+
const list = await this.room.listInvites();
|
|
505
|
+
if (!this.attached) return;
|
|
506
|
+
this.invites = list;
|
|
507
|
+
this.emit();
|
|
508
|
+
}
|
|
509
|
+
/** Mint a new invite link and prepend it to `invites`. */
|
|
510
|
+
async createInvite(opts) {
|
|
511
|
+
const token = await this.room.createInvite(opts);
|
|
512
|
+
if (this.attached) {
|
|
513
|
+
this.invites = [token, ...this.invites];
|
|
514
|
+
this.emit();
|
|
515
|
+
}
|
|
516
|
+
return token;
|
|
517
|
+
}
|
|
518
|
+
/** Revoke a token and drop it from `invites`. */
|
|
519
|
+
async revokeInvite(token) {
|
|
520
|
+
await this.room.revokeInvite(token);
|
|
521
|
+
if (this.attached) {
|
|
522
|
+
this.invites = this.invites.filter((t) => t.token !== token);
|
|
523
|
+
this.emit();
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
// ── join requests (operator inbox) ──────────────────────────────
|
|
527
|
+
/** Fetch pending join requests (operator-only server-side). */
|
|
528
|
+
async loadJoinRequests() {
|
|
529
|
+
const page = await this.room.listJoinRequests();
|
|
530
|
+
if (!this.attached) return;
|
|
531
|
+
this.joinRequests = page.items;
|
|
532
|
+
this.emit();
|
|
533
|
+
}
|
|
534
|
+
/** Approve a pending request and drop it from `joinRequests`. */
|
|
535
|
+
async approveJoinRequest(userId, reason) {
|
|
536
|
+
await this.room.approveJoinRequest(userId, reason);
|
|
537
|
+
if (this.attached) {
|
|
538
|
+
this.joinRequests = this.joinRequests.filter((r) => r.userId !== userId);
|
|
539
|
+
this.emit();
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/** Reject a pending request and drop it from `joinRequests`. */
|
|
543
|
+
async rejectJoinRequest(userId, reason) {
|
|
544
|
+
await this.room.rejectJoinRequest(userId, reason);
|
|
545
|
+
if (this.attached) {
|
|
546
|
+
this.joinRequests = this.joinRequests.filter((r) => r.userId !== userId);
|
|
547
|
+
this.emit();
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// ── history / membership ────────────────────────────────────────
|
|
551
|
+
/** Per-user "clear chat" — hides existing messages for the caller only. */
|
|
552
|
+
clearHistory() {
|
|
553
|
+
return this.room.clearHistory();
|
|
554
|
+
}
|
|
555
|
+
/** Leave the room. `deleteMessages`: `"none"` (default) | `"mine"` |
|
|
556
|
+
* `"all"` — see `Room.leave`. */
|
|
557
|
+
leave(options) {
|
|
558
|
+
return this.room.leave(options);
|
|
559
|
+
}
|
|
560
|
+
/** Delete every message the caller sent (without leaving). Returns the
|
|
561
|
+
* cleared count. */
|
|
562
|
+
async deleteMyMessages() {
|
|
563
|
+
const r = await this.room.deleteMyMessages();
|
|
564
|
+
return r.cleared;
|
|
565
|
+
}
|
|
566
|
+
// ── operator moderation ─────────────────────────────────────────
|
|
567
|
+
/** **Destructive, operator-only** — wipe every message in the room. */
|
|
568
|
+
deleteAllMessages() {
|
|
569
|
+
return this.room.deleteAllMessages();
|
|
570
|
+
}
|
|
571
|
+
/** Operator: block non-operator sends (`ROOM_FROZEN` server-side). */
|
|
572
|
+
freeze() {
|
|
573
|
+
return this.room.freeze();
|
|
574
|
+
}
|
|
575
|
+
/** Operator: reopen the room to sends. */
|
|
576
|
+
unfreeze() {
|
|
577
|
+
return this.room.unfreeze();
|
|
578
|
+
}
|
|
579
|
+
/** Operator: toggle open-chat visibility. */
|
|
580
|
+
setPublic(isPublic) {
|
|
581
|
+
return this.room.setPublic(isPublic);
|
|
582
|
+
}
|
|
583
|
+
/** Operator: mute a member (`durationMinutes` null → indefinite). */
|
|
584
|
+
muteMember(userId, durationMinutes) {
|
|
585
|
+
return this.room.muteMember(userId, durationMinutes);
|
|
586
|
+
}
|
|
587
|
+
/** Operator: clear a member's mute. */
|
|
588
|
+
unmuteMember(userId) {
|
|
589
|
+
return this.room.unmuteMember(userId);
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
// src/internal/compare-id.ts
|
|
594
|
+
function idGreater(a, b) {
|
|
595
|
+
if (!a || a === "0") return false;
|
|
596
|
+
if (!b || b === "0") return true;
|
|
597
|
+
try {
|
|
598
|
+
return BigInt(a) > BigInt(b);
|
|
599
|
+
} catch {
|
|
600
|
+
return a > b;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// src/room-store.ts
|
|
605
|
+
var uploadSeq = 0;
|
|
606
|
+
var RoomStore = class {
|
|
607
|
+
chat;
|
|
608
|
+
roomId;
|
|
609
|
+
listeners = /* @__PURE__ */ new Set();
|
|
610
|
+
unsubs = [];
|
|
611
|
+
messages = [];
|
|
612
|
+
readWatermarks = {};
|
|
613
|
+
hasMore = false;
|
|
614
|
+
oldestCursor = null;
|
|
615
|
+
loadingMore = false;
|
|
616
|
+
historyRequested = false;
|
|
617
|
+
snapshot;
|
|
618
|
+
attached = false;
|
|
619
|
+
onPageHide = () => this.room.flushMarkRead();
|
|
620
|
+
constructor(chat, roomId) {
|
|
621
|
+
this.chat = chat;
|
|
622
|
+
this.roomId = roomId;
|
|
623
|
+
this.snapshot = {
|
|
624
|
+
messages: [],
|
|
625
|
+
hasMore: false,
|
|
626
|
+
readWatermarks: {}
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
/** The live `Room` facade — resolved from the client on every access
|
|
630
|
+
* (get-or-create in a map, cheap) so we never act on an orphan. */
|
|
631
|
+
get room() {
|
|
632
|
+
return this.chat.room(this.roomId);
|
|
633
|
+
}
|
|
634
|
+
/** Subscribe to WS events + page lifecycle. Safe to call repeatedly
|
|
635
|
+
* (no-op while attached) — matches React 18/19 StrictMode's
|
|
636
|
+
* mount → cleanup → mount effect cycle. */
|
|
637
|
+
attach() {
|
|
638
|
+
if (this.attached) return;
|
|
639
|
+
this.attached = true;
|
|
640
|
+
const room = this.room;
|
|
641
|
+
this.unsubs.push(
|
|
642
|
+
room.on("message", (f) => this.onIncoming(f)),
|
|
643
|
+
room.on("messageUpdated", (f) => this.onUpdated(f)),
|
|
644
|
+
room.on("messageDeleted", (f) => this.onDeleted(f)),
|
|
645
|
+
room.on("messagesCleared", (f) => this.onCleared(f)),
|
|
646
|
+
room.on("reactionAdded", (f) => this.onReactionAdded(f)),
|
|
647
|
+
room.on("reactionRemoved", (f) => this.onReactionRemoved(f)),
|
|
648
|
+
room.on("syncTick", (f) => this.onSyncTick(f))
|
|
649
|
+
);
|
|
650
|
+
if (typeof document !== "undefined") {
|
|
651
|
+
document.addEventListener("visibilitychange", this.onVisibilityChange);
|
|
652
|
+
}
|
|
653
|
+
if (typeof window !== "undefined") {
|
|
654
|
+
window.addEventListener("pagehide", this.onPageHide);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
/** Unsubscribe everything and flush the pending read watermark. The store
|
|
658
|
+
* keeps its message state, so a later `attach()` resumes cleanly. */
|
|
659
|
+
dispose() {
|
|
660
|
+
if (!this.attached) return;
|
|
661
|
+
this.attached = false;
|
|
662
|
+
this.room.flushMarkRead();
|
|
663
|
+
for (const u of this.unsubs.splice(0)) u();
|
|
664
|
+
if (typeof document !== "undefined") {
|
|
665
|
+
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
|
666
|
+
}
|
|
667
|
+
if (typeof window !== "undefined") {
|
|
668
|
+
window.removeEventListener("pagehide", this.onPageHide);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
// ── external-store contract ─────────────────────────────────────
|
|
672
|
+
/** `useSyncExternalStore`-compatible subscribe. Returns an unsubscribe fn. */
|
|
673
|
+
subscribe = (fn) => {
|
|
674
|
+
this.listeners.add(fn);
|
|
675
|
+
return () => this.listeners.delete(fn);
|
|
676
|
+
};
|
|
677
|
+
getSnapshot = () => this.snapshot;
|
|
678
|
+
emit() {
|
|
679
|
+
this.snapshot = {
|
|
680
|
+
messages: [...this.messages],
|
|
681
|
+
hasMore: this.hasMore,
|
|
682
|
+
readWatermarks: { ...this.readWatermarks }
|
|
683
|
+
};
|
|
684
|
+
for (const fn of this.listeners) fn();
|
|
685
|
+
}
|
|
686
|
+
// ── read receipts ───────────────────────────────────────────────
|
|
687
|
+
/** Whether `userId` has read `messageId` according to the latest watermark. */
|
|
688
|
+
isReadBy(userId, messageId) {
|
|
689
|
+
const w = this.readWatermarks[userId];
|
|
690
|
+
if (!w) return false;
|
|
691
|
+
return !idGreater(messageId, w);
|
|
692
|
+
}
|
|
693
|
+
/** How many of the given users have read `messageId`. Pass the room's
|
|
694
|
+
* member ids (minus the sender) to get a KakaoTalk-style unread count:
|
|
695
|
+
* `members.length - readCount(...)`. */
|
|
696
|
+
readCount(messageId, userIds) {
|
|
697
|
+
let n = 0;
|
|
698
|
+
for (const u of userIds) if (this.isReadBy(u, messageId)) n++;
|
|
699
|
+
return n;
|
|
700
|
+
}
|
|
701
|
+
// ── history ─────────────────────────────────────────────────────
|
|
702
|
+
/** Load the most recent page of history and prepend it. No-op after the
|
|
703
|
+
* first call (StrictMode double-mount safe) — use `loadMore` for
|
|
704
|
+
* scrollback. */
|
|
705
|
+
async loadHistory(limit = 50) {
|
|
706
|
+
if (this.historyRequested) return;
|
|
707
|
+
this.historyRequested = true;
|
|
708
|
+
const page = await this.room.listRecent(limit);
|
|
709
|
+
this.messages.unshift(...page.items.map(chatMessageFromHistory));
|
|
710
|
+
this.oldestCursor = page.nextCursor;
|
|
711
|
+
this.hasMore = page.hasMore;
|
|
712
|
+
this.emit();
|
|
713
|
+
}
|
|
714
|
+
/** Scrollback: load the page of messages older than what's on screen and
|
|
715
|
+
* prepend it. No-op while a previous call is in flight or when `hasMore`
|
|
716
|
+
* is false. Returns the number of messages added. */
|
|
717
|
+
async loadMore(limit = 50) {
|
|
718
|
+
const cursor = this.oldestCursor;
|
|
719
|
+
if (this.loadingMore || !this.hasMore || cursor == null) return 0;
|
|
720
|
+
this.loadingMore = true;
|
|
721
|
+
try {
|
|
722
|
+
const page = await this.room.listBefore(cursor, limit);
|
|
723
|
+
this.messages.unshift(...page.items.map(chatMessageFromHistory));
|
|
724
|
+
this.oldestCursor = page.nextCursor;
|
|
725
|
+
this.hasMore = page.hasMore;
|
|
726
|
+
this.emit();
|
|
727
|
+
return page.items.length;
|
|
728
|
+
} finally {
|
|
729
|
+
this.loadingMore = false;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
// ── outgoing ────────────────────────────────────────────────────
|
|
733
|
+
/** Send `text` optimistically: a `sending` bubble appears immediately,
|
|
734
|
+
* then flips to `sent` (with the real id) on ack, or `failed` on error.
|
|
735
|
+
*
|
|
736
|
+
* Optional extras ride along: `replyToId` for replies, `customType` /
|
|
737
|
+
* `customMeta` for app-defined conventions (e.g. message priority). */
|
|
738
|
+
send(text, opts) {
|
|
739
|
+
void (async () => {
|
|
740
|
+
let tempId;
|
|
741
|
+
let ackPromise;
|
|
742
|
+
try {
|
|
743
|
+
const sent = await this.room.send({
|
|
744
|
+
content: text,
|
|
745
|
+
...opts?.replyToId ? { replyToId: opts.replyToId } : {},
|
|
746
|
+
...opts?.customType ? { customType: opts.customType } : {},
|
|
747
|
+
...opts?.customMeta ? { customMeta: opts.customMeta } : {}
|
|
748
|
+
});
|
|
749
|
+
tempId = sent.tempId;
|
|
750
|
+
ackPromise = sent.messageId;
|
|
751
|
+
} catch {
|
|
752
|
+
const localId = `failed_${Date.now()}_${uploadSeq++}`;
|
|
753
|
+
this.messages.push({
|
|
754
|
+
...pendingChatMessage({ tempId: localId, content: text, ...opts }),
|
|
755
|
+
status: "failed"
|
|
756
|
+
});
|
|
757
|
+
this.emit();
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
this.messages.push(
|
|
761
|
+
pendingChatMessage({ tempId, content: text, ...opts })
|
|
762
|
+
);
|
|
763
|
+
this.emit();
|
|
764
|
+
this.bindAck(tempId, ackPromise);
|
|
765
|
+
})();
|
|
766
|
+
}
|
|
767
|
+
/** Send a file optimistically. A FILE bubble with `uploadProgress`
|
|
768
|
+
* appears immediately; progress advances during the S3 upload, then the
|
|
769
|
+
* bubble flips to `sent` on ack (or `failed`). */
|
|
770
|
+
async sendFile(file, opts) {
|
|
771
|
+
const localId = `upload_${Date.now()}_${uploadSeq++}`;
|
|
772
|
+
this.messages.push(
|
|
773
|
+
pendingFileChatMessage({
|
|
774
|
+
tempId: localId,
|
|
775
|
+
name: opts?.name ?? file.name,
|
|
776
|
+
mime: file.type,
|
|
777
|
+
size: file.size
|
|
778
|
+
})
|
|
779
|
+
);
|
|
780
|
+
this.emit();
|
|
781
|
+
try {
|
|
782
|
+
const sent = await this.room.sendFile(file, {
|
|
783
|
+
...opts ?? {},
|
|
784
|
+
onProgress: (p) => {
|
|
785
|
+
this.mutateByTempId(localId, (m) => ({
|
|
786
|
+
...m,
|
|
787
|
+
uploadProgress: p.ratio
|
|
788
|
+
}));
|
|
789
|
+
opts?.onProgress?.(p);
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
this.mutateByTempId(localId, (m) => ({
|
|
793
|
+
...m,
|
|
794
|
+
fileId: sent.fileId,
|
|
795
|
+
file: m.file ? { ...m.file, id: sent.fileId } : m.file,
|
|
796
|
+
uploadProgress: null
|
|
797
|
+
}));
|
|
798
|
+
const realId = await sent.messageId;
|
|
799
|
+
this.mutateByTempId(localId, (m) => ({
|
|
800
|
+
...m,
|
|
801
|
+
id: realId,
|
|
802
|
+
status: "sent"
|
|
803
|
+
}));
|
|
804
|
+
} catch {
|
|
805
|
+
this.mutateByTempId(localId, (m) => ({
|
|
806
|
+
...m,
|
|
807
|
+
status: "failed",
|
|
808
|
+
uploadProgress: null
|
|
809
|
+
}));
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
/** Edit a message's content (server-side; peers get `message_updated`).
|
|
813
|
+
* The local copy updates optimistically. */
|
|
814
|
+
async edit(messageId, content) {
|
|
815
|
+
await this.room.edit(messageId, { content });
|
|
816
|
+
this.mutateById(messageId, (m) => ({
|
|
817
|
+
...m,
|
|
818
|
+
content,
|
|
819
|
+
editedCount: m.editedCount + 1
|
|
820
|
+
}));
|
|
821
|
+
}
|
|
822
|
+
/** Delete a message. `scope: "ALL"` (default) removes it for everyone;
|
|
823
|
+
* `"MY"` hides it only for the caller. Local copy flips to deleted. */
|
|
824
|
+
async delete(messageId, scope = "ALL") {
|
|
825
|
+
await this.room.delete(messageId, scope);
|
|
826
|
+
this.mutateById(messageId, (m) => ({ ...m, isDeleted: true }));
|
|
827
|
+
}
|
|
828
|
+
/** Add/remove the caller's reaction. Pass `myUserId` so the toggle can
|
|
829
|
+
* tell whether the caller already reacted with `key`. The authoritative
|
|
830
|
+
* state arrives back via the reaction WS events. */
|
|
831
|
+
async toggleReaction(messageId, key, myUserId) {
|
|
832
|
+
const m = this.messages.find((x) => x.id === messageId);
|
|
833
|
+
const mine = m?.reactions.some(
|
|
834
|
+
(r) => r.key === key && r.userIds.includes(myUserId)
|
|
835
|
+
) ?? false;
|
|
836
|
+
if (mine) {
|
|
837
|
+
await this.room.unreact(messageId, key);
|
|
838
|
+
} else {
|
|
839
|
+
await this.room.react(messageId, key);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
/** Search this room's messages (server-side, content substring match).
|
|
843
|
+
* Returns view models without touching the message list. */
|
|
844
|
+
async search(q, limit = 30) {
|
|
845
|
+
const page = await this.room.search(q, { limit });
|
|
846
|
+
return page.items.map(chatMessageFromHistory);
|
|
847
|
+
}
|
|
848
|
+
/** Mark `messageId` read (debounced inside the SDK). */
|
|
849
|
+
markRead(messageId) {
|
|
850
|
+
this.room.markRead(messageId);
|
|
851
|
+
}
|
|
852
|
+
// ── event handlers ──────────────────────────────────────────────
|
|
853
|
+
onIncoming(f) {
|
|
854
|
+
this.messages.push(chatMessageFromLive(f));
|
|
855
|
+
this.emit();
|
|
856
|
+
}
|
|
857
|
+
onUpdated(f) {
|
|
858
|
+
this.mutateById(f.message_id, (m) => ({
|
|
859
|
+
...m,
|
|
860
|
+
content: f.content ?? m.content,
|
|
861
|
+
editedCount: m.editedCount + 1
|
|
862
|
+
}));
|
|
863
|
+
}
|
|
864
|
+
onDeleted(f) {
|
|
865
|
+
this.mutateById(f.message_id, (m) => ({ ...m, isDeleted: true }));
|
|
866
|
+
}
|
|
867
|
+
onCleared(f) {
|
|
868
|
+
const upTo = f.up_to_message_id;
|
|
869
|
+
let changed = false;
|
|
870
|
+
this.messages = this.messages.map((m) => {
|
|
871
|
+
if (!m.isDeleted && (upTo == null || !idGreater(m.id, upTo))) {
|
|
872
|
+
changed = true;
|
|
873
|
+
return { ...m, isDeleted: true };
|
|
874
|
+
}
|
|
875
|
+
return m;
|
|
876
|
+
});
|
|
877
|
+
if (changed) this.emit();
|
|
878
|
+
}
|
|
879
|
+
onReactionAdded(f) {
|
|
880
|
+
this.mutateById(f.message_id, (m) => {
|
|
881
|
+
const next = m.reactions.map(
|
|
882
|
+
(r) => r.key === f.reaction_key ? {
|
|
883
|
+
key: r.key,
|
|
884
|
+
userIds: [.../* @__PURE__ */ new Set([...r.userIds, f.user_id])],
|
|
885
|
+
updatedAt: f.updated_at
|
|
886
|
+
} : r
|
|
887
|
+
);
|
|
888
|
+
if (!next.some((r) => r.key === f.reaction_key)) {
|
|
889
|
+
next.push({
|
|
890
|
+
key: f.reaction_key,
|
|
891
|
+
userIds: [f.user_id],
|
|
892
|
+
updatedAt: f.updated_at
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
return { ...m, reactions: next };
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
onReactionRemoved(f) {
|
|
899
|
+
this.mutateById(f.message_id, (m) => {
|
|
900
|
+
const next = [];
|
|
901
|
+
for (const r of m.reactions) {
|
|
902
|
+
if (r.key !== f.reaction_key) {
|
|
903
|
+
next.push(r);
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
const users = r.userIds.filter((u) => u !== f.user_id);
|
|
907
|
+
if (users.length > 0) {
|
|
908
|
+
next.push({ key: r.key, userIds: users, updatedAt: f.updated_at });
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return { ...m, reactions: next };
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
onSyncTick(f) {
|
|
915
|
+
let changed = false;
|
|
916
|
+
for (const [userId, mid] of Object.entries(f.read_states)) {
|
|
917
|
+
const cur = this.readWatermarks[userId];
|
|
918
|
+
if (!cur || idGreater(mid, cur)) {
|
|
919
|
+
this.readWatermarks[userId] = mid;
|
|
920
|
+
changed = true;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
if (changed) this.emit();
|
|
924
|
+
}
|
|
925
|
+
onVisibilityChange = () => {
|
|
926
|
+
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
|
|
927
|
+
this.room.flushMarkRead();
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
// ── helpers ─────────────────────────────────────────────────────
|
|
931
|
+
bindAck(tempId, messageId) {
|
|
932
|
+
messageId.then((realId) => {
|
|
933
|
+
this.mutateByTempId(tempId, (m) => ({
|
|
934
|
+
...m,
|
|
935
|
+
id: realId,
|
|
936
|
+
status: "sent"
|
|
937
|
+
}));
|
|
938
|
+
}).catch(() => {
|
|
939
|
+
this.mutateByTempId(tempId, (m) => ({ ...m, status: "failed" }));
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
mutateById(id, f) {
|
|
943
|
+
const i = this.messages.findIndex((m) => m.id === id);
|
|
944
|
+
if (i === -1) return;
|
|
945
|
+
this.messages[i] = f(this.messages[i]);
|
|
946
|
+
this.emit();
|
|
947
|
+
}
|
|
948
|
+
mutateByTempId(tempId, f) {
|
|
949
|
+
const i = this.messages.findIndex((m) => m.tempId === tempId);
|
|
950
|
+
if (i === -1) return;
|
|
951
|
+
this.messages[i] = f(this.messages[i]);
|
|
952
|
+
this.emit();
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
// src/use-messages.ts
|
|
957
|
+
import { useEffect as useEffect2, useMemo, useSyncExternalStore } from "react";
|
|
958
|
+
function useMessages(roomId, opts) {
|
|
959
|
+
const chat = useNoveraChat();
|
|
960
|
+
const historyLimit = opts?.historyLimit ?? 50;
|
|
961
|
+
const store = useMemo(() => new RoomStore(chat, roomId), [chat, roomId]);
|
|
962
|
+
useEffect2(() => {
|
|
963
|
+
store.attach();
|
|
964
|
+
void store.loadHistory(historyLimit);
|
|
965
|
+
return () => store.dispose();
|
|
966
|
+
}, [store]);
|
|
967
|
+
const snapshot = useSyncExternalStore(
|
|
968
|
+
store.subscribe,
|
|
969
|
+
store.getSnapshot,
|
|
970
|
+
store.getSnapshot
|
|
971
|
+
);
|
|
972
|
+
return { ...snapshot, store };
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// src/use-member-list.ts
|
|
976
|
+
import { useEffect as useEffect3, useMemo as useMemo2, useSyncExternalStore as useSyncExternalStore2 } from "react";
|
|
977
|
+
function useMemberList(roomId) {
|
|
978
|
+
const chat = useNoveraChat();
|
|
979
|
+
const store = useMemo2(
|
|
980
|
+
() => new MemberListStore(chat, roomId),
|
|
981
|
+
[chat, roomId]
|
|
982
|
+
);
|
|
983
|
+
useEffect3(() => {
|
|
984
|
+
store.attach();
|
|
985
|
+
void store.load();
|
|
986
|
+
return () => store.dispose();
|
|
987
|
+
}, [store]);
|
|
988
|
+
const snapshot = useSyncExternalStore2(
|
|
989
|
+
store.subscribe,
|
|
990
|
+
store.getSnapshot,
|
|
991
|
+
store.getSnapshot
|
|
992
|
+
);
|
|
993
|
+
return { ...snapshot, store };
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// src/use-room.ts
|
|
997
|
+
function useRoom(roomId) {
|
|
998
|
+
return useNoveraChat().room(roomId);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// src/use-room-files.ts
|
|
1002
|
+
import { useEffect as useEffect4, useMemo as useMemo3, useSyncExternalStore as useSyncExternalStore3 } from "react";
|
|
1003
|
+
function useRoomFiles(roomId, opts) {
|
|
1004
|
+
const chat = useNoveraChat();
|
|
1005
|
+
const pageSize = opts?.pageSize ?? 50;
|
|
1006
|
+
const store = useMemo3(
|
|
1007
|
+
() => new RoomFilesStore(chat, roomId, pageSize),
|
|
1008
|
+
[chat, roomId, pageSize]
|
|
1009
|
+
);
|
|
1010
|
+
useEffect4(() => {
|
|
1011
|
+
store.attach();
|
|
1012
|
+
void store.refresh();
|
|
1013
|
+
return () => store.dispose();
|
|
1014
|
+
}, [store]);
|
|
1015
|
+
const snapshot = useSyncExternalStore3(
|
|
1016
|
+
store.subscribe,
|
|
1017
|
+
store.getSnapshot,
|
|
1018
|
+
store.getSnapshot
|
|
1019
|
+
);
|
|
1020
|
+
return { ...snapshot, store };
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// src/use-room-list.ts
|
|
1024
|
+
import { useEffect as useEffect5, useMemo as useMemo4, useRef, useSyncExternalStore as useSyncExternalStore4 } from "react";
|
|
1025
|
+
function useRoomList(opts) {
|
|
1026
|
+
const chat = useNoveraChat();
|
|
1027
|
+
const optsRef = useRef(opts);
|
|
1028
|
+
const store = useMemo4(
|
|
1029
|
+
() => new RoomListStore(chat, optsRef.current ?? {}),
|
|
1030
|
+
[chat]
|
|
1031
|
+
);
|
|
1032
|
+
useEffect5(() => {
|
|
1033
|
+
store.attach();
|
|
1034
|
+
void store.load();
|
|
1035
|
+
return () => store.dispose();
|
|
1036
|
+
}, [store]);
|
|
1037
|
+
const snapshot = useSyncExternalStore4(
|
|
1038
|
+
store.subscribe,
|
|
1039
|
+
store.getSnapshot,
|
|
1040
|
+
store.getSnapshot
|
|
1041
|
+
);
|
|
1042
|
+
return { ...snapshot, store };
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// src/use-room-settings.ts
|
|
1046
|
+
import { useEffect as useEffect6, useMemo as useMemo5, useSyncExternalStore as useSyncExternalStore5 } from "react";
|
|
1047
|
+
function useRoomSettings(roomId) {
|
|
1048
|
+
const chat = useNoveraChat();
|
|
1049
|
+
const store = useMemo5(
|
|
1050
|
+
() => new RoomSettingsStore(chat, roomId),
|
|
1051
|
+
[chat, roomId]
|
|
1052
|
+
);
|
|
1053
|
+
useEffect6(() => {
|
|
1054
|
+
store.attach();
|
|
1055
|
+
return () => store.dispose();
|
|
1056
|
+
}, [store]);
|
|
1057
|
+
const snapshot = useSyncExternalStore5(
|
|
1058
|
+
store.subscribe,
|
|
1059
|
+
store.getSnapshot,
|
|
1060
|
+
store.getSnapshot
|
|
1061
|
+
);
|
|
1062
|
+
return { ...snapshot, store };
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// src/use-typing.ts
|
|
1066
|
+
import { useEffect as useEffect7, useRef as useRef2, useState as useState2 } from "react";
|
|
1067
|
+
function useTyping(roomId, opts) {
|
|
1068
|
+
const chat = useNoveraChat();
|
|
1069
|
+
const timeoutMs = opts?.timeoutMs ?? 5e3;
|
|
1070
|
+
const [typingUserIds, setTypingUserIds] = useState2([]);
|
|
1071
|
+
const timers = useRef2(/* @__PURE__ */ new Map());
|
|
1072
|
+
useEffect7(() => {
|
|
1073
|
+
const room = chat.room(roomId);
|
|
1074
|
+
const timerMap = timers.current;
|
|
1075
|
+
const stop = (userId) => {
|
|
1076
|
+
const t = timerMap.get(userId);
|
|
1077
|
+
if (t) clearTimeout(t);
|
|
1078
|
+
timerMap.delete(userId);
|
|
1079
|
+
setTypingUserIds(
|
|
1080
|
+
(cur) => cur.includes(userId) ? cur.filter((u) => u !== userId) : cur
|
|
1081
|
+
);
|
|
1082
|
+
};
|
|
1083
|
+
const start = (userId) => {
|
|
1084
|
+
const t = timerMap.get(userId);
|
|
1085
|
+
if (t) clearTimeout(t);
|
|
1086
|
+
timerMap.set(userId, setTimeout(() => stop(userId), timeoutMs));
|
|
1087
|
+
setTypingUserIds(
|
|
1088
|
+
(cur) => cur.includes(userId) ? cur : [...cur, userId]
|
|
1089
|
+
);
|
|
1090
|
+
};
|
|
1091
|
+
const unsub = room.on("typing", (f) => {
|
|
1092
|
+
if (f.is_typing) start(f.user_id);
|
|
1093
|
+
else stop(f.user_id);
|
|
1094
|
+
});
|
|
1095
|
+
return () => {
|
|
1096
|
+
unsub();
|
|
1097
|
+
for (const t of timerMap.values()) clearTimeout(t);
|
|
1098
|
+
timerMap.clear();
|
|
1099
|
+
setTypingUserIds([]);
|
|
1100
|
+
};
|
|
1101
|
+
}, [chat, roomId, timeoutMs]);
|
|
1102
|
+
return {
|
|
1103
|
+
typingUserIds,
|
|
1104
|
+
isAnyoneTyping: typingUserIds.length > 0,
|
|
1105
|
+
setTyping: (isTyping) => chat.room(roomId).setTyping(isTyping)
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// src/use-unread.ts
|
|
1110
|
+
import { useCallback, useEffect as useEffect8, useState as useState3 } from "react";
|
|
1111
|
+
function useUnread(opts) {
|
|
1112
|
+
const chat = useNoveraChat();
|
|
1113
|
+
const refreshIntervalMs = opts?.refreshIntervalMs;
|
|
1114
|
+
const [summary, setSummary] = useState3(null);
|
|
1115
|
+
const refresh = useCallback(async () => {
|
|
1116
|
+
setSummary(await chat.unreadSummary());
|
|
1117
|
+
}, [chat]);
|
|
1118
|
+
useEffect8(() => {
|
|
1119
|
+
let alive = true;
|
|
1120
|
+
const tick = async () => {
|
|
1121
|
+
try {
|
|
1122
|
+
const s = await chat.unreadSummary();
|
|
1123
|
+
if (alive) setSummary(s);
|
|
1124
|
+
} catch {
|
|
1125
|
+
}
|
|
1126
|
+
};
|
|
1127
|
+
void tick();
|
|
1128
|
+
const timer = refreshIntervalMs != null && refreshIntervalMs > 0 ? setInterval(() => void tick(), refreshIntervalMs) : null;
|
|
1129
|
+
return () => {
|
|
1130
|
+
alive = false;
|
|
1131
|
+
if (timer) clearInterval(timer);
|
|
1132
|
+
};
|
|
1133
|
+
}, [chat, refreshIntervalMs]);
|
|
1134
|
+
return { total: summary?.total ?? 0, summary, refresh };
|
|
1135
|
+
}
|
|
1136
|
+
export {
|
|
1137
|
+
MemberListStore,
|
|
1138
|
+
NoveraChatProvider,
|
|
1139
|
+
RoomFilesStore,
|
|
1140
|
+
RoomListStore,
|
|
1141
|
+
RoomSettingsStore,
|
|
1142
|
+
RoomStore,
|
|
1143
|
+
chatMessageFromHistory,
|
|
1144
|
+
chatMessageFromLive,
|
|
1145
|
+
useMemberList,
|
|
1146
|
+
useMessages,
|
|
1147
|
+
useNoveraChat,
|
|
1148
|
+
useRoom,
|
|
1149
|
+
useRoomFiles,
|
|
1150
|
+
useRoomList,
|
|
1151
|
+
useRoomSettings,
|
|
1152
|
+
useTyping,
|
|
1153
|
+
useUnread
|
|
1154
|
+
};
|