@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
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DeleteScope,
|
|
3
|
+
NoveraChat,
|
|
4
|
+
ReactionEntry,
|
|
5
|
+
Room,
|
|
6
|
+
SendFileOptions,
|
|
7
|
+
WsChatReceive,
|
|
8
|
+
WsMessageDeleted,
|
|
9
|
+
WsMessagesCleared,
|
|
10
|
+
WsMessageUpdated,
|
|
11
|
+
WsReactionAdded,
|
|
12
|
+
WsReactionRemoved,
|
|
13
|
+
WsSyncTick,
|
|
14
|
+
} from "@noverachat/sdk-web";
|
|
15
|
+
|
|
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";
|
|
24
|
+
|
|
25
|
+
/** Immutable snapshot handed to React via `useSyncExternalStore`. A new
|
|
26
|
+
* object (new array/record identities) is produced on every change. */
|
|
27
|
+
export interface RoomSnapshot {
|
|
28
|
+
/** The current messages, oldest first. */
|
|
29
|
+
messages: ChatMessage[];
|
|
30
|
+
/** True when older history remains — show a "load more" affordance. */
|
|
31
|
+
hasMore: boolean;
|
|
32
|
+
/** Per-user read watermarks (`userId` → last read `messageId`), kept live
|
|
33
|
+
* from `sync_tick` frames. */
|
|
34
|
+
readWatermarks: Readonly<Record<string, string>>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let uploadSeq = 0;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Turns a `Room`'s events into an externally-subscribable message list and
|
|
41
|
+
* drives optimistic sends. Framework-agnostic on purpose so it can also be
|
|
42
|
+
* used outside hooks (tests, non-React glue).
|
|
43
|
+
*
|
|
44
|
+
* Lifecycle: construct → `attach()` (subscribe WS events; idempotent) →
|
|
45
|
+
* `dispose()` (flush read watermark + unsubscribe). `useMessages` does all
|
|
46
|
+
* three for you.
|
|
47
|
+
*
|
|
48
|
+
* Holds `(chat, roomId)` rather than a `Room` instance and re-resolves the
|
|
49
|
+
* live facade on every use — `chat.disconnect()` drops its Room facades, so
|
|
50
|
+
* a remounted provider (React StrictMode simulates this) would otherwise
|
|
51
|
+
* leave the store subscribed to an orphaned Room that no longer receives
|
|
52
|
+
* dispatches.
|
|
53
|
+
*
|
|
54
|
+
* ```ts
|
|
55
|
+
* const store = new RoomStore(chat, "room_123");
|
|
56
|
+
* store.attach();
|
|
57
|
+
* const unsub = store.subscribe(() => render(store.getSnapshot()));
|
|
58
|
+
* await store.loadHistory();
|
|
59
|
+
* store.send("hello");
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export class RoomStore {
|
|
63
|
+
private readonly chat: NoveraChat;
|
|
64
|
+
readonly roomId: string;
|
|
65
|
+
private readonly listeners = new Set<() => void>();
|
|
66
|
+
private readonly unsubs: Array<() => void> = [];
|
|
67
|
+
|
|
68
|
+
private messages: ChatMessage[] = [];
|
|
69
|
+
private readWatermarks: Record<string, string> = {};
|
|
70
|
+
private hasMore = false;
|
|
71
|
+
private oldestCursor: string | null = null;
|
|
72
|
+
private loadingMore = false;
|
|
73
|
+
private historyRequested = false;
|
|
74
|
+
private snapshot: RoomSnapshot;
|
|
75
|
+
private attached = false;
|
|
76
|
+
private readonly onPageHide = () => this.room.flushMarkRead();
|
|
77
|
+
|
|
78
|
+
constructor(chat: NoveraChat, roomId: string) {
|
|
79
|
+
this.chat = chat;
|
|
80
|
+
this.roomId = roomId;
|
|
81
|
+
this.snapshot = {
|
|
82
|
+
messages: [],
|
|
83
|
+
hasMore: false,
|
|
84
|
+
readWatermarks: {},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The live `Room` facade — resolved from the client on every access
|
|
89
|
+
* (get-or-create in a map, cheap) so we never act on an orphan. */
|
|
90
|
+
private get room(): Room {
|
|
91
|
+
return this.chat.room(this.roomId);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Subscribe to WS events + page lifecycle. Safe to call repeatedly
|
|
95
|
+
* (no-op while attached) — matches React 18/19 StrictMode's
|
|
96
|
+
* mount → cleanup → mount effect cycle. */
|
|
97
|
+
attach(): void {
|
|
98
|
+
if (this.attached) return;
|
|
99
|
+
this.attached = true;
|
|
100
|
+
const room = this.room; // resolve once — all subs must share one facade
|
|
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
|
+
);
|
|
110
|
+
// Flush the pending read watermark when the tab goes to the background,
|
|
111
|
+
// so the last-read position isn't lost to the debounce window.
|
|
112
|
+
if (typeof document !== "undefined") {
|
|
113
|
+
document.addEventListener("visibilitychange", this.onVisibilityChange);
|
|
114
|
+
}
|
|
115
|
+
if (typeof window !== "undefined") {
|
|
116
|
+
window.addEventListener("pagehide", this.onPageHide);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Unsubscribe everything and flush the pending read watermark. The store
|
|
121
|
+
* keeps its message state, so a later `attach()` resumes cleanly. */
|
|
122
|
+
dispose(): void {
|
|
123
|
+
if (!this.attached) return;
|
|
124
|
+
this.attached = false;
|
|
125
|
+
this.room.flushMarkRead();
|
|
126
|
+
for (const u of this.unsubs.splice(0)) u();
|
|
127
|
+
if (typeof document !== "undefined") {
|
|
128
|
+
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
|
129
|
+
}
|
|
130
|
+
if (typeof window !== "undefined") {
|
|
131
|
+
window.removeEventListener("pagehide", this.onPageHide);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── external-store contract ─────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/** `useSyncExternalStore`-compatible subscribe. Returns an unsubscribe fn. */
|
|
138
|
+
readonly subscribe = (fn: () => void): (() => void) => {
|
|
139
|
+
this.listeners.add(fn);
|
|
140
|
+
return () => this.listeners.delete(fn);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
readonly getSnapshot = (): RoomSnapshot => this.snapshot;
|
|
144
|
+
|
|
145
|
+
private emit(): void {
|
|
146
|
+
this.snapshot = {
|
|
147
|
+
messages: [...this.messages],
|
|
148
|
+
hasMore: this.hasMore,
|
|
149
|
+
readWatermarks: { ...this.readWatermarks },
|
|
150
|
+
};
|
|
151
|
+
for (const fn of this.listeners) fn();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── read receipts ───────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/** Whether `userId` has read `messageId` according to the latest watermark. */
|
|
157
|
+
isReadBy(userId: string, messageId: string): boolean {
|
|
158
|
+
const w = this.readWatermarks[userId];
|
|
159
|
+
if (!w) return false;
|
|
160
|
+
return !idGreater(messageId, w);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** How many of the given users have read `messageId`. Pass the room's
|
|
164
|
+
* member ids (minus the sender) to get a KakaoTalk-style unread count:
|
|
165
|
+
* `members.length - readCount(...)`. */
|
|
166
|
+
readCount(messageId: string, userIds: Iterable<string>): number {
|
|
167
|
+
let n = 0;
|
|
168
|
+
for (const u of userIds) if (this.isReadBy(u, messageId)) n++;
|
|
169
|
+
return n;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── history ─────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/** Load the most recent page of history and prepend it. No-op after the
|
|
175
|
+
* first call (StrictMode double-mount safe) — use `loadMore` for
|
|
176
|
+
* scrollback. */
|
|
177
|
+
async loadHistory(limit = 50): Promise<void> {
|
|
178
|
+
if (this.historyRequested) return;
|
|
179
|
+
this.historyRequested = true;
|
|
180
|
+
const page = await this.room.listRecent(limit);
|
|
181
|
+
this.messages.unshift(...page.items.map(chatMessageFromHistory));
|
|
182
|
+
this.oldestCursor = page.nextCursor;
|
|
183
|
+
this.hasMore = page.hasMore;
|
|
184
|
+
this.emit();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Scrollback: load the page of messages older than what's on screen and
|
|
188
|
+
* prepend it. No-op while a previous call is in flight or when `hasMore`
|
|
189
|
+
* is false. Returns the number of messages added. */
|
|
190
|
+
async loadMore(limit = 50): Promise<number> {
|
|
191
|
+
const cursor = this.oldestCursor;
|
|
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
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── outgoing ────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
/** Send `text` optimistically: a `sending` bubble appears immediately,
|
|
209
|
+
* then flips to `sent` (with the real id) on ack, or `failed` on error.
|
|
210
|
+
*
|
|
211
|
+
* Optional extras ride along: `replyToId` for replies, `customType` /
|
|
212
|
+
* `customMeta` for app-defined conventions (e.g. message priority). */
|
|
213
|
+
send(
|
|
214
|
+
text: string,
|
|
215
|
+
opts?: {
|
|
216
|
+
replyToId?: string;
|
|
217
|
+
customType?: string;
|
|
218
|
+
customMeta?: Record<string, unknown>;
|
|
219
|
+
},
|
|
220
|
+
): void {
|
|
221
|
+
void (async () => {
|
|
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
|
+
})();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Send a file optimistically. A FILE bubble with `uploadProgress`
|
|
253
|
+
* appears immediately; progress advances during the S3 upload, then the
|
|
254
|
+
* bubble flips to `sent` on ack (or `failed`). */
|
|
255
|
+
async sendFile(file: File, opts?: SendFileOptions): Promise<void> {
|
|
256
|
+
// Local temp id for the optimistic bubble — the real WS temp id only
|
|
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
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Edit a message's content (server-side; peers get `message_updated`).
|
|
303
|
+
* The local copy updates optimistically. */
|
|
304
|
+
async edit(messageId: string, content: string): Promise<void> {
|
|
305
|
+
await this.room.edit(messageId, { content });
|
|
306
|
+
this.mutateById(messageId, (m) => ({
|
|
307
|
+
...m,
|
|
308
|
+
content,
|
|
309
|
+
editedCount: m.editedCount + 1,
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Delete a message. `scope: "ALL"` (default) removes it for everyone;
|
|
314
|
+
* `"MY"` hides it only for the caller. Local copy flips to deleted. */
|
|
315
|
+
async delete(messageId: string, scope: DeleteScope = "ALL"): Promise<void> {
|
|
316
|
+
await this.room.delete(messageId, scope);
|
|
317
|
+
this.mutateById(messageId, (m) => ({ ...m, isDeleted: true }));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Add/remove the caller's reaction. Pass `myUserId` so the toggle can
|
|
321
|
+
* tell whether the caller already reacted with `key`. The authoritative
|
|
322
|
+
* state arrives back via the reaction WS events. */
|
|
323
|
+
async toggleReaction(
|
|
324
|
+
messageId: string,
|
|
325
|
+
key: string,
|
|
326
|
+
myUserId: string,
|
|
327
|
+
): Promise<void> {
|
|
328
|
+
const m = this.messages.find((x) => x.id === messageId);
|
|
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
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Search this room's messages (server-side, content substring match).
|
|
341
|
+
* Returns view models without touching the message list. */
|
|
342
|
+
async search(q: string, limit = 30): Promise<ChatMessage[]> {
|
|
343
|
+
const page = await this.room.search(q, { limit });
|
|
344
|
+
return page.items.map(chatMessageFromHistory);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Mark `messageId` read (debounced inside the SDK). */
|
|
348
|
+
markRead(messageId: string): void {
|
|
349
|
+
this.room.markRead(messageId);
|
|
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();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private onVisibilityChange = (): void => {
|
|
438
|
+
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
|
|
439
|
+
this.room.flushMarkRead();
|
|
440
|
+
}
|
|
441
|
+
};
|
|
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
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useEffect, useMemo, useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
import { useNoveraChat } from "./context.js";
|
|
4
|
+
import {
|
|
5
|
+
MemberListStore,
|
|
6
|
+
type MemberListSnapshot,
|
|
7
|
+
} from "./member-list-store.js";
|
|
8
|
+
|
|
9
|
+
export interface UseMemberListResult extends MemberListSnapshot {
|
|
10
|
+
/** The backing store — `load`, `isOperator`. */
|
|
11
|
+
store: MemberListStore;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Live member list of a room — roles, presence dots, mute state. Reloads
|
|
16
|
+
* on membership events; presence transitions update in place.
|
|
17
|
+
*
|
|
18
|
+
* Combine with `useMessages` for read receipts:
|
|
19
|
+
* `activeMemberIds.length - 1 - store.readCount(messageId, activeMemberIds)`
|
|
20
|
+
* gives a KakaoTalk-style "unread N" per message.
|
|
21
|
+
*
|
|
22
|
+
* ```tsx
|
|
23
|
+
* const { members, operators } = useMemberList("room_123");
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function useMemberList(roomId: string): UseMemberListResult {
|
|
27
|
+
const chat = useNoveraChat();
|
|
28
|
+
const store = useMemo(
|
|
29
|
+
() => new MemberListStore(chat, roomId),
|
|
30
|
+
[chat, roomId],
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
store.attach();
|
|
35
|
+
void store.load();
|
|
36
|
+
return () => store.dispose();
|
|
37
|
+
}, [store]);
|
|
38
|
+
|
|
39
|
+
const snapshot = useSyncExternalStore(
|
|
40
|
+
store.subscribe,
|
|
41
|
+
store.getSnapshot,
|
|
42
|
+
store.getSnapshot,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return { ...snapshot, store };
|
|
46
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useEffect, useMemo, useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
import { useNoveraChat } from "./context.js";
|
|
4
|
+
import { RoomStore, type RoomSnapshot } from "./room-store.js";
|
|
5
|
+
|
|
6
|
+
export interface UseMessagesOptions {
|
|
7
|
+
/** Page size for the initial history load (and a sensible default for
|
|
8
|
+
* scrollback pages). Default 50. */
|
|
9
|
+
historyLimit?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface UseMessagesResult extends RoomSnapshot {
|
|
13
|
+
/** The backing store — exposes `send`, `sendFile`, `edit`, `delete`,
|
|
14
|
+
* `toggleReaction`, `markRead`, `loadMore`, `search`, `isReadBy`,
|
|
15
|
+
* `readCount`. Stable identity for the lifetime of `(client, roomId)`. */
|
|
16
|
+
store: RoomStore;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The live message list of a room, plus the store that drives it.
|
|
21
|
+
*
|
|
22
|
+
* Subscribes to the room's WS events, loads the first history page on
|
|
23
|
+
* mount, and re-renders on every change — new/edited/deleted messages,
|
|
24
|
+
* reactions, read watermarks, optimistic send state.
|
|
25
|
+
*
|
|
26
|
+
* ```tsx
|
|
27
|
+
* const { messages, hasMore, store } = useMessages("room_123");
|
|
28
|
+
* // ...
|
|
29
|
+
* <button onClick={() => store.loadMore()} disabled={!hasMore}>more</button>
|
|
30
|
+
* {messages.map((m) => <Bubble key={m.id} message={m} />)}
|
|
31
|
+
* <input onKeyDown={(e) => e.key === "Enter" && store.send(draft)} />
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function useMessages(
|
|
35
|
+
roomId: string,
|
|
36
|
+
opts?: UseMessagesOptions,
|
|
37
|
+
): UseMessagesResult {
|
|
38
|
+
const chat = useNoveraChat();
|
|
39
|
+
const historyLimit = opts?.historyLimit ?? 50;
|
|
40
|
+
|
|
41
|
+
// Construction is side-effect free — subscriptions happen in attach(),
|
|
42
|
+
// so a StrictMode double-render leaking an extra never-attached store
|
|
43
|
+
// is harmless.
|
|
44
|
+
const store = useMemo(() => new RoomStore(chat, roomId), [chat, roomId]);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
store.attach();
|
|
48
|
+
void store.loadHistory(historyLimit);
|
|
49
|
+
return () => store.dispose();
|
|
50
|
+
// historyLimit is intentionally not a dep — it only matters for the
|
|
51
|
+
// first load; changing it later shouldn't reset the room.
|
|
52
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
53
|
+
}, [store]);
|
|
54
|
+
|
|
55
|
+
const snapshot = useSyncExternalStore(
|
|
56
|
+
store.subscribe,
|
|
57
|
+
store.getSnapshot,
|
|
58
|
+
store.getSnapshot,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return { ...snapshot, store };
|
|
62
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useEffect, useMemo, useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
import { useNoveraChat } from "./context.js";
|
|
4
|
+
import {
|
|
5
|
+
RoomFilesStore,
|
|
6
|
+
type RoomFilesSnapshot,
|
|
7
|
+
} from "./room-files-store.js";
|
|
8
|
+
|
|
9
|
+
export interface UseRoomFilesOptions {
|
|
10
|
+
/** Page size for both the initial load and `loadMore`. Default 50. */
|
|
11
|
+
pageSize?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface UseRoomFilesResult extends RoomFilesSnapshot {
|
|
15
|
+
/** The backing store — `refresh`, `loadMore`. */
|
|
16
|
+
store: RoomFilesStore;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Paginated media/file grid of a room (파일함·앨범). Newest first; call
|
|
21
|
+
* `store.loadMore()` from the grid's tail while `hasMore`, and
|
|
22
|
+
* `store.refresh()` after an upload to surface it at the head. Render
|
|
23
|
+
* thumbnails with `chat.fileThumbnailUrl(item.file.id, token)`.
|
|
24
|
+
*
|
|
25
|
+
* ```tsx
|
|
26
|
+
* const { media, hasMore, store } = useRoomFiles("room_123");
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export function useRoomFiles(
|
|
30
|
+
roomId: string,
|
|
31
|
+
opts?: UseRoomFilesOptions,
|
|
32
|
+
): UseRoomFilesResult {
|
|
33
|
+
const chat = useNoveraChat();
|
|
34
|
+
const pageSize = opts?.pageSize ?? 50;
|
|
35
|
+
const store = useMemo(
|
|
36
|
+
() => new RoomFilesStore(chat, roomId, pageSize),
|
|
37
|
+
[chat, roomId, pageSize],
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
store.attach();
|
|
42
|
+
void store.refresh();
|
|
43
|
+
return () => store.dispose();
|
|
44
|
+
}, [store]);
|
|
45
|
+
|
|
46
|
+
const snapshot = useSyncExternalStore(
|
|
47
|
+
store.subscribe,
|
|
48
|
+
store.getSnapshot,
|
|
49
|
+
store.getSnapshot,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return { ...snapshot, store };
|
|
53
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
import { useNoveraChat } from "./context.js";
|
|
4
|
+
import {
|
|
5
|
+
RoomListStore,
|
|
6
|
+
type RoomListSnapshot,
|
|
7
|
+
type RoomListStoreOptions,
|
|
8
|
+
} from "./room-list-store.js";
|
|
9
|
+
|
|
10
|
+
export interface UseRoomListResult extends RoomListSnapshot {
|
|
11
|
+
/** The backing store — `load`, `markRead`, `hideRoom`, `unhideRoom`. */
|
|
12
|
+
store: RoomListStore;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Live chat-room list (채팅 탭): previews, unread badges and
|
|
17
|
+
* most-recent-first ordering, kept fresh from WS streams.
|
|
18
|
+
*
|
|
19
|
+
* `opts.previewBuilder` / `opts.isRoomVisible` are read once (first
|
|
20
|
+
* render). Call `store.markRead(roomId)` when returning from a room screen
|
|
21
|
+
* to clear its badge.
|
|
22
|
+
*
|
|
23
|
+
* ```tsx
|
|
24
|
+
* const { rooms, totalUnread, store } = useRoomList();
|
|
25
|
+
* // ...
|
|
26
|
+
* {rooms.map((r) => (
|
|
27
|
+
* <li key={r.roomId} onClick={() => openRoom(r.roomId)}>
|
|
28
|
+
* {r.name} — {r.lastMessagePreview}
|
|
29
|
+
* {r.unreadCount > 0 && <b>{r.unreadCount}</b>}
|
|
30
|
+
* </li>
|
|
31
|
+
* ))}
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function useRoomList(opts?: RoomListStoreOptions): UseRoomListResult {
|
|
35
|
+
const chat = useNoveraChat();
|
|
36
|
+
// Options are captured once — they're behavior callbacks, not reactive
|
|
37
|
+
// inputs; recreating the store per render would drop the live list.
|
|
38
|
+
const optsRef = useRef(opts);
|
|
39
|
+
const store = useMemo(
|
|
40
|
+
() => new RoomListStore(chat, optsRef.current ?? {}),
|
|
41
|
+
[chat],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
store.attach();
|
|
46
|
+
void store.load();
|
|
47
|
+
return () => store.dispose();
|
|
48
|
+
}, [store]);
|
|
49
|
+
|
|
50
|
+
const snapshot = useSyncExternalStore(
|
|
51
|
+
store.subscribe,
|
|
52
|
+
store.getSnapshot,
|
|
53
|
+
store.getSnapshot,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return { ...snapshot, store };
|
|
57
|
+
}
|