@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/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@noverachat/sdk-react",
3
+ "version": "0.3.0",
4
+ "description": "React hooks/bindings for NoveraChat — @noverachat/sdk-web 위에 얹는 얇은 래퍼",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.cjs",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "require": "./dist/index.cjs"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "src",
20
+ "README.md"
21
+ ],
22
+ "sideEffects": false,
23
+ "peerDependencies": {
24
+ "react": ">=18"
25
+ },
26
+ "dependencies": {
27
+ "@noverachat/sdk-web": "0.3.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/react": "^19.0.0",
31
+ "tsup": "^8.3.5",
32
+ "typescript": "^5.6.3",
33
+ "vitest": "^2.1.4"
34
+ },
35
+ "scripts": {
36
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean",
37
+ "dev": "tsup src/index.ts --format esm,cjs --dts --watch",
38
+ "typecheck": "tsc --noEmit",
39
+ "test": "vitest run",
40
+ "test:watch": "vitest"
41
+ }
42
+ }
@@ -0,0 +1,209 @@
1
+ import type {
2
+ FileInline,
3
+ MessageOut,
4
+ ReactionEntry,
5
+ SenderMini,
6
+ WsChatReceive,
7
+ } 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
+ }
@@ -0,0 +1,72 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useState,
6
+ type ReactNode,
7
+ } from "react";
8
+ import { NoveraChat, type ClientOptions } from "@noverachat/sdk-web";
9
+
10
+ const NoveraChatContext = createContext<NoveraChat | null>(null);
11
+
12
+ export interface NoveraChatProviderProps {
13
+ /** Options for the `NoveraChat` this provider creates. Read **once**, when
14
+ * the provider first mounts — to connect with different options, give the
15
+ * provider a new `key` so it remounts. */
16
+ options: ClientOptions;
17
+ children: ReactNode;
18
+ }
19
+
20
+ /**
21
+ * Provides a connected `NoveraChat` to the component subtree.
22
+ *
23
+ * Mount it once near the top of your app. It creates the client, calls
24
+ * `connect()` on mount and `disconnect()` on unmount. Descendants read the
25
+ * client with `useNoveraChat()`.
26
+ *
27
+ * ```tsx
28
+ * <NoveraChatProvider
29
+ * options={{
30
+ * appId: "app_9f8k2x",
31
+ * endpoint: "https://chat.example.com",
32
+ * tokenProvider: async () => fetchJwt(),
33
+ * }}
34
+ * >
35
+ * <ChatScreen />
36
+ * </NoveraChatProvider>
37
+ * ```
38
+ */
39
+ export function NoveraChatProvider(props: NoveraChatProviderProps): ReactNode {
40
+ // useState initializer = create exactly once per mounted provider.
41
+ const [chat] = useState(() => new NoveraChat(props.options));
42
+
43
+ useEffect(() => {
44
+ void chat.connect();
45
+ return () => chat.disconnect();
46
+ }, [chat]);
47
+
48
+ return (
49
+ <NoveraChatContext.Provider value={chat}>
50
+ {props.children}
51
+ </NoveraChatContext.Provider>
52
+ );
53
+ }
54
+
55
+ /**
56
+ * The `NoveraChat` from the nearest enclosing `NoveraChatProvider`.
57
+ * Throws when there is no provider above in the tree.
58
+ *
59
+ * ```tsx
60
+ * const chat = useNoveraChat();
61
+ * const room = chat.room("room_123");
62
+ * ```
63
+ */
64
+ export function useNoveraChat(): NoveraChat {
65
+ const chat = useContext(NoveraChatContext);
66
+ if (!chat) {
67
+ throw new Error(
68
+ "useNoveraChat() called with no NoveraChatProvider in the tree.",
69
+ );
70
+ }
71
+ return chat;
72
+ }
package/src/index.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @noverachat/sdk-react — NoveraChat React 바인딩.
3
+ *
4
+ * `@noverachat/sdk-web`(NoveraChat/Room 파사드) 위의 얇은 훅 레이어.
5
+ * 채팅 로직을 재구현하지 않고, SDK 이벤트를 React 상태로 옮기고 언마운트
6
+ * 정리를 대신한다.
7
+ *
8
+ * - NoveraChatProvider / useNoveraChat 클라이언트 생성 + connect/disconnect
9
+ * - useRoom(roomId) Room 파사드 접근
10
+ * - useMessages(roomId) 메시지 리스트 + 낙관적 send (RoomStore)
11
+ * - useTyping(roomId) "입력 중…" 표시
12
+ * - useUnread() 안읽음 뱃지
13
+ * - useRoomList() 채팅방 리스트 (채팅 탭)
14
+ * - useMemberList(roomId) 멤버 목록 + presence
15
+ * - useRoomFiles(roomId) 파일함·앨범 페이지네이션
16
+ * - useRoomSettings(roomId) 방 설정 액션 (무음·초대·모더레이션)
17
+ */
18
+
19
+ // Re-export the headless core so consumers get `NoveraChat`, `Room`,
20
+ // `ClientOptions`, message/event types, etc. from a single import.
21
+ export * from "@noverachat/sdk-web";
22
+
23
+ export {
24
+ chatMessageFromHistory,
25
+ chatMessageFromLive,
26
+ type ChatMessage,
27
+ type ChatMessageStatus,
28
+ } from "./chat-message.js";
29
+ export {
30
+ NoveraChatProvider,
31
+ useNoveraChat,
32
+ type NoveraChatProviderProps,
33
+ } from "./context.js";
34
+ export {
35
+ MemberListStore,
36
+ type MemberListSnapshot,
37
+ } from "./member-list-store.js";
38
+ export {
39
+ RoomFilesStore,
40
+ type RoomFileItem,
41
+ type RoomFilesSnapshot,
42
+ } from "./room-files-store.js";
43
+ export {
44
+ RoomListStore,
45
+ type RoomListItem,
46
+ type RoomListSnapshot,
47
+ type RoomListStoreOptions,
48
+ } from "./room-list-store.js";
49
+ export {
50
+ RoomSettingsStore,
51
+ type RoomSettingsSnapshot,
52
+ } from "./room-settings-store.js";
53
+ export { RoomStore, type RoomSnapshot } from "./room-store.js";
54
+ export {
55
+ useMessages,
56
+ type UseMessagesOptions,
57
+ type UseMessagesResult,
58
+ } from "./use-messages.js";
59
+ export {
60
+ useMemberList,
61
+ type UseMemberListResult,
62
+ } from "./use-member-list.js";
63
+ export { useRoom } from "./use-room.js";
64
+ export {
65
+ useRoomFiles,
66
+ type UseRoomFilesOptions,
67
+ type UseRoomFilesResult,
68
+ } from "./use-room-files.js";
69
+ export { useRoomList, type UseRoomListResult } from "./use-room-list.js";
70
+ export {
71
+ useRoomSettings,
72
+ type UseRoomSettingsResult,
73
+ } from "./use-room-settings.js";
74
+ export {
75
+ useTyping,
76
+ type UseTypingOptions,
77
+ type UseTypingResult,
78
+ } from "./use-typing.js";
79
+ export {
80
+ useUnread,
81
+ type UseUnreadOptions,
82
+ type UseUnreadResult,
83
+ } from "./use-unread.js";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Snowflake ids are 63-bit ints as strings — compare via BigInt (numeric
3
+ * `>` on the string form is wrong for unequal lengths). Private copy of the
4
+ * core's internal helper.
5
+ */
6
+ export function idGreater(
7
+ a: string | null | undefined,
8
+ b: string | null | undefined,
9
+ ): boolean {
10
+ if (!a || a === "0") return false;
11
+ if (!b || b === "0") return true;
12
+ try {
13
+ return BigInt(a) > BigInt(b);
14
+ } catch {
15
+ return a > b;
16
+ }
17
+ }
@@ -0,0 +1,116 @@
1
+ import type { NoveraChat, RoomMemberOut, WsPresence } from "@noverachat/sdk-web";
2
+
3
+ export interface MemberListSnapshot {
4
+ /** Current members (server order). Empty until `load` completes. */
5
+ members: RoomMemberOut[];
6
+ /** Members with the OPERATOR role — e.g. to show a crown badge or gate
7
+ * operator-only actions client-side. */
8
+ operators: RoomMemberOut[];
9
+ /** Active (non-KICKED) member ids — handy as the `userIds` argument of
10
+ * `RoomStore.readCount` for read-receipt math. */
11
+ activeMemberIds: string[];
12
+ }
13
+
14
+ /**
15
+ * Live member list of one room — roles, presence dots, mute state. Backs a
16
+ * room-settings member section or an operator panel.
17
+ *
18
+ * Loads via `room.listMembers()` and keeps itself fresh:
19
+ * - membership / lifecycle `roomEvent`s for this room trigger a
20
+ * debounced reload (member joined / left / kicked / role changed /
21
+ * muted …);
22
+ * - `presence` transitions flip the member's `isOnline` in place, no
23
+ * round-trip.
24
+ */
25
+ export class MemberListStore {
26
+ private readonly chat: NoveraChat;
27
+ readonly roomId: string;
28
+ private readonly listeners = new Set<() => void>();
29
+
30
+ private members: RoomMemberOut[] = [];
31
+ private unsubs: Array<() => void> = [];
32
+ private reloadTimer: ReturnType<typeof setTimeout> | null = null;
33
+ private snapshot: MemberListSnapshot = {
34
+ members: [],
35
+ operators: [],
36
+ activeMemberIds: [],
37
+ };
38
+ private attached = false;
39
+
40
+ constructor(chat: NoveraChat, roomId: string) {
41
+ this.chat = chat;
42
+ this.roomId = roomId;
43
+ }
44
+
45
+ attach(): void {
46
+ if (this.attached) return;
47
+ this.attached = true;
48
+ this.unsubs.push(
49
+ this.chat.on("roomEvent", (e) => {
50
+ if (e.room_id === this.roomId) this.scheduleReload();
51
+ }),
52
+ this.chat.on("presence", (f) => this.onPresence(f)),
53
+ );
54
+ }
55
+
56
+ dispose(): void {
57
+ if (!this.attached) return;
58
+ this.attached = false;
59
+ if (this.reloadTimer) clearTimeout(this.reloadTimer);
60
+ this.reloadTimer = null;
61
+ for (const u of this.unsubs.splice(0)) u();
62
+ }
63
+
64
+ // ── external-store contract ─────────────────────────────────────
65
+
66
+ readonly subscribe = (fn: () => void): (() => void) => {
67
+ this.listeners.add(fn);
68
+ return () => this.listeners.delete(fn);
69
+ };
70
+
71
+ readonly getSnapshot = (): MemberListSnapshot => this.snapshot;
72
+
73
+ private emit(): void {
74
+ this.snapshot = {
75
+ members: [...this.members],
76
+ operators: this.members.filter((m) => m.role === "OPERATOR"),
77
+ activeMemberIds: this.members
78
+ .filter((m) => m.role !== "KICKED")
79
+ .map((m) => m.userId),
80
+ };
81
+ for (const fn of this.listeners) fn();
82
+ }
83
+
84
+ /** Whether `userId` is an OPERATOR of this room. */
85
+ isOperator(userId: string): boolean {
86
+ return this.members.some(
87
+ (m) => m.userId === userId && m.role === "OPERATOR",
88
+ );
89
+ }
90
+
91
+ /** Fetch the member list. Also called automatically on membership
92
+ * events. */
93
+ async load(): Promise<void> {
94
+ const list = await this.chat.room(this.roomId).listMembers();
95
+ if (!this.attached) return;
96
+ this.members = list;
97
+ this.emit();
98
+ }
99
+
100
+ // ── internals ───────────────────────────────────────────────────
101
+
102
+ private onPresence(f: WsPresence): void {
103
+ const i = this.members.findIndex((m) => m.userId === f.user_id);
104
+ if (i === -1) return;
105
+ this.members[i] = { ...this.members[i]!, isOnline: f.online };
106
+ this.emit();
107
+ }
108
+
109
+ private scheduleReload(): void {
110
+ if (this.reloadTimer) clearTimeout(this.reloadTimer);
111
+ this.reloadTimer = setTimeout(() => {
112
+ this.reloadTimer = null;
113
+ if (this.attached) void this.load();
114
+ }, 300);
115
+ }
116
+ }
@@ -0,0 +1,130 @@
1
+ import type { FileMeta, NoveraChat } from "@noverachat/sdk-web";
2
+
3
+ /** One (message, file) entry of a room's shared-file list — the same shape
4
+ * `room.listFiles()` returns per item. */
5
+ export interface RoomFileItem {
6
+ messageId: string;
7
+ senderId: string | null;
8
+ createdAtMs: number;
9
+ file: FileMeta;
10
+ }
11
+
12
+ export interface RoomFilesSnapshot {
13
+ /** Loaded entries, newest first. One entry per (message, file). */
14
+ items: RoomFileItem[];
15
+ /** Only image/video entries — for a photo-album grid that skips plain
16
+ * files. */
17
+ media: RoomFileItem[];
18
+ /** True when older pages remain — call `loadMore` from the grid's tail. */
19
+ hasMore: boolean;
20
+ /** True while a `refresh` / `loadMore` request is in flight. */
21
+ isLoading: boolean;
22
+ }
23
+
24
+ /**
25
+ * Paginated media/file grid of one room — backs a "파일" section in room
26
+ * settings or an album-style photo grid.
27
+ *
28
+ * Wraps `room.listFiles()` (newest first) with cursor bookkeeping. New
29
+ * uploads land at the head on `refresh`; older pages append via `loadMore`.
30
+ * Render thumbnails with `chat.fileThumbnailUrl(item.file.id, token)`.
31
+ */
32
+ export class RoomFilesStore {
33
+ private readonly chat: NoveraChat;
34
+ readonly roomId: string;
35
+ /** Page size for both `refresh` and `loadMore`. */
36
+ readonly pageSize: number;
37
+ private readonly listeners = new Set<() => void>();
38
+
39
+ private items: RoomFileItem[] = [];
40
+ private cursor: string | null = null;
41
+ private hasMore = false;
42
+ private loading = false;
43
+ private attached = false;
44
+ private snapshot: RoomFilesSnapshot = {
45
+ items: [],
46
+ media: [],
47
+ hasMore: false,
48
+ isLoading: false,
49
+ };
50
+
51
+ constructor(chat: NoveraChat, roomId: string, pageSize = 50) {
52
+ this.chat = chat;
53
+ this.roomId = roomId;
54
+ this.pageSize = pageSize;
55
+ }
56
+
57
+ /** No realtime subscriptions — kept for lifecycle symmetry with the
58
+ * other stores (guards in-flight results after unmount). */
59
+ attach(): void {
60
+ this.attached = true;
61
+ }
62
+
63
+ dispose(): void {
64
+ this.attached = false;
65
+ }
66
+
67
+ // ── external-store contract ─────────────────────────────────────
68
+
69
+ readonly subscribe = (fn: () => void): (() => void) => {
70
+ this.listeners.add(fn);
71
+ return () => this.listeners.delete(fn);
72
+ };
73
+
74
+ readonly getSnapshot = (): RoomFilesSnapshot => this.snapshot;
75
+
76
+ private emit(): void {
77
+ this.snapshot = {
78
+ items: [...this.items],
79
+ media: this.items.filter(
80
+ (it) => it.file.kind === "image" || it.file.kind === "video",
81
+ ),
82
+ hasMore: this.hasMore,
83
+ isLoading: this.loading,
84
+ };
85
+ for (const fn of this.listeners) fn();
86
+ }
87
+
88
+ // ── actions ─────────────────────────────────────────────────────
89
+
90
+ /** Reload from the newest page (clears what was loaded). */
91
+ async refresh(): Promise<void> {
92
+ if (this.loading) return;
93
+ this.loading = true;
94
+ this.emit();
95
+ try {
96
+ const page = await this.chat
97
+ .room(this.roomId)
98
+ .listFiles({ limit: this.pageSize });
99
+ if (!this.attached) return;
100
+ this.items = page.items;
101
+ this.cursor = page.nextCursor;
102
+ this.hasMore = page.hasMore;
103
+ } finally {
104
+ this.loading = false;
105
+ if (this.attached) this.emit();
106
+ }
107
+ }
108
+
109
+ /** Fetch the next older page and append it. No-op while loading or when
110
+ * `hasMore` is false. Returns the number of entries added. */
111
+ async loadMore(): Promise<number> {
112
+ const cursor = this.cursor;
113
+ if (this.loading || !this.hasMore || cursor == null) return 0;
114
+ this.loading = true;
115
+ this.emit();
116
+ try {
117
+ const page = await this.chat
118
+ .room(this.roomId)
119
+ .listFiles({ limit: this.pageSize, before: cursor });
120
+ if (!this.attached) return 0;
121
+ this.items = [...this.items, ...page.items];
122
+ this.cursor = page.nextCursor;
123
+ this.hasMore = page.hasMore;
124
+ return page.items.length;
125
+ } finally {
126
+ this.loading = false;
127
+ if (this.attached) this.emit();
128
+ }
129
+ }
130
+ }