@mademi_dev/chatemi 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +451 -0
- package/dist/api.d.ts +43 -0
- package/dist/assets/styles.css +2 -0
- package/dist/chatemi.js +1626 -0
- package/dist/chatemi.umd.cjs +1 -0
- package/dist/components/ChatEmiLauncher.d.ts +3 -0
- package/dist/components/ChatEmiMessenger.d.ts +3 -0
- package/dist/context.d.ts +42 -0
- package/dist/index.d.ts +8 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/dist/server/mongodb.d.ts +23 -0
- package/dist/server/mongodb.js +58 -0
- package/dist/socket.d.ts +33 -0
- package/dist/types.d.ts +364 -0
- package/docs/BACKEND_CONTRACT.md +305 -0
- package/docs/IMPLEMENTATION_GUIDE.md +306 -0
- package/examples/nextjs-chat-widget/README.md +46 -0
- package/examples/nextjs-chat-widget/app/api/chat/conversations/route.ts +30 -0
- package/examples/nextjs-chat-widget/app/api/chat/socket-events/route.ts +44 -0
- package/examples/nextjs-chat-widget/app/components/ChatWidget.tsx +35 -0
- package/examples/nextjs-chat-widget/app/layout.tsx +20 -0
- package/examples/nextjs-chat-widget/app/lib/chatemi-config.ts +52 -0
- package/examples/nextjs-chat-widget/app/lib/chatemi-mongo.ts +27 -0
- package/examples/nextjs-chat-widget/app/page.tsx +11 -0
- package/package.json +77 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ChatEmi contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
# ChatEmi
|
|
2
|
+
|
|
3
|
+
ChatEmi is a publish-ready React messaging package for building in-app messenger experiences. It includes:
|
|
4
|
+
|
|
5
|
+
- A typed REST API client for conversations, messages, attachments, reactions, read receipts, and search.
|
|
6
|
+
- A typed WebSocket client with reconnects, outbound queuing, typing, presence, receipts, and realtime conversation/message events.
|
|
7
|
+
- Group, channel, direct, and bot conversation models with owner/admin/member roles.
|
|
8
|
+
- Delivered/read receipts, last-seen presence, replies, forwards, avatars, voice messages, images, videos, and files.
|
|
9
|
+
- A modern floating launcher with notification badge, draggable/resizable modal, and compact notification tray.
|
|
10
|
+
- Optional external user-directory API integration.
|
|
11
|
+
- Optional server-side MongoDB connection helpers for API backends.
|
|
12
|
+
- `ChatEmiProvider` for application layout/state.
|
|
13
|
+
- `useChatEmi` for product code that needs chat actions and state.
|
|
14
|
+
- `ChatEmiMessenger`, a default responsive light/dark Telegram-style UI that can be used immediately or customized.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install chatemi
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Documentation and examples
|
|
23
|
+
|
|
24
|
+
- Full implementation guide: [`docs/IMPLEMENTATION_GUIDE.md`](docs/IMPLEMENTATION_GUIDE.md)
|
|
25
|
+
- Backend REST/WebSocket contract: [`docs/BACKEND_CONTRACT.md`](docs/BACKEND_CONTRACT.md)
|
|
26
|
+
- Next.js launcher example: [`examples/nextjs-chat-widget`](examples/nextjs-chat-widget)
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
import { ChatEmiLauncher, ChatEmiMessenger, ChatEmiProvider, useChatEmi } from "chatemi";
|
|
30
|
+
import "chatemi/styles.css";
|
|
31
|
+
|
|
32
|
+
export function App() {
|
|
33
|
+
return (
|
|
34
|
+
<ChatEmiProvider
|
|
35
|
+
config={{
|
|
36
|
+
apiBaseUrl: "https://api.example.com/chat",
|
|
37
|
+
socketUrl: "wss://api.example.com/chat/socket",
|
|
38
|
+
token: () => localStorage.getItem("access_token") ?? undefined,
|
|
39
|
+
theme: "violet",
|
|
40
|
+
notifications: {
|
|
41
|
+
enabled: true,
|
|
42
|
+
browser: true,
|
|
43
|
+
maxStored: 50
|
|
44
|
+
},
|
|
45
|
+
userDirectory: {
|
|
46
|
+
baseUrl: "https://identity.example.com",
|
|
47
|
+
searchPath: "/users/search",
|
|
48
|
+
headers: () => ({
|
|
49
|
+
Authorization: `Bearer ${localStorage.getItem("identity_token")}`
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
<ChatEmiLauncher theme="violet" />
|
|
55
|
+
</ChatEmiProvider>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Next.js app usage
|
|
61
|
+
|
|
62
|
+
Import package CSS once from `app/layout.tsx`:
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
import "chatemi/styles.css";
|
|
66
|
+
import type { Metadata } from "next";
|
|
67
|
+
|
|
68
|
+
export const metadata: Metadata = {
|
|
69
|
+
title: "My app"
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
73
|
+
return (
|
|
74
|
+
<html lang="en">
|
|
75
|
+
<body>{children}</body>
|
|
76
|
+
</html>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Create a client component for the widget:
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
"use client";
|
|
85
|
+
|
|
86
|
+
import { ChatEmiLauncher, ChatEmiProvider } from "chatemi";
|
|
87
|
+
|
|
88
|
+
export function ChatWidget() {
|
|
89
|
+
return (
|
|
90
|
+
<ChatEmiProvider
|
|
91
|
+
config={{
|
|
92
|
+
apiBaseUrl: process.env.NEXT_PUBLIC_CHAT_API_URL!,
|
|
93
|
+
socketUrl: process.env.NEXT_PUBLIC_CHAT_SOCKET_URL,
|
|
94
|
+
token: () => localStorage.getItem("access_token") ?? undefined,
|
|
95
|
+
theme: "glass",
|
|
96
|
+
notifications: {
|
|
97
|
+
enabled: true,
|
|
98
|
+
browser: true,
|
|
99
|
+
showWhenOpen: false
|
|
100
|
+
}
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
<ChatEmiLauncher
|
|
104
|
+
defaultOpen={false}
|
|
105
|
+
placement="bottom-right"
|
|
106
|
+
theme="glass"
|
|
107
|
+
title="Support"
|
|
108
|
+
subtitle="Usually replies fast"
|
|
109
|
+
/>
|
|
110
|
+
</ChatEmiProvider>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Then render `<ChatWidget />` from any client component or include it in a page layout. The provider keeps the socket connected while the launcher modal is closed, so incoming `notification` and `message.created` events continue updating the badge in the background.
|
|
116
|
+
|
|
117
|
+
## Hook usage
|
|
118
|
+
|
|
119
|
+
```tsx
|
|
120
|
+
import { useChatEmi } from "chatemi";
|
|
121
|
+
|
|
122
|
+
export function SendWelcomeButton({ conversationId }: { conversationId: string }) {
|
|
123
|
+
const { actions, activeMessages, connectionStatus } = useChatEmi();
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<button
|
|
127
|
+
onClick={() =>
|
|
128
|
+
actions.sendMessage({
|
|
129
|
+
conversationId,
|
|
130
|
+
text: "Welcome to the chat",
|
|
131
|
+
replyToId: activeMessages.at(-1)?.id
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
>
|
|
135
|
+
Send ({activeMessages.length} loaded, socket {connectionStatus})
|
|
136
|
+
</button>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Expected REST API contract
|
|
142
|
+
|
|
143
|
+
By default ChatEmi calls these paths under `apiBaseUrl`:
|
|
144
|
+
|
|
145
|
+
| Feature | Method/path |
|
|
146
|
+
| --- | --- |
|
|
147
|
+
| Current user | `GET /me` |
|
|
148
|
+
| Users | `GET /users?q=...`, `GET /users/:userId` |
|
|
149
|
+
| Conversations | `GET /conversations`, `POST /conversations` |
|
|
150
|
+
| Conversation detail | `GET /conversations/:conversationId`, `PATCH /conversations/:conversationId`, `DELETE /conversations/:conversationId` |
|
|
151
|
+
| Avatar | `PATCH /conversations/:conversationId/avatar`, `PATCH /users/:userId` |
|
|
152
|
+
| Members/admins | `POST /conversations/:conversationId/members`, `PATCH /conversations/:conversationId/members/:userId`, `DELETE /conversations/:conversationId/members/:userId` |
|
|
153
|
+
| Messages | `GET /conversations/:conversationId/messages`, `POST /conversations/:conversationId/messages` |
|
|
154
|
+
| Message detail | `PATCH /conversations/:conversationId/messages/:messageId`, `DELETE /conversations/:conversationId/messages/:messageId` |
|
|
155
|
+
| Read receipts | `POST /conversations/:conversationId/read` |
|
|
156
|
+
| Delivered receipts | `POST /conversations/:conversationId/delivered` |
|
|
157
|
+
| Forward | `POST /conversations/:conversationId/messages/:messageId/forward` |
|
|
158
|
+
| Reactions | `POST /conversations/:conversationId/messages/:messageId/reactions`, `DELETE /conversations/:conversationId/messages/:messageId/reactions` |
|
|
159
|
+
| Attachment upload | `POST /attachments` multipart form data |
|
|
160
|
+
| Search | `GET /search/messages?q=...` |
|
|
161
|
+
|
|
162
|
+
If your backend uses different paths, pass `config.endpoints` to override any route.
|
|
163
|
+
|
|
164
|
+
## Socket event contract
|
|
165
|
+
|
|
166
|
+
The socket sends and receives JSON envelopes:
|
|
167
|
+
|
|
168
|
+
```json
|
|
169
|
+
{
|
|
170
|
+
"type": "message.created",
|
|
171
|
+
"payload": {}
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Built-in incoming event names include:
|
|
176
|
+
|
|
177
|
+
- `conversation.created`
|
|
178
|
+
- `conversation.updated`
|
|
179
|
+
- `conversation.deleted`
|
|
180
|
+
- `conversation.member.added`
|
|
181
|
+
- `conversation.member.updated`
|
|
182
|
+
- `conversation.member.removed`
|
|
183
|
+
- `message.created`
|
|
184
|
+
- `message.updated`
|
|
185
|
+
- `message.deleted`
|
|
186
|
+
- `message.receipt`
|
|
187
|
+
- `message.reaction`
|
|
188
|
+
- `typing`
|
|
189
|
+
- `presence`
|
|
190
|
+
- `notification`
|
|
191
|
+
|
|
192
|
+
Built-in outgoing helper events include:
|
|
193
|
+
|
|
194
|
+
- `conversation.subscribe`
|
|
195
|
+
- `conversation.unsubscribe`
|
|
196
|
+
- `typing`
|
|
197
|
+
- `message.read`
|
|
198
|
+
- `message.delivered`
|
|
199
|
+
- `message.forward`
|
|
200
|
+
- `conversation.member.update`
|
|
201
|
+
- `conversation.avatar.update`
|
|
202
|
+
- `presence`
|
|
203
|
+
|
|
204
|
+
## Launcher, themes, and notifications
|
|
205
|
+
|
|
206
|
+
Use `ChatEmiLauncher` when you want a floating in-app messenger:
|
|
207
|
+
|
|
208
|
+
```tsx
|
|
209
|
+
<ChatEmiLauncher
|
|
210
|
+
placement="bottom-right"
|
|
211
|
+
theme="midnight"
|
|
212
|
+
title="Messages"
|
|
213
|
+
subtitle="Team chat"
|
|
214
|
+
initialSize={{ width: 460, height: 720 }}
|
|
215
|
+
minSize={{ width: 360, height: 520 }}
|
|
216
|
+
maxSize={{ width: 960, height: 860 }}
|
|
217
|
+
/>
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The launcher includes:
|
|
221
|
+
|
|
222
|
+
- toggle button with unread notification badge
|
|
223
|
+
- draggable modal header on desktop
|
|
224
|
+
- native CSS resize handle on desktop
|
|
225
|
+
- compact notification tray above the chat
|
|
226
|
+
- mobile-friendly full-width modal behavior
|
|
227
|
+
|
|
228
|
+
Notification events should use this envelope:
|
|
229
|
+
|
|
230
|
+
```json
|
|
231
|
+
{
|
|
232
|
+
"type": "notification",
|
|
233
|
+
"payload": {
|
|
234
|
+
"id": "notif_1",
|
|
235
|
+
"kind": "message",
|
|
236
|
+
"title": "Ava",
|
|
237
|
+
"body": "Sent a new message",
|
|
238
|
+
"conversationId": "chat_1",
|
|
239
|
+
"messageId": "message_1",
|
|
240
|
+
"createdAt": "2026-06-19T15:43:00.000Z"
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
If the backend only emits `message.created`, ChatEmi creates a local message notification automatically for messages sent by other users.
|
|
246
|
+
|
|
247
|
+
Browser notifications are optional and request permission from a user gesture when the launcher opens:
|
|
248
|
+
|
|
249
|
+
```tsx
|
|
250
|
+
<ChatEmiProvider
|
|
251
|
+
config={{
|
|
252
|
+
apiBaseUrl: "https://api.example.com/chat",
|
|
253
|
+
socketUrl: "wss://api.example.com/chat/socket",
|
|
254
|
+
notifications: {
|
|
255
|
+
enabled: true,
|
|
256
|
+
browser: true,
|
|
257
|
+
showWhenOpen: false,
|
|
258
|
+
maxStored: 100
|
|
259
|
+
}
|
|
260
|
+
}}
|
|
261
|
+
>
|
|
262
|
+
<ChatEmiLauncher />
|
|
263
|
+
</ChatEmiProvider>
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
## Groups, channels, admins, and members
|
|
267
|
+
|
|
268
|
+
Create groups and channels by calling the typed API or hook action:
|
|
269
|
+
|
|
270
|
+
```tsx
|
|
271
|
+
const { actions } = useChatEmi();
|
|
272
|
+
|
|
273
|
+
await actions.createConversation({
|
|
274
|
+
type: "group",
|
|
275
|
+
title: "Product Team",
|
|
276
|
+
participantIds: ["user_1", "user_2"],
|
|
277
|
+
avatarUrl: "https://cdn.example.com/product.png"
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
await actions.createConversation({
|
|
281
|
+
type: "channel",
|
|
282
|
+
title: "Announcements",
|
|
283
|
+
participantIds: ["owner_1"],
|
|
284
|
+
readOnly: true,
|
|
285
|
+
publicUsername: "company_announcements"
|
|
286
|
+
});
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Conversation members can include roles and permissions:
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
{
|
|
293
|
+
user: { id: "user_1", name: "Ava" },
|
|
294
|
+
role: "admin",
|
|
295
|
+
permissions: ["manage_members", "pin_messages", "send_media"],
|
|
296
|
+
joinedAt: "2026-06-19T00:00:00.000Z"
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
The default UI shows a member-management panel to owners/admins/moderators when `enableAdminControls` is enabled.
|
|
301
|
+
|
|
302
|
+
## Messages, receipts, replies, forwards, and media
|
|
303
|
+
|
|
304
|
+
Messages support:
|
|
305
|
+
|
|
306
|
+
- text and HTML bodies
|
|
307
|
+
- `replyToId`/`replyTo`
|
|
308
|
+
- `forwardedFrom`, `forwardedFromConversationId`, and `forwardedFromMessageId`
|
|
309
|
+
- `deliveredTo` and `readBy` receipts
|
|
310
|
+
- images, videos, audio, voice messages, generic files, locations, and contacts
|
|
311
|
+
|
|
312
|
+
```tsx
|
|
313
|
+
await actions.sendMessage({
|
|
314
|
+
conversationId: "chat_1",
|
|
315
|
+
text: "Here is the design",
|
|
316
|
+
replyToId: "message_1",
|
|
317
|
+
attachments: [
|
|
318
|
+
{
|
|
319
|
+
id: "attachment_1",
|
|
320
|
+
type: "image",
|
|
321
|
+
url: "https://cdn.example.com/design.png",
|
|
322
|
+
name: "design.png"
|
|
323
|
+
}
|
|
324
|
+
]
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
await actions.forwardMessage({
|
|
328
|
+
sourceConversationId: "chat_1",
|
|
329
|
+
targetConversationId: "chat_2",
|
|
330
|
+
messageId: "message_1"
|
|
331
|
+
});
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
## Last seen and presence
|
|
335
|
+
|
|
336
|
+
Users can include `presence` and `lastSeenAt`. The default UI renders direct chats as `online`, `last seen 4m ago`, or `last seen recently`.
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
{
|
|
340
|
+
id: "user_1",
|
|
341
|
+
name: "Ava",
|
|
342
|
+
presence: "offline",
|
|
343
|
+
lastSeenAt: "2026-06-19T14:00:00.000Z"
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
## External user API
|
|
348
|
+
|
|
349
|
+
Use `config.userDirectory` when users live outside your chat backend. ChatEmi will call that API for user search and user details without leaking the chat API bearer token unless you add it yourself in `userDirectory.headers`.
|
|
350
|
+
|
|
351
|
+
```tsx
|
|
352
|
+
<ChatEmiProvider
|
|
353
|
+
config={{
|
|
354
|
+
apiBaseUrl: "https://api.example.com/chat",
|
|
355
|
+
userDirectory: {
|
|
356
|
+
baseUrl: "https://identity.example.com",
|
|
357
|
+
searchPath: "/directory/users",
|
|
358
|
+
userPath: (userId) => `/directory/users/${userId}`,
|
|
359
|
+
headers: async () => ({
|
|
360
|
+
Authorization: `Bearer ${await getIdentityToken()}`
|
|
361
|
+
}),
|
|
362
|
+
mapUser: (raw) => {
|
|
363
|
+
const user = raw as { id: string; displayName: string; photo?: string };
|
|
364
|
+
return {
|
|
365
|
+
id: user.id,
|
|
366
|
+
name: user.displayName,
|
|
367
|
+
avatarUrl: user.photo
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}}
|
|
372
|
+
>
|
|
373
|
+
<ChatEmiMessenger />
|
|
374
|
+
</ChatEmiProvider>
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
## MongoDB backend integration
|
|
378
|
+
|
|
379
|
+
MongoDB must be connected from your API server, not from browser React code. Install the optional peer dependency in your backend:
|
|
380
|
+
|
|
381
|
+
```bash
|
|
382
|
+
npm install chatemi mongodb
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
import { createChatEmiMongoConnection } from "chatemi/server";
|
|
387
|
+
|
|
388
|
+
const chatDb = await createChatEmiMongoConnection({
|
|
389
|
+
uri: process.env.MONGODB_URI!,
|
|
390
|
+
databaseName: "chatemi",
|
|
391
|
+
// Pass MongoClient options that match your deployment. ChatEmi intentionally
|
|
392
|
+
// does not guess pool sizes because serverless and long-running servers need
|
|
393
|
+
// different connection strategies.
|
|
394
|
+
clientOptions: {
|
|
395
|
+
appName: "chatemi-api"
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
await chatDb.ensureIndexes();
|
|
400
|
+
|
|
401
|
+
export const conversations = chatDb.collections.conversations;
|
|
402
|
+
export const messages = chatDb.collections.messages;
|
|
403
|
+
export const members = chatDb.collections.members;
|
|
404
|
+
export const receipts = chatDb.collections.receipts;
|
|
405
|
+
export const attachments = chatDb.collections.attachments;
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Connection guidance:
|
|
409
|
+
|
|
410
|
+
- Create one MongoDB client per server process and reuse it.
|
|
411
|
+
- Do not put MongoDB credentials in React/browser code.
|
|
412
|
+
- For serverless functions, initialize the connection outside the handler so warm invocations reuse it.
|
|
413
|
+
- For long-running servers, pass pool/timeouts through `clientOptions` based on observed concurrency and MongoDB connection metrics.
|
|
414
|
+
|
|
415
|
+
## Light mode and dark mode
|
|
416
|
+
|
|
417
|
+
Use `theme="light"`, `theme="dark"`, `theme="system"`, `theme="midnight"`, `theme="glass"`, `theme="emerald"`, or `theme="violet"`:
|
|
418
|
+
|
|
419
|
+
```tsx
|
|
420
|
+
<ChatEmiLauncher theme="glass" />
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
## Customizing the UI
|
|
424
|
+
|
|
425
|
+
```tsx
|
|
426
|
+
<ChatEmiMessenger
|
|
427
|
+
composerPlaceholder="Message the team"
|
|
428
|
+
renderConversation={(conversation, isActive) => (
|
|
429
|
+
<span style={{ fontWeight: isActive ? 800 : 500 }}>{conversation.title}</span>
|
|
430
|
+
)}
|
|
431
|
+
renderMessage={(message, isMine) => (
|
|
432
|
+
<div className={isMine ? "mine" : "theirs"}>{message.text}</div>
|
|
433
|
+
)}
|
|
434
|
+
/>
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
## Development
|
|
438
|
+
|
|
439
|
+
```bash
|
|
440
|
+
npm install
|
|
441
|
+
npm run typecheck
|
|
442
|
+
npm run build
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
## Publishing
|
|
446
|
+
|
|
447
|
+
Update the version in `package.json`, then run:
|
|
448
|
+
|
|
449
|
+
```bash
|
|
450
|
+
npm publish
|
|
451
|
+
```
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ChatEmiAttachment, ChatEmiConfig, ChatEmiConversation, ChatEmiCreateConversationInput, ChatEmiEditMessageInput, ChatEmiForwardMessageInput, ChatEmiID, ChatEmiListOptions, ChatEmiManageMemberInput, ChatEmiMember, ChatEmiMessage, ChatEmiMessageListOptions, ChatEmiPage, ChatEmiSendMessageInput, ChatEmiUpdateAvatarInput, ChatEmiUpdateMemberInput, ChatEmiUploadAttachmentInput, ChatEmiUser, ChatEmiUserSearchOptions } from "./types";
|
|
2
|
+
export declare class ChatEmiApiError extends Error {
|
|
3
|
+
status: number;
|
|
4
|
+
payload: unknown;
|
|
5
|
+
constructor(message: string, status: number, payload: unknown);
|
|
6
|
+
}
|
|
7
|
+
export declare class ChatEmiApi {
|
|
8
|
+
private readonly config;
|
|
9
|
+
private readonly fetcher;
|
|
10
|
+
constructor(config: ChatEmiConfig);
|
|
11
|
+
getMe(signal?: AbortSignal): Promise<ChatEmiUser>;
|
|
12
|
+
searchUsers(options: ChatEmiUserSearchOptions, signal?: AbortSignal): Promise<ChatEmiPage<ChatEmiUser>>;
|
|
13
|
+
getUser(userId: ChatEmiID, signal?: AbortSignal): Promise<ChatEmiUser>;
|
|
14
|
+
listConversations(options?: ChatEmiListOptions, signal?: AbortSignal): Promise<ChatEmiPage<ChatEmiConversation>>;
|
|
15
|
+
getConversation(conversationId: ChatEmiID, signal?: AbortSignal): Promise<ChatEmiConversation>;
|
|
16
|
+
createConversation(input: ChatEmiCreateConversationInput): Promise<ChatEmiConversation>;
|
|
17
|
+
createGroup(input: Omit<ChatEmiCreateConversationInput, "type">): Promise<ChatEmiConversation>;
|
|
18
|
+
createChannel(input: Omit<ChatEmiCreateConversationInput, "type">): Promise<ChatEmiConversation>;
|
|
19
|
+
updateConversation(conversationId: ChatEmiID, input: Partial<ChatEmiConversation>): Promise<ChatEmiConversation>;
|
|
20
|
+
archiveConversation(conversationId: ChatEmiID): Promise<void>;
|
|
21
|
+
updateConversationAvatar(input: ChatEmiUpdateAvatarInput): Promise<ChatEmiConversation | ChatEmiUser>;
|
|
22
|
+
addMembers(conversationId: ChatEmiID, userIds: ChatEmiID[]): Promise<ChatEmiMember[]>;
|
|
23
|
+
updateMember(input: ChatEmiUpdateMemberInput): Promise<ChatEmiMember>;
|
|
24
|
+
removeMember(input: ChatEmiManageMemberInput): Promise<void>;
|
|
25
|
+
listMessages(conversationId: ChatEmiID, options?: ChatEmiMessageListOptions, signal?: AbortSignal): Promise<ChatEmiPage<ChatEmiMessage>>;
|
|
26
|
+
sendMessage(input: ChatEmiSendMessageInput): Promise<ChatEmiMessage>;
|
|
27
|
+
forwardMessage(input: ChatEmiForwardMessageInput): Promise<ChatEmiMessage>;
|
|
28
|
+
editMessage(input: ChatEmiEditMessageInput): Promise<ChatEmiMessage>;
|
|
29
|
+
deleteMessage(conversationId: ChatEmiID, messageId: ChatEmiID): Promise<void>;
|
|
30
|
+
markConversationRead(conversationId: ChatEmiID, messageIds?: ChatEmiID[]): Promise<void>;
|
|
31
|
+
markConversationDelivered(conversationId: ChatEmiID, messageIds?: ChatEmiID[]): Promise<void>;
|
|
32
|
+
addReaction(conversationId: ChatEmiID, messageId: ChatEmiID, emoji: string): Promise<ChatEmiMessage>;
|
|
33
|
+
removeReaction(conversationId: ChatEmiID, messageId: ChatEmiID, emoji: string): Promise<ChatEmiMessage>;
|
|
34
|
+
uploadAttachment(input: ChatEmiUploadAttachmentInput): Promise<ChatEmiAttachment>;
|
|
35
|
+
searchMessages(query: string, options?: ChatEmiListOptions, signal?: AbortSignal): Promise<ChatEmiPage<ChatEmiMessage>>;
|
|
36
|
+
private endpoint;
|
|
37
|
+
private request;
|
|
38
|
+
private buildUrl;
|
|
39
|
+
private resolveHeaders;
|
|
40
|
+
private resolveUserDirectoryHeaders;
|
|
41
|
+
private parseResponse;
|
|
42
|
+
private errorMessage;
|
|
43
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
.chatemi{--chatemi-bg:#f7f8fc;--chatemi-panel:#ffffffeb;--chatemi-border:#94a3b847;--chatemi-text:#0f172a;--chatemi-muted:#64748b;--chatemi-primary:#2563eb;--chatemi-primary-dark:#1d4ed8;--chatemi-bubble:#eef2ff;--chatemi-bubble-mine:linear-gradient(135deg, #2563eb, #7c3aed);--chatemi-danger:#d33f49;--chatemi-shadow:0 24px 80px #0f172a2e;background:var(--chatemi-bg);border:1px solid var(--chatemi-border);box-shadow:var(--chatemi-shadow);color:var(--chatemi-text);border-radius:28px;grid-template-columns:minmax(260px,340px) minmax(0,1fr);width:100%;height:min(760px,100vh);min-height:520px;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;display:grid;overflow:hidden}.chatemi-launcher{--chatemi-bg:#f7f8fc;--chatemi-panel:#ffffffeb;--chatemi-border:#94a3b847;--chatemi-text:#0f172a;--chatemi-muted:#64748b;--chatemi-primary:#2563eb;--chatemi-primary-dark:#1d4ed8;--chatemi-bubble:#eef2ff;--chatemi-bubble-mine:linear-gradient(135deg, #2563eb, #7c3aed);--chatemi-danger:#d33f49;--chatemi-shadow:0 24px 80px #0f172a2e}.chatemi[data-theme=dark],.chatemi-launcher[data-theme=dark]{--chatemi-bg:#101722;--chatemi-panel:#172231;--chatemi-border:#2a3a4e;--chatemi-text:#eef5ff;--chatemi-muted:#94a7bd;--chatemi-primary:#2f9bff;--chatemi-primary-dark:#1877d6;--chatemi-bubble:#223044;--chatemi-bubble-mine:linear-gradient(135deg, #1e88e5, #7c4dff);--chatemi-danger:#ff7380;--chatemi-shadow:0 24px 80px #00000070}.chatemi[data-theme=midnight],.chatemi-launcher[data-theme=midnight]{--chatemi-bg:#070b18;--chatemi-panel:#0d1220f5;--chatemi-border:#6366f138;--chatemi-text:#edf2ff;--chatemi-muted:#9aa8c7;--chatemi-primary:#8b5cf6;--chatemi-primary-dark:#6d28d9;--chatemi-bubble:#151f35;--chatemi-bubble-mine:linear-gradient(135deg, #8b5cf6, #06b6d4);--chatemi-danger:#fb7185;--chatemi-shadow:0 30px 90px #050712a8}.chatemi[data-theme=glass],.chatemi-launcher[data-theme=glass]{--chatemi-bg:linear-gradient(135deg, #eff6ffd1, #faf5ffd1);--chatemi-panel:#ffffffb8;--chatemi-border:#ffffff6b;--chatemi-text:#111827;--chatemi-muted:#64748b;--chatemi-primary:#7c3aed;--chatemi-primary-dark:#5b21b6;--chatemi-bubble:#ffffffbd;--chatemi-bubble-mine:linear-gradient(135deg, #7c3aed, #2563eb);--chatemi-danger:#e11d48;--chatemi-shadow:0 30px 90px #4f46e538}.chatemi[data-theme=emerald],.chatemi-launcher[data-theme=emerald]{--chatemi-bg:#f0fdfa;--chatemi-panel:#fffffff0;--chatemi-border:#14b8a638;--chatemi-text:#042f2e;--chatemi-muted:#4b8079;--chatemi-primary:#0f766e;--chatemi-primary-dark:#115e59;--chatemi-bubble:#ccfbf1;--chatemi-bubble-mine:linear-gradient(135deg, #0f766e, #10b981);--chatemi-danger:#dc2626}.chatemi[data-theme=violet],.chatemi-launcher[data-theme=violet]{--chatemi-bg:#faf5ff;--chatemi-panel:#fffffff0;--chatemi-border:#a855f73d;--chatemi-text:#2e1065;--chatemi-muted:#7e6a9e;--chatemi-primary:#9333ea;--chatemi-primary-dark:#7e22ce;--chatemi-bubble:#f3e8ff;--chatemi-bubble-mine:linear-gradient(135deg, #9333ea, #ec4899);--chatemi-danger:#be123c}@media (prefers-color-scheme:dark){.chatemi[data-theme=system],.chatemi-launcher[data-theme=system]{--chatemi-bg:#101722;--chatemi-panel:#172231;--chatemi-border:#2a3a4e;--chatemi-text:#eef5ff;--chatemi-muted:#94a7bd;--chatemi-primary:#2f9bff;--chatemi-primary-dark:#1877d6;--chatemi-bubble:#223044;--chatemi-bubble-mine:linear-gradient(135deg, #1e88e5, #7c4dff);--chatemi-danger:#ff7380;--chatemi-shadow:0 24px 80px #00000070}}.chatemi *{box-sizing:border-box}.chatemi button,.chatemi input,.chatemi textarea{font:inherit}.chatemi-launcher{color:var(--chatemi-text);z-index:2147483000;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;position:fixed}.chatemi-launcher--bottom-right{bottom:24px;right:24px}.chatemi-launcher--bottom-left{bottom:24px;left:24px}.chatemi-launcher--top-right{top:24px;right:24px}.chatemi-launcher--top-left{top:24px;left:24px}.chatemi-launcher__toggle{background:var(--chatemi-bubble-mine);box-shadow:var(--chatemi-shadow);color:#fff;cursor:pointer;border:0;border-radius:24px;justify-content:center;align-items:center;width:64px;height:64px;transition:transform .2s,box-shadow .2s;display:inline-flex;position:relative}.chatemi-launcher__toggle:hover{transform:translateY(-2px)scale(1.02);box-shadow:0 22px 70px #2563eb5c}.chatemi-launcher__badge{color:#fff;background:#ef4444;border:3px solid #fff;border-radius:999px;justify-content:center;align-items:center;min-width:26px;min-height:26px;padding:0 6px;font-size:.72rem;font-weight:900;display:inline-flex;position:absolute;top:-7px;right:-7px}.chatemi-launcher__modal{background:var(--chatemi-panel);border:1px solid var(--chatemi-border);box-shadow:var(--chatemi-shadow);resize:both;touch-action:none;border-radius:28px;grid-template-rows:auto auto minmax(0,1fr);display:grid;position:absolute;overflow:hidden}.chatemi-launcher--bottom-right .chatemi-launcher__modal{bottom:82px;right:0}.chatemi-launcher--bottom-left .chatemi-launcher__modal{bottom:82px;left:0}.chatemi-launcher--top-right .chatemi-launcher__modal{top:82px;right:0}.chatemi-launcher--top-left .chatemi-launcher__modal{top:82px;left:0}.chatemi-launcher__modal-header{background:linear-gradient(135deg, #ffffff2e, transparent), var(--chatemi-bubble-mine);color:#fff;cursor:grab;-webkit-user-select:none;user-select:none;justify-content:space-between;align-items:center;gap:14px;padding:14px 16px;display:flex}.chatemi-launcher__modal-header:active{cursor:grabbing}.chatemi-launcher__modal-header div{gap:2px;display:grid}.chatemi-launcher__modal-header span{color:#ffffffc2;font-size:.78rem}.chatemi-launcher__modal-header button,.chatemi-launcher__clear{color:inherit;cursor:pointer;background:#ffffff2e;border:1px solid #ffffff52;border-radius:999px;padding:8px 12px;font-weight:800}.chatemi-launcher__notifications{background:color-mix(in srgb, var(--chatemi-panel) 86%, var(--chatemi-primary) 14%);border-bottom:1px solid var(--chatemi-border);grid-template-columns:minmax(0,1fr) auto;gap:8px;padding:10px;display:grid}.chatemi-launcher__notification{border:1px solid var(--chatemi-border);color:var(--chatemi-text);cursor:pointer;text-align:left;background:#ffffffb8;border-radius:18px;grid-column:1;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:10px;padding:8px;display:grid}.chatemi-launcher__notification--unread{box-shadow:inset 3px 0 0 var(--chatemi-primary)}.chatemi-launcher__notification img,.chatemi-launcher__notification>span:first-child{background:var(--chatemi-bubble-mine);color:#fff;object-fit:cover;border-radius:14px;justify-content:center;align-items:center;width:36px;height:36px;font-size:.72rem;font-weight:900;display:inline-flex}.chatemi-launcher__notification span:last-child{min-width:0;display:grid}.chatemi-launcher__notification small{color:var(--chatemi-muted);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.chatemi-launcher__clear{border-color:var(--chatemi-border);color:var(--chatemi-muted);background:0 0;grid-area:1/2;align-self:start}.chatemi-launcher__modal .chatemi{box-shadow:none;border:0;border-radius:0;height:100%;min-height:0}.chatemi__sidebar{border-right:1px solid var(--chatemi-border);background:#ffffffd1;flex-direction:column;min-width:0;display:flex}.chatemi__brand{justify-content:space-between;align-items:center;gap:12px;padding:20px;display:flex}.chatemi__brand div{gap:4px;display:grid}.chatemi__brand span,.chatemi__conversation small,.chatemi__header span,.chatemi__message footer{color:var(--chatemi-muted);font-size:.82rem}.chatemi__status{background:var(--chatemi-muted);border-radius:999px;width:10px;height:10px}.chatemi__status--connected{background:#24b47e}.chatemi__status--connecting,.chatemi__status--reconnecting{background:#f2a900}.chatemi__status--error{background:var(--chatemi-danger)}.chatemi__search{padding:0 16px 14px}.chatemi__search input{background:#eef4fb;border:1px solid #0000;border-radius:999px;outline:none;width:100%;padding:12px 16px;transition:background .2s,border-color .2s}.chatemi__search input:focus{border-color:var(--chatemi-primary);background:#fff}.chatemi__conversation-list{gap:4px;padding:0 10px 12px;display:grid;overflow:auto}.chatemi__conversation{color:inherit;cursor:pointer;text-align:left;background:0 0;border:0;border-radius:18px;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:12px;padding:12px;display:grid}.chatemi__conversation:hover,.chatemi__conversation--active{background:#eaf4ff}.chatemi__conversation-body{gap:4px;min-width:0;display:grid}.chatemi__conversation-body strong,.chatemi__conversation-body small{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.chatemi__badge{background:var(--chatemi-primary);color:#fff;text-align:center;border-radius:999px;min-width:24px;padding:4px 8px;font-size:.75rem;font-weight:700}.chatemi__main{background:radial-gradient(circle at top right, #1687ff17, transparent 32%), var(--chatemi-panel);grid-template-rows:auto minmax(0,1fr) auto auto;min-width:0;display:grid;position:relative}.chatemi__header{border-bottom:1px solid var(--chatemi-border);grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:14px;padding:16px 20px;display:grid}.chatemi__header div{gap:4px;display:grid}.chatemi__header-action,.chatemi__members button,.chatemi__message-actions button,.chatemi__replying button{border:1px solid var(--chatemi-border);color:var(--chatemi-text);cursor:pointer;background:#eef4fb;border-radius:999px;padding:8px 12px;font-size:.82rem;font-weight:700}.chatemi__avatar{background:linear-gradient(135deg, var(--chatemi-primary), #7c4dff);color:#fff;object-fit:cover;border-radius:18px;flex:0 0 44px;justify-content:center;align-items:center;width:44px;height:44px;font-weight:800;display:inline-flex}.chatemi__avatar--fallback{letter-spacing:-.04em}.chatemi__messages{flex-direction:column;gap:10px;padding:24px;display:flex;overflow:auto}.chatemi__members{border-bottom:1px solid var(--chatemi-border);background:#ffffffc2;gap:12px;padding:14px 18px;display:grid}.chatemi[data-theme=dark] .chatemi__members{background:#1017229e}.chatemi__members-list{gap:8px;max-height:190px;display:grid;overflow:auto}.chatemi__member,.chatemi__user-results button{grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:10px;display:grid}.chatemi__member-avatar,.chatemi__user-results img{background:linear-gradient(135deg, var(--chatemi-primary), #7c4dff);color:#fff;object-fit:cover;border-radius:999px;justify-content:center;align-items:center;width:32px;height:32px;font-size:.75rem;font-weight:800;display:inline-flex}.chatemi__member span{display:grid}.chatemi__member small,.chatemi__member-search span{color:var(--chatemi-muted);font-size:.78rem}.chatemi__member-actions{flex-wrap:wrap;justify-content:flex-end;gap:6px;display:flex}.chatemi__member-search{gap:6px;display:grid}.chatemi__member-search input{border:1px solid var(--chatemi-border);color:var(--chatemi-text);background:#eef4fb;border-radius:14px;padding:10px 12px}.chatemi__user-results{flex-wrap:wrap;gap:8px;display:flex}.chatemi__message-shell{display:contents}.chatemi__message{background:var(--chatemi-bubble);border-radius:18px 18px 18px 6px;align-self:flex-start;max-width:min(72%,680px);padding:12px 14px 8px}.chatemi__message--mine{background:var(--chatemi-bubble-mine);color:#fff;border-radius:18px 18px 6px;align-self:flex-end}.chatemi__message p{white-space:pre-wrap;margin:4px 0 0}.chatemi__forwarded,.chatemi__reply-preview{border-left:3px solid var(--chatemi-primary);color:var(--chatemi-muted);margin:0 0 8px;padding-left:8px;font-size:.82rem}.chatemi__reply-preview{gap:2px;display:grid}.chatemi__message-sender{margin-bottom:4px;font-size:.82rem;display:block}.chatemi__message footer{justify-content:flex-end;align-items:center;gap:8px;margin-top:8px;display:flex}.chatemi__message--mine footer{color:#ffffffc7}.chatemi__message-attachments,.chatemi__reactions,.chatemi__attachments{flex-wrap:wrap;gap:8px;margin-top:10px;display:flex}.chatemi__media,.chatemi__attachment-pill{border:1px solid var(--chatemi-border);color:inherit;background:#ffffffad;border-radius:12px;align-items:center;gap:8px;padding:8px 10px;text-decoration:none;display:inline-flex}.chatemi__media--image,.chatemi__media--video,.chatemi__media--audio{align-items:stretch;max-width:320px;padding:8px;display:grid}.chatemi__media--image img,.chatemi__media--video video{object-fit:cover;border-radius:12px;width:100%;max-height:260px}.chatemi__media--audio audio{width:100%;max-width:260px}.chatemi__media--file img{object-fit:cover;border-radius:8px;width:44px;height:44px}.chatemi__media small{color:var(--chatemi-muted)}.chatemi__reactions span{color:var(--chatemi-text);background:#ffffffb3;border-radius:999px;padding:4px 8px;font-size:.82rem}.chatemi__attachments{border-top:1px solid var(--chatemi-border);margin:0;padding:10px 16px 0}.chatemi__replying{border-top:1px solid var(--chatemi-border);justify-content:space-between;align-items:center;gap:12px;padding:10px 16px 0;display:flex}.chatemi__replying span{border-left:3px solid var(--chatemi-primary);color:var(--chatemi-muted);padding-left:10px}.chatemi__attachment-pill{cursor:pointer}.chatemi__composer{grid-template-columns:auto minmax(0,1fr) auto;align-items:end;gap:10px;padding:16px;display:grid}.chatemi__composer textarea{resize:vertical;background:#eef4fb;border:1px solid #0000;border-radius:18px;outline:none;min-height:46px;max-height:160px;padding:12px 14px}.chatemi__composer textarea:focus{border-color:var(--chatemi-primary);background:#fff}.chatemi__composer button,.chatemi__upload{cursor:pointer;border-radius:14px;justify-content:center;align-items:center;min-height:46px;padding:0 16px;font-weight:700;display:inline-flex}.chatemi__composer button{background:var(--chatemi-primary);color:#fff;border:0}.chatemi__composer button:hover{background:var(--chatemi-primary-dark)}.chatemi__composer button:disabled{cursor:not-allowed;opacity:.6}.chatemi__upload:has(input:disabled){cursor:not-allowed;opacity:.6}.chatemi__message-actions{opacity:0;justify-content:flex-end;gap:6px;margin-top:8px;transition:opacity .2s;display:flex}.chatemi__message:hover .chatemi__message-actions,.chatemi__message:focus-within .chatemi__message-actions{opacity:1}.chatemi__upload{border:1px solid var(--chatemi-border);color:var(--chatemi-text);background:#eef4fb;position:relative;overflow:hidden}.chatemi__upload input{opacity:0;position:absolute;inset:0}.chatemi__empty{color:var(--chatemi-muted);text-align:center;margin:auto;padding:24px}.chatemi__empty--screen{place-self:center}.chatemi__error{color:var(--chatemi-danger);background:#fff2f2;border-top:1px solid #ffd5d5;padding:10px 16px;font-size:.9rem;position:absolute;bottom:0;left:0;right:0}.chatemi__sr-only{clip:rect(0, 0, 0, 0);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}@media (width<=760px){.chatemi-launcher{inset:auto 16px 16px}.chatemi-launcher__toggle{margin-left:auto}.chatemi-launcher__modal,.chatemi-launcher--bottom-right .chatemi-launcher__modal,.chatemi-launcher--bottom-left .chatemi-launcher__modal,.chatemi-launcher--top-right .chatemi-launcher__modal,.chatemi-launcher--top-left .chatemi-launcher__modal{resize:none;inset:auto 0 84px;width:100%!important;min-width:0!important;max-width:none!important;height:min(78vh,720px)!important;max-height:78vh!important;transform:none!important}.chatemi{border-radius:0;grid-template-columns:1fr;height:100vh}.chatemi__sidebar{display:none}.chatemi__composer{grid-template-columns:1fr auto}.chatemi__upload{grid-column:1/-1}.chatemi__message{max-width:88%}}
|
|
2
|
+
/*$vite$:1*/
|