@noverachat/sdk-web 0.0.3

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/src/types.ts ADDED
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Shared wire types. Must stay in lock-step with:
3
+ * noverachat-backend/src/noverachat/ws/protocol.py
4
+ * noverachat-backend/src/noverachat/modules/message/schemas.py
5
+ *
6
+ * NOTE on `message_id` / `reply_to_id` / `last_*_message_id`: server-side
7
+ * these are 63-bit Snowflake integers. On the wire they are JSON STRINGS
8
+ * because JS `Number` can only represent integers up to 2^53 exactly.
9
+ * Stringifying preserves precision; the SDK never converts them back to
10
+ * Number. Compare with `BigInt(a) > BigInt(b)`.
11
+ */
12
+
13
+ /** 63-bit Snowflake serialized as a JSON string. Treat as opaque. */
14
+ export type MessageIdStr = string;
15
+
16
+ /**
17
+ * Wire frame types + shared string enums are GENERATED from
18
+ * packages/protocol/schema (single source of truth). Do not hand-edit them
19
+ * here — edit the schema and run `pnpm --filter @noverachat/protocol gen`.
20
+ * Re-exported so the rest of the SDK keeps importing them from "./types.js".
21
+ */
22
+ import type {
23
+ MessageType, DeleteScope, FileKind, FileThumbnailStatus,
24
+ WsInbound, WsOutbound, WsChatSend, WsReadWatermark, WsTypingSend, WsPing,
25
+ WsConnected, WsAck, WsChatReceive, WsMessageUpdated, WsMessageDeleted,
26
+ WsMessagesCleared, WsReactionAdded, WsReactionRemoved, WsSyncTick,
27
+ WsTypingBroadcast, WsRoomEvent, WsAnnouncementEvent, WsPresence, WsPong,
28
+ WsError, WsCloseNotice,
29
+ } from "./generated/wire.js";
30
+
31
+ export type {
32
+ MessageType, DeleteScope, FileKind, FileThumbnailStatus,
33
+ WsInbound, WsOutbound, WsChatSend, WsReadWatermark, WsTypingSend, WsPing,
34
+ WsConnected, WsAck, WsChatReceive, WsMessageUpdated, WsMessageDeleted,
35
+ WsMessagesCleared, WsReactionAdded, WsReactionRemoved, WsSyncTick,
36
+ WsTypingBroadcast, WsRoomEvent, WsAnnouncementEvent, WsPresence, WsPong,
37
+ WsError, WsCloseNotice,
38
+ };
39
+
40
+ export interface SenderMini {
41
+ userId: string;
42
+ nickname?: string | null;
43
+ profileImageUrl?: string | null;
44
+ }
45
+
46
+ export interface ReactionEntry {
47
+ key: string;
48
+ userIds: string[];
49
+ updatedAt: number; // unix ms
50
+ }
51
+
52
+ export interface MessageOut {
53
+ appId: string;
54
+ roomId: string;
55
+ messageId: MessageIdStr;
56
+ sender?: SenderMini | null;
57
+ senderId?: string | null;
58
+ messageType: MessageType;
59
+ customType?: string | null;
60
+ content?: string | null;
61
+ data?: Record<string, unknown> | null;
62
+ meta?: Record<string, unknown> | null;
63
+ /** Custom metadata attached by the sender (extracted from `data._customMeta`).
64
+ * Null when the sender didn't attach anything. */
65
+ customMeta?: Record<string, unknown> | null;
66
+ /** Snowflake bigint (string) of the attached file, when this is a file
67
+ * message. Use ``chat.getFileUrl(fileId)`` to obtain a short-lived
68
+ * signed download URL, or ``chat.getFileMeta(fileId)`` for metadata
69
+ * without the download. Null for text-only messages. */
70
+ fileId?: MessageIdStr | null;
71
+ /** Inline file metadata embedded by the server on chat_receive — lets
72
+ * the UI render a placeholder (filename, size, dimensions, thumbnail
73
+ * status) without an extra round-trip. May be null even when `fileId`
74
+ * is set (e.g. for messages loaded via REST history before the inline
75
+ * metadata feature shipped); call ``getFileMeta()`` in that case. */
76
+ file?: FileInline | null;
77
+ /** Multi-file bundle — set when the message references multiple files
78
+ * via ``data.file_ids`` (kind = "file_group"). Each entry has the
79
+ * same shape as ``file``. Null for single-file / text-only messages.
80
+ * UIs should check ``files?.length > 0`` first and only fall through
81
+ * to the ``file`` singleton when the bundle field is absent. */
82
+ files?: FileInline[] | null;
83
+ replyToId?: MessageIdStr | null;
84
+ reactions: ReactionEntry[];
85
+ createdAt: string; // ISO
86
+ createdAtMs: number; // unix ms
87
+ updatedAt?: string | null;
88
+ updatedAtMs?: number | null;
89
+ deletedAt?: string | null;
90
+ deletedByUserId?: string | null;
91
+ deleteScope?: DeleteScope | null;
92
+ editedCount: number;
93
+ }
94
+
95
+ export interface RoomUnread {
96
+ roomId: string;
97
+ unreadCount: number;
98
+ lastReadMessageId: MessageIdStr | null;
99
+ lastMessageId: MessageIdStr | null;
100
+ }
101
+
102
+ export interface Announcement {
103
+ id: number;
104
+ roomId: string;
105
+ content: string;
106
+ createdBy: string;
107
+ createdAt: string;
108
+ updatedAt: string;
109
+ }
110
+
111
+ /** One-way user block — KakaoTalk-style.
112
+ *
113
+ * A row in the caller's block list. The blocked user is NOT told they're
114
+ * blocked; they keep sending messages that just don't reach the blocker.
115
+ * See ``chat.blockUser`` / ``chat.unblockUser`` / ``chat.listBlocks``.
116
+ */
117
+ export interface Block {
118
+ blockedUserId: string;
119
+ reason: string | null;
120
+ /** ISO-8601 UTC. */
121
+ createdAt: string;
122
+ }
123
+
124
+ /** 방 초대 링크 (카톡 오픈채팅 URL 스타일). Minted by an OPERATOR via
125
+ * ``room.createInvite()``; consumed by any user via ``chat.acceptInvite()``. */
126
+ export interface InviteToken {
127
+ /** URL-safe short id (~22 chars). Embed in the shareable URL as-is. */
128
+ token: string;
129
+ roomId: string;
130
+ createdBy: string;
131
+ /** ISO-8601 UTC. */
132
+ createdAt: string;
133
+ /** ISO-8601 UTC. `null` = never expires. */
134
+ expiresAt: string | null;
135
+ /** `null` = unlimited uses. */
136
+ maxUses: number | null;
137
+ /** Successful `accept` calls so far. */
138
+ usedCount: number;
139
+ /** ISO-8601 UTC. `null` = still active (assuming not expired / exhausted). */
140
+ revokedAt: string | null;
141
+ }
142
+
143
+ /** Result of `chat.acceptInvite(token)`. */
144
+ export interface AcceptInviteResult {
145
+ roomId: string;
146
+ roomName: string | null;
147
+ /** True when the caller was already an active member — the call is a
148
+ * no-op and does NOT bump the invite's `used_count`. Useful for
149
+ * deep-link flows that may be triggered on re-open. */
150
+ wasAlreadyMember: boolean;
151
+ }
152
+
153
+ /** One member shown in a channel-list avatar mosaic. */
154
+ export interface MemberPreview {
155
+ userId: string;
156
+ nickname: string | null;
157
+ profileImageUrl: string | null;
158
+ /** Cluster-wide presence dot. `null` = not resolved (endpoint didn't
159
+ * include it). Consumers rendering a green/gray dot should treat
160
+ * `null` and `false` differently only if they care about the
161
+ * "unknown" state. */
162
+ isOnline: boolean | null;
163
+ }
164
+
165
+ export interface UnreadRoomItem {
166
+ roomId: string;
167
+ name: string | null;
168
+ /** "ONE" | "GROUP" | "SUPER_GROUP" — lets the UI pick a 1-up vs mosaic avatar. */
169
+ roomType: string | null;
170
+ unreadCount: number;
171
+ lastMessageId: MessageIdStr | null;
172
+ /** One-line preview shown under the room name in the channel list.
173
+ * TEXT/SYSTEM → first 200 chars, EMOTICON → "[이모티콘]", images/videos/
174
+ * files → "[사진]" / "[동영상]" / "[파일] name". Null when the room has
175
+ * no messages yet (or was just cleared). */
176
+ lastMessagePreview: string | null;
177
+ lastReadMessageId: MessageIdStr | null;
178
+ /** Total active (non-KICKED) members incl. the caller — the count badge. */
179
+ memberCount: number;
180
+ /** Up to 4 OTHER members (caller excluded) for the avatar mosaic. */
181
+ members: MemberPreview[];
182
+ }
183
+
184
+ export interface UnreadSummary {
185
+ total: number;
186
+ rooms: UnreadRoomItem[];
187
+ }
188
+
189
+ export interface AppPolicy {
190
+ appId: string;
191
+ /** When true, any non-KICKED room member can call
192
+ * `room.deleteAllMessages()`. When false, only operators (or super-admin)
193
+ * can. UIs should gate the destructive button on this. */
194
+ allowMemberBulkDelete: boolean;
195
+ /** When false, server does NOT broadcast `sync_tick` frames — hide
196
+ * read-receipt badges client-side. Per-user unread counts via REST
197
+ * still work (DB tracking is independent of the broadcast). */
198
+ readReceiptsEnabled: boolean;
199
+ /** When false, the server silently drops inbound `typing` frames and
200
+ * no peer ever sees "alice is typing…". SDK callers should also
201
+ * short-circuit `room.setTyping()` based on this flag so a disabled
202
+ * app doesn't pay the WS frame cost per keystroke. */
203
+ typingIndicatorsEnabled: boolean;
204
+ /** When true, any non-KICKED room member can hard-delete another
205
+ * member's message (scope=ALL). Default false — sender-or-operator
206
+ * only. UIs should gate the "Delete for everyone" button on other
207
+ * users' messages by this flag. */
208
+ deleteAnyMessageEnabled: boolean;
209
+ }
210
+
211
+ /** Push providers we can deliver to. Matches the backend `TokenType`. */
212
+ export type DeviceTokenType = "FCM" | "APNS" | "HUAWEI";
213
+
214
+ /** Coarse device platform — used only for display / analytics on the
215
+ * server. Matches the backend `DeviceOS`. */
216
+ export type DeviceOS = "iOS" | "Android" | "Web";
217
+
218
+ export interface RegisterDeviceParams {
219
+ /** Client-stable installation id. NOT the push token — tokens rotate,
220
+ * this should not. Re-registering with the same deviceId just
221
+ * refreshes the stored token. */
222
+ deviceId: string;
223
+ /** The FCM / APNS / HMS push token. */
224
+ token: string;
225
+ tokenType: DeviceTokenType;
226
+ os?: DeviceOS;
227
+ appVersion?: string;
228
+ }
229
+
230
+ export interface DeviceInfo {
231
+ deviceId: string;
232
+ tokenType: DeviceTokenType;
233
+ /** Server returns only a short prefix of the token — it's a credential
234
+ * and the caller already holds the full value. */
235
+ tokenPreview: string;
236
+ os: DeviceOS | null;
237
+ appVersion: string | null;
238
+ pushEnabled: boolean;
239
+ lastActiveAt: string | null;
240
+ createdAt: string;
241
+ }
242
+
243
+ export interface SendParams {
244
+ content?: string;
245
+ messageType?: "TEXT" | "FILE" | "EMOTICON";
246
+ customType?: string;
247
+ data?: Record<string, unknown>;
248
+ meta?: Record<string, unknown>;
249
+ /** Attach an already-committed file (returned by ``room.sendFile()``'s
250
+ * internal upload flow, or by a prior ``presignFileUpload()`` +
251
+ * ``commitFileUpload()`` if you're orchestrating the upload manually).
252
+ * The message_type will be auto-set to ``FILE`` when this is supplied. */
253
+ fileId?: MessageIdStr;
254
+ /** Multi-file bundle — attach N already-committed files as ONE
255
+ * message. The SDK sets ``message_type = "FILE"``, ``file_id = null``,
256
+ * and writes the ids into ``data.file_ids`` (with
257
+ * ``data.kind = "file_group"``). Use ``room.uploadFileOnly()`` per
258
+ * file to collect the ids first. Mutually exclusive with ``fileId`` —
259
+ * when both are set, ``fileIds`` wins. */
260
+ fileIds?: MessageIdStr[];
261
+ /** Apply the "no-forward" flag to the whole bundle (mirrors the
262
+ * per-file flag set at upload time). Written to
263
+ * ``data.forward_blocked`` for the receiving client to gate its
264
+ * forward-toolbar button on. */
265
+ forwardBlocked?: boolean;
266
+ replyToId?: MessageIdStr;
267
+ /** Set when this send is forwarding an existing message. The server
268
+ * uses it to enforce the source file's ``forwardBlocked`` flag (Phase
269
+ * C #7): if the source's file is no-forward AND the destination room
270
+ * differs from the source room, the send is rejected with
271
+ * ``NC-FILE-014``. Pass the ORIGINAL message's ``messageId``. */
272
+ forwardOfMessageId?: MessageIdStr;
273
+ /** Arbitrary custom metadata attached to the message. Stored under
274
+ * `data._customMeta`. Surfaces on receiving clients as
275
+ * `MessageOut.customMeta`. */
276
+ customMeta?: Record<string, unknown>;
277
+ }
278
+
279
+
280
+
281
+ /** Compact metadata embedded in chat_receive frames. The full ``FileMeta``
282
+ * shape is returned by ``chat.getFileMeta(fileId)`` and matches the
283
+ * ``GET /files/{id}/meta`` response — currently identical to this
284
+ * inline form. */
285
+ export interface FileInline {
286
+ id: MessageIdStr;
287
+ kind: FileKind;
288
+ mime: string;
289
+ /** Bytes. */
290
+ size: number;
291
+ /** Original filename as the uploader sent it — may be null when not
292
+ * known (some clients omit it). */
293
+ name?: string | null;
294
+ /** Pixel dimensions — populated for image / video after the thumbnail
295
+ * worker probes the file. May be null until the worker finishes. */
296
+ width?: number | null;
297
+ height?: number | null;
298
+ /** Seconds — video only. */
299
+ durationSec?: number | null;
300
+ thumbnailStatus: FileThumbnailStatus;
301
+ /** Uploader-set "no forward" flag. When true, another user cannot
302
+ * forward a message carrying this file to a different room — the
303
+ * server rejects such sends with NC-FILE-014. UIs should surface a
304
+ * 🚫 badge and hide the forward action for these files. Downloads
305
+ * and same-room views stay allowed. */
306
+ forwardBlocked: boolean;
307
+ }
308
+
309
+ /** Same shape as ``FileInline`` for now — kept distinct so future
310
+ * meta-only fields (e.g. uploader info, dedupe hash) can land here
311
+ * without forcing them onto every WS frame. */
312
+ export type FileMeta = FileInline;
313
+
314
+ /** Per-call options for ``room.sendFile()``. */
315
+ export interface SendFileOptions {
316
+ /** Optional progress callback fired during the S3 PUT. ``loaded`` and
317
+ * ``total`` are bytes; ``ratio`` is in [0, 1]. Called from an XHR
318
+ * ``upload.onprogress`` listener — at minimum the final ``ratio=1``
319
+ * fires when the PUT completes. */
320
+ onProgress?: (p: { loaded: number; total: number; ratio: number }) => void;
321
+ /** Override the declared kind. Default: derived from the File's MIME
322
+ * type (``image/*`` → image, ``video/*`` → video, else file). Useful
323
+ * if you need to force a particular UI rendering. */
324
+ kind?: FileKind;
325
+ /** Override the declared filename. Default: ``file.name``. */
326
+ name?: string;
327
+ /** 파일 전달 제한 — when true, the uploader (this call) marks the
328
+ * file as "no-forward". Other users won't be able to forward the
329
+ * resulting message to a different room; the server rejects such
330
+ * sends with ``NC-FILE-014``. Downloads and same-room views stay
331
+ * allowed. Default false. */
332
+ forwardBlocked?: boolean;
333
+ /** Arbitrary custom metadata attached to the resulting file message.
334
+ * Stored under `data._customMeta`. Surfaces on receiving clients as
335
+ * `MessageOut.customMeta`. */
336
+ customMeta?: Record<string, unknown>;
337
+ }
338
+
339
+ /** Backend response from ``POST /api/v1/files`` (presign step). */
340
+ export interface FilePresignResponse {
341
+ fileId: MessageIdStr;
342
+ uploadUrl: string;
343
+ /** Headers the client MUST send on its S3 PUT. At minimum carries
344
+ * ``Content-Type`` — the presigned URL is bound to it, so a mismatch
345
+ * causes the PUT to fail with 403. */
346
+ headers: Record<string, string>;
347
+ expiresAtMs: number;
348
+ }
349
+
350
+ // ------------------ WS envelopes (outbound to server) ------------------
351
+
352
+ // (wire frame types are generated — see re-export block near the top)
353
+
354
+ export interface ClientOptions {
355
+ appId: string;
356
+ endpoint: string; // e.g. "https://chat.example.com"
357
+ wsEndpoint?: string; // defaults to endpoint with ws(s)://
358
+ tokenProvider: () => Promise<string>;
359
+ autoReconnect?: boolean; // default true
360
+ pingIntervalMs?: number; // default 20_000
361
+ readWatermarkDebounceMs?: number;// default 1500
362
+ maxReconnectDelayMs?: number; // default 30_000
363
+ /**
364
+ * Seed the per-room "highest-seen message_id" map at construction time.
365
+ * Use this to feed back values your app persisted to local storage in a
366
+ * previous session, so a cold page load can still drive `?since=...` on
367
+ * the WS handshake and surface unread messages via `chat.backfill()`.
368
+ *
369
+ * Shape: { [roomId]: lastSeenMessageId }
370
+ */
371
+ initialLastMessageIds?: Record<string, MessageIdStr>;
372
+ fetch?: typeof fetch;
373
+ WebSocketImpl?: typeof WebSocket;
374
+ logger?: (lvl: "debug" | "info" | "warn" | "error", msg: string, ctx?: unknown) => void;
375
+ }
@@ -0,0 +1,14 @@
1
+ /** Debounce a single-arg function: calls are coalesced within `waitMs`. */
2
+
3
+ export function debounce<T>(waitMs: number, fn: (arg: T) => void): (arg: T) => void {
4
+ let timer: ReturnType<typeof setTimeout> | null = null;
5
+ let lastArg: T | undefined;
6
+ return (arg: T) => {
7
+ lastArg = arg;
8
+ if (timer) clearTimeout(timer);
9
+ timer = setTimeout(() => {
10
+ if (lastArg !== undefined) fn(lastArg);
11
+ timer = null;
12
+ }, waitMs);
13
+ };
14
+ }