react-peer-chat 0.6.8 → 0.7.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.
@@ -0,0 +1,4 @@
1
+ {
2
+ "editor.tabSize": 2,
3
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
4
+ }
package/README.md CHANGED
@@ -1,17 +1,22 @@
1
1
  # react-peer-chat
2
- An easy to use react component for impleting peer-to-peer chatting using [peerjs](https://peerjs.com/) under the hood.
2
+
3
+ A simple-to-use React component for implementing peer-to-peer chatting, powered by [peerjs](https://peerjs.com/).
3
4
 
4
5
  ## Features
5
- - Peer-to-peer chat without need to have any knowledge about [WebRTC](https://webrtc.org/)
6
- - Easy to use
7
- - Supports text chat that persists on page reload
8
- - Recovers old chat upon reconnection
9
- - Clear text chat on command
10
- - Supports voice chat
11
- - Multiple peer connections. See [multi peer usage](#Multi-Peer-Usage)
12
- - Fully Customizable. See [usage with FaC](#Full-Customization)
6
+
7
+ - Peer-to-peer chat without requiring knowledge of [WebRTC](https://webrtc.org/)
8
+ - Easy integration
9
+ - Supports persistent text chat across page reloads
10
+ - Recovers old chats upon reconnection
11
+ - Option to clear chat on command
12
+ - Supports audio/voice chat
13
+ - Multiple peer connections. See [multi-peer usage](#Multi-Peer-Usage)
14
+ - Fully customizable. See [usage with FaC](#Full-Customization)
15
+
13
16
  ## Installation
14
- To install react-peer-chat
17
+
18
+ To install `react-peer-chat`:
19
+
15
20
  ```bash
16
21
  # with npm:
17
22
  npm install react-peer-chat --save
@@ -25,74 +30,92 @@ To install react-peer-chat
25
30
  # with bun:
26
31
  bun add react-peer-chat
27
32
  ```
33
+
28
34
  ## Usage
29
- `react-peer-chat` default exports `<Chat>` component. When you use the `<Chat>` component, initially the user will see 2 buttons (svg icons), one for text chat and other for voice chat.
30
35
 
31
- It also exports a `clearChat` function that clears the text chat whenever invoked.
36
+ `react-peer-chat` provides two primary methods to integrate chat functionality into your React apps: through the `<Chat>` component and the `useSpeech` hook.
37
+
38
+ It also exports a `clearChat` function that clears the text chat from the browser's session storage when called.
39
+
40
+ ### Chat Component
41
+
42
+ The default export of `react-peer-chat` is the `<Chat>` component, which offers the easiest integration and is fully configurable. When using the `<Chat>` component, the user will initially see two buttons (SVG icons) - one for text chat and the other for voice chat.
43
+
32
44
  #### Basic Usage
45
+
33
46
  ```jsx
34
- import React from 'react';
35
- import Chat, { clearChat } from 'react-peer-chat';
36
- import 'react-peer-chat/dist/styles.css';
47
+ import React from "react";
48
+ import Chat, { clearChat } from "react-peer-chat";
49
+ import "react-peer-chat/styles.css";
37
50
 
38
51
  export default function App() {
39
- return <div>
40
- <Chat
41
- name='John Doe'
42
- peerId='my-unique-id'
43
- remotePeerId='remote-unique-id'
44
- />
45
- {/* Text chat will be cleared when following button is clicked. */}
46
- <button onClick={clearChat}>Clear Chat</button>
52
+ return (
53
+ <div>
54
+ <Chat name="John Doe" peerId="my-unique-id" remotePeerId="remote-unique-id" />
55
+ {/* Text chat will be cleared when following button is clicked. */}
56
+ <button onClick={clearChat}>Clear Chat</button>
47
57
  </div>
58
+ );
48
59
  }
49
60
  ```
61
+
50
62
  #### Multi Peer Usage
63
+
51
64
  ```jsx
52
- import React from 'react';
53
- import Chat, { clearChat } from 'react-peer-chat';
54
- import 'react-peer-chat/dist/styles.css';
65
+ import React from "react";
66
+ import Chat, { clearChat } from "react-peer-chat";
67
+ import "react-peer-chat/styles.css";
55
68
 
56
69
  export default function App() {
57
- return <div>
58
- <Chat
59
- name='John Doe'
60
- peerId='my-unique-id'
61
- remotePeerId={['remote-unique-id-1', 'remote-unique-id-2', 'remote-unique-id-3']} // Array of remote peer ids
62
- />
63
- {/* Text chat will be cleared when following button is clicked. */}
64
- <button onClick={clearChat}>Clear Chat</button>
70
+ return (
71
+ <div>
72
+ <Chat
73
+ name="John Doe"
74
+ peerId="my-unique-id"
75
+ remotePeerId={["remote-unique-id-1", "remote-unique-id-2", "remote-unique-id-3"]} // Array of remote peer ids
76
+ />
77
+ {/* Text chat will be cleared when following button is clicked. */}
78
+ <button onClick={clearChat}>Clear Chat</button>
65
79
  </div>
80
+ );
66
81
  }
67
82
  ```
83
+
68
84
  #### Partial Customization
69
- Use props provided by `<Chat>` component to customize it.
85
+
86
+ Use the props provided by the `<Chat>` component for customization.
87
+
70
88
  ```jsx
71
- import React from 'react';
72
- import Chat from 'react-peer-chat';
73
- import 'react-peer-chat/dist/styles.css';
89
+ import React from "react";
90
+ import Chat from "react-peer-chat";
91
+ import "react-peer-chat/styles.css";
74
92
 
75
93
  export default function App() {
76
- return <Chat
77
- name='John Doe'
78
- peerId='my-unique-id'
79
- remotePeerId='remote-unique-id'
80
- dialogOptions={{
81
- position: 'left',
82
- style: { padding: '4px' }
83
- }}
84
- props={{ title: 'React Peer Chat Component' }}
85
- onError={() => console.error('Browser not supported!')}
86
- onMicError={() => console.error('Microphone not accessible!')}
94
+ return (
95
+ <Chat
96
+ name="John Doe"
97
+ peerId="my-unique-id"
98
+ remotePeerId="remote-unique-id"
99
+ dialogOptions={{
100
+ position: "left",
101
+ style: { padding: "4px" },
102
+ }}
103
+ props={{ title: "React Peer Chat Component" }}
104
+ onError={() => console.error("Browser not supported!")}
105
+ onMicError={() => console.error("Microphone not accessible!")}
87
106
  />
107
+ );
88
108
  }
89
109
  ```
90
- #### Full Customization
91
- Use Function as Children(FaC) to fully customize the `<Chat>` component.
110
+
111
+ #### Full Customization
112
+
113
+ Use Function as Children (FaC) to fully customize the `<Chat>` component.
114
+
92
115
  ```jsx
93
116
  import React from 'react'
94
117
  import Chat from 'react-peer-chat'
95
- // import 'react-peer-chat/dist/styles.css' (No need to import CSS when using custom component)
118
+ // import 'react-peer-chat/styles.css' (No need to import CSS when using custom component)
96
119
 
97
120
  export default function App() {
98
121
  return <Chat
@@ -102,7 +125,7 @@ export default function App() {
102
125
  onError={() => console.error('Browser not supported!')}
103
126
  onMicError={() => console.error('Microphone not accessible!')}
104
127
  >
105
- {({ remotePeers, messages, addMessage, audio, setAudio }) => (
128
+ {({ remotePeers, messages, sendMessage, audio, setAudio }) => (
106
129
  <YourCustomComponent>
107
130
  {...}
108
131
  </YourCustomComponent>
@@ -110,36 +133,160 @@ export default function App() {
110
133
  </Chat>
111
134
  }
112
135
  ```
136
+
113
137
  #### Custom ICE Servers
114
- You can also use custom ICE servers to avoid any connectivity issues in case free TURN server limit provided by `react-peer-chat` expires.
138
+
139
+ You can also provide custom ICE servers to avoid connectivity issues if the free TURN server provided by `react-peer-chat` expires.
140
+
115
141
  ```jsx
116
- import React from 'react';
117
- import Chat from 'react-peer-chat';
118
- import 'react-peer-chat/dist/styles.css';
142
+ import React from "react";
143
+ import Chat from "react-peer-chat";
144
+ import "react-peer-chat/styles.css";
119
145
 
120
146
  export default function App() {
121
- return <Chat
122
- name='John Doe'
123
- peerId='my-unique-id'
124
- remotePeerId='remote-unique-id'
125
- peerOptions={{
126
- config: {
127
- iceServers: [
128
- { urls: "stun:stun-server.example.com:19302" },
129
- {
130
- urls: 'turn:turn-server.example.com:19403',
131
- username: 'optional-username',
132
- credential: 'auth-token'
133
- }
134
- ]
135
- }
136
- // other peerjs options (optional)
137
- }}
147
+ return (
148
+ <Chat
149
+ name="John Doe"
150
+ peerId="my-unique-id"
151
+ remotePeerId="remote-unique-id"
152
+ peerOptions={{
153
+ config: {
154
+ iceServers: [
155
+ { urls: "stun:stun-server.example.com:19302" },
156
+ {
157
+ urls: "turn:turn-server.example.com:19403",
158
+ username: "optional-username",
159
+ credential: "auth-token",
160
+ },
161
+ ],
162
+ },
163
+ // other peerjs options (optional)
164
+ }}
138
165
  />
166
+ );
139
167
  }
140
168
  ```
141
- ## Chat Component API Reference
142
- Here is the full API for the `<Chat>` component, these properties can be set on an instance of Chat:
169
+
170
+ ### useChat Hook
171
+
172
+ The `useChat` hook is ideal when you want to completely redesign the Chat UI or handle the audio stream differently, instead of using traditional playback methods.
173
+
174
+ ```jsx
175
+ import React, { useEffect, useRef, useState } from "react";
176
+ import { clearChat, useChat } from "react-peer-chat";
177
+ import { BiSolidMessageDetail, BiSolidMessageX, BsFillMicFill, BsFillMicMuteFill, GrSend } from "react-peer-chat/icons";
178
+ import "react-peer-chat/styles.css"; // (No need to import CSS when using custom styles)
179
+
180
+ function Chat({ text = true, audio = true, onMessageReceived, dialogOptions, props = {}, children, ...hookProps }) {
181
+ const { peerId, audioStreamRef, ...childrenOptions } = useChat({
182
+ text,
183
+ audio,
184
+ onMessageReceived: modifiedOnMessageReceived,
185
+ ...hookProps,
186
+ });
187
+ const { remotePeers, messages, sendMessage, audio: audioEnabled, setAudio } = childrenOptions;
188
+ const containerRef = useRef(null);
189
+ const [dialog, setDialog] = useState(false);
190
+ const dialogRef = useRef(null);
191
+ const inputRef = useRef(null);
192
+ const [notification, setNotification] = useState(false);
193
+
194
+ function modifiedOnMessageReceived(message) {
195
+ if (!dialogRef.current?.open) setNotification(true);
196
+ onMessageReceived?.(message);
197
+ }
198
+
199
+ useEffect(() => {
200
+ if (dialog) dialogRef.current?.show();
201
+ else dialogRef.current?.close();
202
+ }, [dialog]);
203
+
204
+ useEffect(() => {
205
+ const container = containerRef.current;
206
+ if (container) container.scrollTop = container.scrollHeight;
207
+ }, [dialog, remotePeers, messages]);
208
+
209
+ return (
210
+ <div className="rpc-main rpc-font" {...props}>
211
+ {typeof children === "function" ? (
212
+ children(childrenOptions)
213
+ ) : (
214
+ <>
215
+ {text && (
216
+ <div className="rpc-dialog-container">
217
+ {dialog ? (
218
+ <BiSolidMessageX title="Close chat" onClick={() => setDialog(false)} />
219
+ ) : (
220
+ <div className="rpc-notification">
221
+ <BiSolidMessageDetail
222
+ title="Open chat"
223
+ onClick={() => {
224
+ setNotification(false);
225
+ setDialog(true);
226
+ }}
227
+ />
228
+ {notification && <span className="rpc-badge" />}
229
+ </div>
230
+ )}
231
+ <dialog ref={dialogRef} className={`${dialog ? "rpc-dialog" : ""} rpc-position-${dialogOptions?.position || "center"}`} style={dialogOptions?.style}>
232
+ <div className="rpc-heading">Chat</div>
233
+ <hr className="rpc-hr" />
234
+ <div>
235
+ <div ref={containerRef} className="rpc-message-container">
236
+ {messages.map(({ id, text }, i) => (
237
+ <div key={i}>
238
+ <strong>{id === peerId ? "You" : remotePeers[id]}: </strong>
239
+ <span>{text}</span>
240
+ </div>
241
+ ))}
242
+ </div>
243
+ <hr className="rpc-hr" />
244
+ <form
245
+ className="rpc-input-container"
246
+ onSubmit={(e) => {
247
+ e.preventDefault();
248
+ const text = inputRef.current?.value;
249
+ if (text) {
250
+ inputRef.current.value = "";
251
+ sendMessage({ id: peerId, text });
252
+ }
253
+ }}
254
+ >
255
+ <input ref={inputRef} className="rpc-input rpc-font" placeholder="Enter a message" />
256
+ <button type="submit" className="rpc-button">
257
+ <GrSend title="Send message" />
258
+ </button>
259
+ </form>
260
+ </div>
261
+ </dialog>
262
+ </div>
263
+ )}
264
+ {audio && <button>{audioEnabled ? <BsFillMicFill title="Turn mic off" onClick={() => setAudio(false)} /> : <BsFillMicMuteFill title="Turn mic on" onClick={() => setAudio(true)} />}</button>}
265
+ </>
266
+ )}
267
+ {audio && audioEnabled && <audio ref={audioStreamRef} autoPlay style={{ display: "none" }} />}
268
+ </div>
269
+ );
270
+ }
271
+
272
+ export default function App() {
273
+ return (
274
+ <div>
275
+ <Chat name="John Doe" peerId="my-unique-id" remotePeerId="remote-unique-id" />
276
+ {/* Text chat will be cleared when following button is clicked. */}
277
+ <button onClick={clearChat}>Clear Chat</button>
278
+ </div>
279
+ );
280
+ }
281
+ ```
282
+
283
+ #### Basic Usage
284
+
285
+ ## API Reference
286
+
287
+ ### useChat Hook
288
+
289
+ Here is the full API for the `useChat` hook, these options can be passed as paramerters to the hook:
143
290
  | Parameter | Type | Required | Default | Description |
144
291
  | - | - | - | - | - |
145
292
  | `name` | `string` | No | Anonymous User | Name of the peer which will be shown to the remote peer. |
@@ -147,43 +294,61 @@ Here is the full API for the `<Chat>` component, these properties can be set on
147
294
  | `remotePeerId` | `string \| string[]` | No | - | It is the unique id (or array of unique ids) of the remote peer(s). If provided, the peer will try to connect to the remote peer(s). |
148
295
  | `text` | `boolean` | No | `true` | Text chat will be enabled if this property is set to true. |
149
296
  | `recoverChat` | `boolean` | No | `false` | Old chats will be recovered upon reconnecting with the same peer(s). |
150
- | `voice` | `boolean` | No | `true` | Voice chat will be enabled if this property is set to true. |
297
+ | `audio` | `boolean` | No | `true` | Voice chat will be enabled if this property is set to true. |
151
298
  | `peerOptions` | [`PeerOptions`](#PeerOptions) | No | - | Options to customize peerjs Peer instance. |
152
- | `dialogOptions` | [`DialogOptions`](#DialogOptions) | No | { position: 'center' } | Options to customize text dialog box styling. |
153
299
  | `onError` | `Function` | No | `() => alert('Browser not supported! Try some other browser.')` | Function to be executed if browser doesn't support `WebRTC` |
154
300
  | `onMicError` | `Function` | No | `() => alert('Microphone not accessible!')` | Function to be executed when microphone is not accessible. |
301
+ | `onMessageSent` | `Function` | No | - | Function to be executed when a text message is sent to other peers. |
302
+ | `onMessageReceived` | `Function` | No | - | Function to be executed when a text message is received from other peers. |
303
+
304
+ ### Chat Component
305
+
306
+ Here is the full API for the `<Chat>` component, these properties can be set on an instance of `<Chat>`. It contains all the parameters
307
+ that are listed in [useChat Hook API Reference](#usechat-hook-1) along with the following parameters:
308
+ | Parameter | Type | Required | Default | Description |
309
+ | - | - | - | - | - |
310
+ | `dialogOptions` | [`DialogOptions`](#DialogOptions) | No | { position: 'center' } | Options to customize text dialog box styling. |
155
311
  | `props` | `React.DetailedHTMLProps` | No | - | Props to customize the `<Chat>` component. |
156
312
  | `children` | [`Children`](#Children) | No | - | See [usage with FaC](#Full-Customization) |
313
+
157
314
  ### Types
315
+
158
316
  #### PeerOptions
317
+
159
318
  ```typescript
160
- import { PeerOptions } from 'peerjs'
319
+ import { PeerOptions } from "peerjs";
161
320
  ```
321
+
162
322
  #### DialogOptions
323
+
163
324
  ```typescript
164
- import { CSSProperties } from 'react';
165
- type DialogPosition = 'left' | 'center' | 'right';
325
+ import { CSSProperties } from "react";
326
+ type DialogPosition = "left" | "center" | "right";
166
327
  type DialogOptions = {
167
- position: DialogPosition;
168
- style: CSSProperties;
328
+ position: DialogPosition;
329
+ style: CSSProperties;
169
330
  };
170
331
  ```
332
+
171
333
  #### Children
334
+
172
335
  ```typescript
173
- import { ReactNode } from 'react';
174
- type RemotePeers = { [id: string]: string }
336
+ import { ReactNode } from "react";
337
+ type RemotePeers = { [id: string]: string };
175
338
  type Message = {
176
- id: string;
177
- text: string;
339
+ id: string;
340
+ text: string;
178
341
  };
179
342
  type ChildrenOptions = {
180
- remotePeers?: RemotePeers;
181
- messages?: Message[];
182
- addMessage?: (message: Message, sendToRemotePeer?: boolean) => void;
183
- audio?: boolean;
184
- setAudio?: (audio: boolean) => void;
343
+ remotePeers?: RemotePeers;
344
+ messages?: Message[];
345
+ sendMessage?: (message: Message) => void;
346
+ audio?: boolean;
347
+ setAudio?: (audio: boolean) => void;
185
348
  };
186
349
  type Children = (childrenOptions: ChildrenOptions) => ReactNode;
187
350
  ```
351
+
188
352
  ## Author
189
- [Sahil Aggarwal](https://www.github.com/SahilAggarwal2004)
353
+
354
+ [Sahil Aggarwal](https://www.github.com/SahilAggarwal2004)
@@ -0,0 +1,2 @@
1
+ import { DataConnection, MediaConnection } from "peerjs";
2
+ export declare function closeConnection(conn: DataConnection | MediaConnection): void;
@@ -0,0 +1,4 @@
1
+ export function closeConnection(conn) {
2
+ conn.removeAllListeners();
3
+ conn.close();
4
+ }
@@ -0,0 +1,5 @@
1
+ export declare const defaultConfig: {
2
+ iceServers: {
3
+ urls: string[];
4
+ }[];
5
+ };
@@ -0,0 +1,15 @@
1
+ const turnAccounts = [
2
+ { username: "70061a377b51f3a3d01c11e3", credential: "lHV4NYJ5Rfl5JNa9" },
3
+ { username: "13b19eb65bbf6e9f96d64b72", credential: "7R9P/+7y7Q516Etv" },
4
+ { username: "3469603f5cdc7ca4a1e891ae", credential: "/jMyLSDbbcgqpVQv" },
5
+ { username: "a7926f4dcc4a688d41f89752", credential: "ZYM8jFYeb8bQkL+N" },
6
+ { username: "0be25ab7f61d9d733ba94809", credential: "hiiSwWVch+ftt3SX" },
7
+ { username: "3c25ba948daeab04f9b66187", credential: "FQB3GQwd27Y0dPeK" },
8
+ ];
9
+ export const defaultConfig = {
10
+ iceServers: [
11
+ {
12
+ urls: ["stun:stun.l.google.com:19302", "stun:stun.relay.metered.ca:80"],
13
+ },
14
+ ].concat(turnAccounts.map((account) => (Object.assign({ urls: ["turn:standard.relay.metered.ca:80", "turn:standard.relay.metered.ca:80?transport=tcp", "turn:standard.relay.metered.ca:443", "turns:standard.relay.metered.ca:443?transport=tcp"] }, account)))),
15
+ };
@@ -0,0 +1,13 @@
1
+ import { Message, RemotePeers, useChatProps } from "./types.js";
2
+ export declare function useChat({ name, peerId, remotePeerId, peerOptions, text, recoverChat, audio: allowed, onError, onMicError, onMessageSent, onMessageReceived }: useChatProps): {
3
+ peerId: string;
4
+ audioStreamRef: import("react").RefObject<HTMLMediaElement>;
5
+ remotePeers: RemotePeers;
6
+ messages: Message[];
7
+ sendMessage: (message: Message) => void;
8
+ audio: boolean;
9
+ setAudio: (value: boolean | ((old: boolean) => boolean)) => void;
10
+ };
11
+ export declare function useMessages(): readonly [Message[], (value: Message[] | ((old: Message[]) => Message[])) => void, (message: Message) => void];
12
+ export declare function useStorage<Value>(key: string, initialValue: Value, local?: boolean): [Value, (value: Value | ((old: Value) => Value)) => void];
13
+ export declare function useAudio(allowed: boolean): readonly [boolean, (value: boolean | ((old: boolean) => boolean)) => void];
package/dist/hooks.js ADDED
@@ -0,0 +1,155 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ import { useEffect, useRef, useState } from "react";
11
+ import { closeConnection } from "./connection.js";
12
+ import { defaultConfig } from "./constants.js";
13
+ import { getStorage, setStorage } from "./storage.js";
14
+ import { addPrefix } from "./utils.js";
15
+ export function useChat({ name, peerId, remotePeerId = [], peerOptions, text = true, recoverChat = false, audio: allowed = true, onError = () => alert("Browser not supported! Try some other browser."), onMicError = () => alert("Microphone not accessible!"), onMessageSent, onMessageReceived }) {
16
+ const [audio, setAudio] = useAudio(allowed);
17
+ const audioStreamRef = useRef(null);
18
+ const connRef = useRef({});
19
+ const localStream = useRef();
20
+ const [messages, setMessages, addMessage] = useMessages();
21
+ const [peer, setPeer] = useState();
22
+ const [remotePeers, setRemotePeers] = useStorage("rpc-remote-peer", {});
23
+ peerId = addPrefix(peerId);
24
+ if (typeof remotePeerId === "string")
25
+ remotePeerId = [remotePeerId];
26
+ const remotePeerIds = remotePeerId.map(addPrefix);
27
+ function handleConnection(conn) {
28
+ connRef.current[conn.peer] = conn;
29
+ conn.on("open", () => {
30
+ conn.on("data", ({ message, messages, remotePeerName, type }) => {
31
+ if (type === "message")
32
+ receiveMessage(message);
33
+ else if (type === "init") {
34
+ setRemotePeers((prev) => {
35
+ prev[conn.peer] = remotePeerName || "Anonymous User";
36
+ return prev;
37
+ });
38
+ if (recoverChat)
39
+ setMessages((old) => (messages.length > old.length ? messages : old));
40
+ }
41
+ });
42
+ conn.send({ type: "init", remotePeerName: name, messages });
43
+ });
44
+ conn.on("close", conn.removeAllListeners);
45
+ }
46
+ function handleError() {
47
+ setAudio(false);
48
+ onMicError();
49
+ }
50
+ function handleRemoteStream(remoteStream) {
51
+ if (audioStreamRef.current)
52
+ audioStreamRef.current.srcObject = remoteStream;
53
+ }
54
+ function receiveMessage(message) {
55
+ addMessage(message);
56
+ onMessageReceived === null || onMessageReceived === void 0 ? void 0 : onMessageReceived(message);
57
+ }
58
+ function sendMessage(message) {
59
+ addMessage(message);
60
+ Object.values(connRef.current).forEach((conn) => conn.send({ type: "message", message }));
61
+ onMessageSent === null || onMessageSent === void 0 ? void 0 : onMessageSent(message);
62
+ }
63
+ useEffect(() => {
64
+ if (!text && !audio) {
65
+ setPeer(undefined);
66
+ return;
67
+ }
68
+ (function () {
69
+ return __awaiter(this, void 0, void 0, function* () {
70
+ const { Peer, util: { supports: { audioVideo, data }, }, } = yield import("peerjs");
71
+ if (!data || !audioVideo)
72
+ return onError();
73
+ const peer = new Peer(peerId, Object.assign({ config: defaultConfig }, peerOptions));
74
+ setPeer(peer);
75
+ });
76
+ })();
77
+ }, [audio]);
78
+ useEffect(() => {
79
+ if (!peer)
80
+ return;
81
+ let calls = {};
82
+ peer.on("open", () => {
83
+ remotePeerIds.forEach((id) => {
84
+ if (text)
85
+ handleConnection(peer.connect(id));
86
+ });
87
+ if (audio) {
88
+ const getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
89
+ try {
90
+ getUserMedia({
91
+ video: false,
92
+ audio: {
93
+ autoGainControl: false,
94
+ noiseSuppression: true,
95
+ echoCancellation: true,
96
+ },
97
+ }, (stream) => {
98
+ localStream.current = stream;
99
+ remotePeerIds.forEach((id) => {
100
+ const call = peer.call(id, stream);
101
+ call.on("stream", handleRemoteStream);
102
+ call.on("close", call.removeAllListeners);
103
+ calls[id] = call;
104
+ });
105
+ peer.on("call", (call) => {
106
+ call.answer(stream);
107
+ call.on("stream", handleRemoteStream);
108
+ call.on("close", call.removeAllListeners);
109
+ calls[call.peer] = call;
110
+ });
111
+ }, handleError);
112
+ }
113
+ catch (_a) {
114
+ handleError();
115
+ }
116
+ }
117
+ });
118
+ peer.on("connection", handleConnection);
119
+ return () => {
120
+ var _a;
121
+ (_a = localStream.current) === null || _a === void 0 ? void 0 : _a.getTracks().forEach((track) => track.stop());
122
+ Object.values(connRef.current).forEach(closeConnection);
123
+ connRef.current = {};
124
+ Object.values(calls).forEach(closeConnection);
125
+ peer.removeAllListeners();
126
+ peer.destroy();
127
+ };
128
+ }, [peer]);
129
+ return { peerId, audioStreamRef, remotePeers, messages, sendMessage, audio, setAudio };
130
+ }
131
+ export function useMessages() {
132
+ const [messages, setMessages] = useStorage("rpc-messages", []);
133
+ const addMessage = (message) => setMessages((prev) => prev.concat(message));
134
+ return [messages, setMessages, addMessage];
135
+ }
136
+ export function useStorage(key, initialValue, local = false) {
137
+ const [storedValue, setStoredValue] = useState(() => {
138
+ if (typeof window === "undefined")
139
+ return initialValue;
140
+ return getStorage(key, initialValue, local);
141
+ });
142
+ const setValue = (value) => {
143
+ setStoredValue((old) => {
144
+ const updatedValue = typeof value === "function" ? value(old) : value;
145
+ setStorage(key, updatedValue, local);
146
+ return updatedValue;
147
+ });
148
+ };
149
+ return [storedValue, setValue];
150
+ }
151
+ export function useAudio(allowed) {
152
+ const [audio, setAudio] = useStorage("rpc-audio", false, true);
153
+ const enabled = audio || allowed;
154
+ return [enabled, setAudio];
155
+ }
package/dist/icons.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- export type IconProps = React.DetailedHTMLProps<React.HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>;
2
+ import { IconProps } from "./types.js";
3
3
  export declare function BiSolidMessageDetail(props: IconProps): React.JSX.Element;
4
4
  export declare function BiSolidMessageX(props: IconProps): React.JSX.Element;
5
5
  export declare function GrSend(props: IconProps): React.JSX.Element;
package/dist/index.d.ts CHANGED
@@ -1,42 +1,6 @@
1
- import React, { CSSProperties, DetailedHTMLProps, HTMLAttributes, ReactNode } from "react";
2
- import { PeerOptions } from "peerjs";
3
- export type RemotePeerId = string | string[];
4
- export type { PeerOptions };
5
- export type DialogPosition = "left" | "center" | "right";
6
- export type DialogOptions = {
7
- position?: DialogPosition;
8
- style?: CSSProperties;
9
- };
10
- export type RemotePeers = {
11
- [id: string]: string;
12
- };
13
- export type Message = {
14
- id: string;
15
- text: string;
16
- };
17
- export type ChildrenOptions = {
18
- remotePeers?: RemotePeers;
19
- messages?: Message[];
20
- addMessage?: (message: Message, sendToRemotePeer?: boolean) => void;
21
- audio?: boolean;
22
- setAudio?: (audio: boolean) => void;
23
- };
24
- export type Children = (childrenOptions: ChildrenOptions) => ReactNode;
25
- export type Props = DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
26
- export type ChatProps = {
27
- name?: string;
28
- peerId: string;
29
- remotePeerId?: RemotePeerId;
30
- text?: boolean;
31
- recoverChat?: boolean;
32
- voice?: boolean;
33
- peerOptions?: PeerOptions;
34
- dialogOptions?: DialogOptions;
35
- onError?: Function;
36
- onMicError?: Function;
37
- children?: Children;
38
- props?: Props;
39
- };
40
- export type { IconProps } from "./icons.js";
41
- export default function Chat({ name, peerId, remotePeerId, peerOptions, text, recoverChat, voice, dialogOptions, onError, onMicError, children, props }: ChatProps): React.JSX.Element;
42
- export declare const clearChat: () => void;
1
+ import React from "react";
2
+ import { useChat } from "./hooks.js";
3
+ import { clearChat } from "./storage.js";
4
+ import { ChatProps } from "./types.js";
5
+ export default function Chat({ text, audio, onMessageReceived, dialogOptions, props, children, ...hookProps }: ChatProps): React.JSX.Element;
6
+ export { clearChat, useChat };
package/dist/index.js CHANGED
@@ -1,149 +1,34 @@
1
- var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
- function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
- return new (P || (P = Promise))(function (resolve, reject) {
4
- function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
- function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
- function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
- step((generator = generator.apply(thisArg, _arguments || [])).next());
8
- });
1
+ var __rest = (this && this.__rest) || function (s, e) {
2
+ var t = {};
3
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
4
+ t[p] = s[p];
5
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
6
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
7
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
8
+ t[p[i]] = s[p[i]];
9
+ }
10
+ return t;
9
11
  };
10
12
  import React, { useEffect, useRef, useState } from "react";
11
- import useStorage, { removeStorage } from "./storage.js";
13
+ import { useChat } from "./hooks.js";
12
14
  import { BiSolidMessageDetail, BiSolidMessageX, BsFillMicFill, BsFillMicMuteFill, GrSend } from "./icons.js";
13
- const turnAccounts = [
14
- { username: "70061a377b51f3a3d01c11e3", credential: "lHV4NYJ5Rfl5JNa9" },
15
- { username: "13b19eb65bbf6e9f96d64b72", credential: "7R9P/+7y7Q516Etv" },
16
- { username: "3469603f5cdc7ca4a1e891ae", credential: "/jMyLSDbbcgqpVQv" },
17
- { username: "a7926f4dcc4a688d41f89752", credential: "ZYM8jFYeb8bQkL+N" },
18
- { username: "0be25ab7f61d9d733ba94809", credential: "hiiSwWVch+ftt3SX" },
19
- { username: "3c25ba948daeab04f9b66187", credential: "FQB3GQwd27Y0dPeK" },
20
- ];
21
- const defaultConfig = {
22
- iceServers: [
23
- {
24
- urls: ["stun:stun.l.google.com:19302", "stun:stun.relay.metered.ca:80"],
25
- },
26
- ].concat(turnAccounts.map((account) => (Object.assign({ urls: ["turn:standard.relay.metered.ca:80", "turn:standard.relay.metered.ca:80?transport=tcp", "turn:standard.relay.metered.ca:443", "turns:standard.relay.metered.ca:443?transport=tcp"] }, account)))),
27
- };
28
- function closeConnection(conn) {
29
- conn.removeAllListeners();
30
- conn.close();
31
- }
32
- export default function Chat({ name, peerId, remotePeerId = [], peerOptions, text = true, recoverChat = false, voice = true, dialogOptions, onError = () => alert("Browser not supported! Try some other browser."), onMicError = () => alert("Microphone not accessible!"), children, props = {} }) {
33
- const [peer, setPeer] = useState();
34
- const [notification, setNotification] = useState(false);
35
- const [remotePeers, setRemotePeers] = useStorage("rpc-remote-peer", {});
36
- const [messages, setMessages] = useStorage("rpc-messages", []);
37
- const connRef = useRef({});
15
+ import { clearChat } from "./storage.js";
16
+ export default function Chat(_a) {
17
+ var { text = true, audio = true, onMessageReceived, dialogOptions, props = {}, children } = _a, hookProps = __rest(_a, ["text", "audio", "onMessageReceived", "dialogOptions", "props", "children"]);
18
+ const _b = useChat(Object.assign({ text,
19
+ audio, onMessageReceived: modifiedOnMessageReceived }, hookProps)), { peerId, audioStreamRef } = _b, childrenOptions = __rest(_b, ["peerId", "audioStreamRef"]);
20
+ const { remotePeers, messages, sendMessage, audio: audioEnabled, setAudio } = childrenOptions;
21
+ const containerRef = useRef(null);
38
22
  const [dialog, setDialog] = useState(false);
39
23
  const dialogRef = useRef(null);
40
- const containerRef = useRef(null);
41
24
  const inputRef = useRef(null);
42
- const [audio, setAudio] = useStorage("rpc-audio", false, true);
43
- const streamRef = useRef(null);
44
- const localStream = useRef();
45
- peerId = `rpc-${peerId}`;
46
- if (typeof remotePeerId === "string")
47
- remotePeerId = [remotePeerId];
48
- const remotePeerIds = remotePeerId.map((id) => `rpc-${id}`);
49
- const handleRemoteStream = (remoteStream) => (streamRef.current.srcObject = remoteStream);
50
- function addMessage(message, sendToRemotePeer = false) {
25
+ const [notification, setNotification] = useState(false);
26
+ function modifiedOnMessageReceived(message) {
51
27
  var _a;
52
- setMessages((prev) => prev.concat(message));
53
- if (sendToRemotePeer)
54
- Object.values(connRef.current).forEach((conn) => conn.send({ type: "message", message }));
55
- else if (!((_a = dialogRef.current) === null || _a === void 0 ? void 0 : _a.open))
28
+ if (!((_a = dialogRef.current) === null || _a === void 0 ? void 0 : _a.open))
56
29
  setNotification(true);
30
+ onMessageReceived === null || onMessageReceived === void 0 ? void 0 : onMessageReceived(message);
57
31
  }
58
- function handleConnection(conn) {
59
- connRef.current[conn.peer] = conn;
60
- conn.on("open", () => {
61
- conn.on("data", ({ type, message, remotePeerName, messages }) => {
62
- if (type === "message")
63
- addMessage(message);
64
- else if (type === "init") {
65
- setRemotePeers((prev) => {
66
- prev[conn.peer] = remotePeerName || "Anonymous User";
67
- return prev;
68
- });
69
- if (recoverChat)
70
- setMessages((old) => (messages.length > old.length ? messages : old));
71
- }
72
- });
73
- conn.send({ type: "init", remotePeerName: name, messages });
74
- });
75
- conn.on("close", conn.removeAllListeners);
76
- }
77
- function handleError() {
78
- setAudio(false);
79
- onMicError();
80
- }
81
- useEffect(() => {
82
- if (!text && !audio) {
83
- setPeer(undefined);
84
- return;
85
- }
86
- (function () {
87
- return __awaiter(this, void 0, void 0, function* () {
88
- const { Peer, util: { supports: { audioVideo, data }, }, } = yield import("peerjs");
89
- if (!data || !audioVideo)
90
- return onError();
91
- const peer = new Peer(peerId, Object.assign({ config: defaultConfig }, peerOptions));
92
- setPeer(peer);
93
- });
94
- })();
95
- }, [audio]);
96
- useEffect(() => {
97
- if (!peer)
98
- return;
99
- let calls = {};
100
- peer.on("open", () => {
101
- remotePeerIds.forEach((id) => {
102
- if (text)
103
- handleConnection(peer.connect(id));
104
- });
105
- if (audio) {
106
- const getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
107
- try {
108
- getUserMedia({
109
- video: false,
110
- audio: {
111
- autoGainControl: false,
112
- noiseSuppression: true,
113
- echoCancellation: true,
114
- },
115
- }, (stream) => {
116
- localStream.current = stream;
117
- remotePeerIds.forEach((id) => {
118
- const call = peer.call(id, stream);
119
- call.on("stream", handleRemoteStream);
120
- call.on("close", call.removeAllListeners);
121
- calls[id] = call;
122
- });
123
- peer.on("call", (call) => {
124
- call.answer(stream);
125
- call.on("stream", handleRemoteStream);
126
- call.on("close", call.removeAllListeners);
127
- calls[call.peer] = call;
128
- });
129
- }, handleError);
130
- }
131
- catch (_a) {
132
- handleError();
133
- }
134
- }
135
- });
136
- peer.on("connection", handleConnection);
137
- return () => {
138
- var _a;
139
- (_a = localStream.current) === null || _a === void 0 ? void 0 : _a.getTracks().forEach((track) => track.stop());
140
- Object.values(connRef.current).forEach(closeConnection);
141
- connRef.current = {};
142
- Object.values(calls).forEach(closeConnection);
143
- peer.removeAllListeners();
144
- peer.destroy();
145
- };
146
- }, [peer]);
147
32
  useEffect(() => {
148
33
  var _a, _b;
149
34
  if (dialog)
@@ -157,7 +42,7 @@ export default function Chat({ name, peerId, remotePeerId = [], peerOptions, tex
157
42
  container.scrollTop = container.scrollHeight;
158
43
  }, [dialog, remotePeers, messages]);
159
44
  return (React.createElement("div", Object.assign({ className: "rpc-main rpc-font" }, props),
160
- typeof children === "function" ? (children({ remotePeers, messages, addMessage, audio, setAudio })) : (React.createElement(React.Fragment, null,
45
+ typeof children === "function" ? (children(childrenOptions)) : (React.createElement(React.Fragment, null,
161
46
  text && (React.createElement("div", { className: "rpc-dialog-container" },
162
47
  dialog ? (React.createElement(BiSolidMessageX, { title: "Close chat", onClick: () => setDialog(false) })) : (React.createElement("div", { className: "rpc-notification" },
163
48
  React.createElement(BiSolidMessageDetail, { title: "Open chat", onClick: () => {
@@ -181,16 +66,13 @@ export default function Chat({ name, peerId, remotePeerId = [], peerOptions, tex
181
66
  const text = (_a = inputRef.current) === null || _a === void 0 ? void 0 : _a.value;
182
67
  if (text) {
183
68
  inputRef.current.value = "";
184
- addMessage({ id: peerId, text }, true);
69
+ sendMessage({ id: peerId, text });
185
70
  }
186
71
  } },
187
72
  React.createElement("input", { ref: inputRef, className: "rpc-input rpc-font", placeholder: "Enter a message" }),
188
73
  React.createElement("button", { type: "submit", className: "rpc-button" },
189
74
  React.createElement(GrSend, { title: "Send message" }))))))),
190
- voice && React.createElement("div", null, audio ? React.createElement(BsFillMicFill, { title: "Turn mic off", onClick: () => setAudio(false) }) : React.createElement(BsFillMicMuteFill, { title: "Turn mic on", onClick: () => setAudio(true) })))),
191
- voice && audio && React.createElement("audio", { ref: streamRef, autoPlay: true, style: { display: "none" } })));
75
+ audio && React.createElement("button", null, audioEnabled ? React.createElement(BsFillMicFill, { title: "Turn mic off", onClick: () => setAudio(false) }) : React.createElement(BsFillMicMuteFill, { title: "Turn mic on", onClick: () => setAudio(true) })))),
76
+ audio && audioEnabled && React.createElement("audio", { ref: audioStreamRef, autoPlay: true, style: { display: "none" } })));
192
77
  }
193
- export const clearChat = () => {
194
- removeStorage("rpc-remote-peer");
195
- removeStorage("rpc-messages");
196
- };
78
+ export { clearChat, useChat };
package/dist/storage.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export declare const removeStorage: (key: string, local?: boolean) => void;
2
- export default function useStorage<Value>(key: string, initialValue: Value, local?: boolean): [Value, (value: Value | ((old: Value) => Value)) => void];
2
+ export declare const setStorage: (key: string, value: any, local?: boolean) => void;
3
+ export declare const getStorage: (key: string, fallbackValue?: any, local?: boolean) => any;
4
+ export declare const clearChat: () => void;
package/dist/storage.js CHANGED
@@ -1,7 +1,6 @@
1
- import { useState } from "react";
2
- const setStorage = (key, value, local = false) => (local ? localStorage : sessionStorage).setItem(key, JSON.stringify(value));
3
1
  export const removeStorage = (key, local = false) => (local ? localStorage : sessionStorage).removeItem(key);
4
- const getStorage = (key, fallbackValue, local = false) => {
2
+ export const setStorage = (key, value, local = false) => (local ? localStorage : sessionStorage).setItem(key, JSON.stringify(value));
3
+ export const getStorage = (key, fallbackValue, local = false) => {
5
4
  let value = (local ? localStorage : sessionStorage).getItem(key);
6
5
  try {
7
6
  if (!value)
@@ -20,18 +19,7 @@ const getStorage = (key, fallbackValue, local = false) => {
20
19
  }
21
20
  return value;
22
21
  };
23
- export default function useStorage(key, initialValue, local = false) {
24
- const [storedValue, setStoredValue] = useState(() => {
25
- if (typeof window === "undefined")
26
- return initialValue;
27
- return getStorage(key, initialValue, local);
28
- });
29
- const setValue = (value) => {
30
- setStoredValue((old) => {
31
- const updatedValue = typeof value === "function" ? value(old) : value;
32
- setStorage(key, updatedValue, local);
33
- return updatedValue;
34
- });
35
- };
36
- return [storedValue, setValue];
37
- }
22
+ export const clearChat = () => {
23
+ removeStorage("rpc-remote-peer");
24
+ removeStorage("rpc-messages");
25
+ };
@@ -0,0 +1,45 @@
1
+ import { PeerOptions } from "peerjs";
2
+ import { CSSProperties, DetailedHTMLProps, HTMLAttributes, ReactNode } from "react";
3
+ export type Message = {
4
+ id: string;
5
+ text: string;
6
+ };
7
+ export type MessageEvent = (message: Message) => void;
8
+ export type { PeerOptions };
9
+ export type RemotePeerId = string | string[];
10
+ export type useChatProps = {
11
+ name?: string;
12
+ peerId: string;
13
+ remotePeerId?: RemotePeerId;
14
+ text?: boolean;
15
+ recoverChat?: boolean;
16
+ audio?: boolean;
17
+ peerOptions?: PeerOptions;
18
+ onError?: Function;
19
+ onMicError?: Function;
20
+ onMessageSent?: MessageEvent;
21
+ onMessageReceived?: MessageEvent;
22
+ };
23
+ export type IconProps = React.DetailedHTMLProps<React.HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>;
24
+ export type RemotePeers = {
25
+ [id: string]: string;
26
+ };
27
+ export type Children = (childrenOptions: ChildrenOptions) => ReactNode;
28
+ export type ChildrenOptions = {
29
+ remotePeers?: RemotePeers;
30
+ messages?: Message[];
31
+ sendMessage?: (message: Message) => void;
32
+ audio?: boolean;
33
+ setAudio?: (audio: boolean) => void;
34
+ };
35
+ export type ChatProps = useChatProps & {
36
+ dialogOptions?: DialogOptions;
37
+ props?: DivProps;
38
+ children?: Children;
39
+ };
40
+ export type DialogOptions = {
41
+ position?: DialogPosition;
42
+ style?: CSSProperties;
43
+ };
44
+ export type DialogPosition = "left" | "center" | "right";
45
+ export type DivProps = DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export declare const addPrefix: (str: string) => string;
package/dist/utils.js ADDED
@@ -0,0 +1 @@
1
+ export const addPrefix = (str) => `rpc-${str}`;
package/package.json CHANGED
@@ -1,14 +1,37 @@
1
1
  {
2
2
  "name": "react-peer-chat",
3
- "version": "0.6.8",
3
+ "version": "0.7.0",
4
4
  "description": "An easy to use react component for impleting peer-to-peer chatting.",
5
- "type": "module",
6
- "main": "dist/index.js",
7
- "types": "dist/index.d.ts",
5
+ "license": "MIT",
6
+ "author": "Sahil Aggarwal <aggarwalsahil2004@gmail.com>",
7
+ "contributors": [],
8
+ "homepage": "https://github.com/SahilAggarwal2004/react-peer-chat#readme",
8
9
  "repository": {
9
10
  "type": "git",
10
11
  "url": "git+https://github.com/SahilAggarwal2004/react-peer-chat.git"
11
12
  },
13
+ "bugs": {
14
+ "url": "https://github.com/SahilAggarwal2004/react-peer-chat/issues"
15
+ },
16
+ "type": "module",
17
+ "exports": {
18
+ ".": "./dist/index.js",
19
+ "./icons": "./dist/icons.js",
20
+ "./styles.css": "./dist/styles.css",
21
+ "./types": "./dist/types.js"
22
+ },
23
+ "main": "dist/index.js",
24
+ "types": "dist/index.d.ts",
25
+ "dependencies": {
26
+ "peerjs": "^1.5.4"
27
+ },
28
+ "devDependencies": {
29
+ "@types/react": "^18.3.12"
30
+ },
31
+ "peerDependencies": {
32
+ "react": ">=17.0.0",
33
+ "react-dom": ">=17.0.0"
34
+ },
12
35
  "keywords": [
13
36
  "react",
14
37
  "chat",
@@ -20,27 +43,11 @@
20
43
  "webrtc",
21
44
  "react-peer-chat",
22
45
  "typescript",
23
- "voice-chat",
24
46
  "p2p-chat",
25
47
  "text-chat",
26
- "peerjs-webrtc"
48
+ "voice-chat",
49
+ "audio-chat"
27
50
  ],
28
- "author": "Sahil Aggarwal",
29
- "license": "MIT",
30
- "bugs": {
31
- "url": "https://github.com/SahilAggarwal2004/react-peer-chat/issues"
32
- },
33
- "homepage": "https://github.com/SahilAggarwal2004/react-peer-chat#readme",
34
- "peerDependencies": {
35
- "react": ">=17.0.0",
36
- "react-dom": ">=17.0.0"
37
- },
38
- "devDependencies": {
39
- "@types/react": "^18.3.12"
40
- },
41
- "dependencies": {
42
- "peerjs": "^1.5.4"
43
- },
44
51
  "scripts": {
45
52
  "copy": "copy .\\src\\*.css dist",
46
53
  "build": "pnpm i && tsc && pnpm copy"