@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/LICENSE +21 -0
- package/README.ko.md +86 -0
- package/README.md +86 -0
- package/dist/index.cjs +1737 -0
- package/dist/index.d.cts +1151 -0
- package/dist/index.d.ts +1151 -0
- package/dist/index.js +1707 -0
- package/package.json +43 -0
- package/src/client.ts +733 -0
- package/src/errors.ts +59 -0
- package/src/features/room.ts +1292 -0
- package/src/generated/README.ko.md +15 -0
- package/src/generated/README.md +15 -0
- package/src/generated/wire.ts +224 -0
- package/src/index.ts +27 -0
- package/src/internal/event-bus.ts +42 -0
- package/src/internal/temp-id.ts +10 -0
- package/src/transport/http.ts +81 -0
- package/src/transport/ws.ts +200 -0
- package/src/types.ts +375 -0
- package/src/utils/debounce.ts +14 -0
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
|
+
}
|