@hibira-io/chat-ui 0.0.0-stage → 0.2.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/PROTOCOL.md +71 -0
- package/README.md +156 -2
- package/conversation.js +188 -0
- package/hibira.d.ts +107 -0
- package/hibira.js +187 -0
- package/index.d.ts +22 -0
- package/index.js +7 -0
- package/locales.js +173 -0
- package/markdown.js +320 -0
- package/package.json +95 -4
- package/react.d.ts +38 -0
- package/react.js +386 -0
- package/server.d.ts +14 -0
- package/server.js +94 -0
- package/styles.css +119 -0
- package/types.d.ts +108 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hibira
|
|
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/PROTOCOL.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Chat protocol v1
|
|
2
|
+
|
|
3
|
+
How the `@hibira-io/chat-ui` components talk to your backend and to a
|
|
4
|
+
[Hibira](https://hibira.io) channel. Any backend can implement it; the
|
|
5
|
+
`server` helpers do the channel side for Node.
|
|
6
|
+
|
|
7
|
+
## Principles
|
|
8
|
+
|
|
9
|
+
- **Your backend stores messages.** Pages read them over HTTPS from your
|
|
10
|
+
routes (the `load` and `send` functions you pass to `createConversation`).
|
|
11
|
+
- **The channel carries hints only**: small events that say "something
|
|
12
|
+
changed". A page that misses one catches up on its next poll, so a lost
|
|
13
|
+
event never loses a message, and message text never crosses the socket.
|
|
14
|
+
- One channel per conversation. Use a private channel (for example
|
|
15
|
+
`private-chat.{conversationId}`) and authorize only the people in it.
|
|
16
|
+
|
|
17
|
+
## Message
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"id": "msg_01",
|
|
22
|
+
"seq": 7,
|
|
23
|
+
"sender": "agent",
|
|
24
|
+
"name": "Support",
|
|
25
|
+
"text": "Markdown text",
|
|
26
|
+
"created_at": "2026-10-31T12:00:00Z",
|
|
27
|
+
"metadata": { "assistant": true, "suggestions": ["Is there a free plan?"] }
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
| Field | Meaning |
|
|
32
|
+
| --- | --- |
|
|
33
|
+
| `seq` | Position in the conversation, increasing by one. Pages ask for messages after the last `seq` they have. |
|
|
34
|
+
| `sender` | `customer` for the person using the chat (the page's `me`), anything else for your side (`agent`, `operator`, ...). |
|
|
35
|
+
| `text` | Markdown. Pages render it without HTML (see the README). |
|
|
36
|
+
| `metadata.assistant` | `true` on a reply your agent wrote: shown as an automatic reply. |
|
|
37
|
+
| `metadata.handoff` | `true` when the agent passed the conversation to a person. |
|
|
38
|
+
| `metadata.suggestions` | Up to 3 follow-up questions; the customer sends one with a tap. Clean them with `cleanSuggestions()`. |
|
|
39
|
+
|
|
40
|
+
## Routes (yours)
|
|
41
|
+
|
|
42
|
+
- `load(after)`: return `{ messages: Message[] }` with the messages whose
|
|
43
|
+
`seq > after`, oldest first. Anything else you return (status, read
|
|
44
|
+
marks) shows up as `state.extra`.
|
|
45
|
+
- `send(text)`: store the customer's message and return it (or
|
|
46
|
+
`{ message }`). Then send the `message` hint.
|
|
47
|
+
|
|
48
|
+
## Events on the conversation's channel
|
|
49
|
+
|
|
50
|
+
| Event | Data | Sent when |
|
|
51
|
+
| --- | --- | --- |
|
|
52
|
+
| `message` | `{ "seq": 7, "from": "agent" }` | A message was stored. Pages read what they have not seen. |
|
|
53
|
+
| `typing` | `{ "from": "agent", "step": "writing" }` | Someone is writing. Repeat every ~4 s while it lasts; pages hide it after 8 s without one, or when that side's message arrives. |
|
|
54
|
+
| anything else | your data | Passed to `onEvent` (status changes, read marks, ...). |
|
|
55
|
+
|
|
56
|
+
### Agent steps
|
|
57
|
+
|
|
58
|
+
While your agent writes a reply, send `typing` with a `step` each time it
|
|
59
|
+
moves on. Pages show the steps so far with a check and the current one with
|
|
60
|
+
a spinner. Send only steps your agent really takes:
|
|
61
|
+
|
|
62
|
+
| Step | Shown as (en) |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `reading` | Reading your question |
|
|
65
|
+
| `account` | Looking at your account |
|
|
66
|
+
| `searching` | Searching our docs |
|
|
67
|
+
| `writing` | Writing a reply |
|
|
68
|
+
| `checking` | Checking the reply |
|
|
69
|
+
|
|
70
|
+
Other step names show as plain typing unless you add labels for them
|
|
71
|
+
(`labels.steps`).
|
package/README.md
CHANGED
|
@@ -1,3 +1,157 @@
|
|
|
1
|
-
#
|
|
1
|
+
# chat-ui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A small, open-source chat interface for support conversations where an AI agent answers first and a person steps in when needed. It shows what the agent is doing while it writes, renders safe Markdown, offers follow-up questions, and marks when a conversation is handed to your team.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
| Agent steps (zh-TW, mobile) | Handed to a person (ja, dark) |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
|  |  |
|
|
10
|
+
|
|
11
|
+
- **Agent steps**: "Reading your question", "Looking at your account", "Writing a reply"… shown live, from hints your backend sends.
|
|
12
|
+
- **Safe Markdown**: parsed into elements, never HTML. Only http(s), mailto and same-site links; images show inline only from origins you allow.
|
|
13
|
+
- **Follow-up suggestions**: up to three short questions the customer can send with one tap.
|
|
14
|
+
- **Handoff**: a reply can say it was passed to a person, and the badge shows it.
|
|
15
|
+
- **Typewriter reveal**, copy buttons on replies and code blocks, a "Seen" marker.
|
|
16
|
+
- **Seven languages** built in: en, zh-TW, zh-CN, ja, ko, th, vi.
|
|
17
|
+
- **Small**: about 9 KB of JavaScript and 2 KB of CSS gzipped, no runtime dependencies. React is an optional peer.
|
|
18
|
+
|
|
19
|
+
It works with any backend. Messages live in your database and are read over HTTPS; a realtime channel on [Hibira](https://hibira.io) (or any Pusher-compatible service) only carries hints, so a missed event costs a poll, never a message. See [PROTOCOL.md](PROTOCOL.md).
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm install @hibira-io/chat-ui pusher-js
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## React
|
|
28
|
+
|
|
29
|
+
```jsx
|
|
30
|
+
import { useEffect } from 'react';
|
|
31
|
+
import Pusher from 'pusher-js';
|
|
32
|
+
import { Chat, createConversation } from '@hibira-io/chat-ui/react';
|
|
33
|
+
import '@hibira-io/chat-ui/styles.css';
|
|
34
|
+
|
|
35
|
+
const pusher = new Pusher('YOUR_APP_KEY', { wsHost: 'ws.hibira.io', forceTLS: true, cluster: '' });
|
|
36
|
+
|
|
37
|
+
const conversation = createConversation({
|
|
38
|
+
pusher,
|
|
39
|
+
channel: `private-conversation-${conversationId}`,
|
|
40
|
+
load: (after, signal) => fetch(`/api/conversations/${conversationId}/messages?after=${after}`, { signal }).then((r) => r.json()),
|
|
41
|
+
send: (text) =>
|
|
42
|
+
fetch(`/api/conversations/${conversationId}/messages`, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'Content-Type': 'application/json' },
|
|
45
|
+
body: JSON.stringify({ text }),
|
|
46
|
+
}).then((r) => r.json()),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export function SupportChat() {
|
|
50
|
+
useEffect(() => conversation.start(), []);
|
|
51
|
+
return <Chat conversation={conversation} locale="ja" />;
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`load(after)` returns `{ messages: [...] }` with every message whose `seq` is greater than `after`. A message looks like:
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
{
|
|
59
|
+
seq: 12,
|
|
60
|
+
sender: 'agent', // or 'customer'; `me` (default "customer") is the person on this page
|
|
61
|
+
text: 'Here is how to **rotate** a key…',
|
|
62
|
+
created_at: '2026-10-31T09:00:00Z',
|
|
63
|
+
metadata: { assistant: true, suggestions: ['How do I revoke the old key?'] },
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The pieces are exported on their own too: `ChatThread` (controlled: pass `messages`, `typing`, `step`, `onSend`), `Markdown`, `AgentSteps`, `Suggestions`, `CopyButton` and `useReveal`.
|
|
68
|
+
|
|
69
|
+
## Without a framework
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
import { createConversation, parseMarkdown } from '@hibira-io/chat-ui';
|
|
73
|
+
|
|
74
|
+
const conversation = createConversation({ pusher, channel, load, send });
|
|
75
|
+
conversation.subscribe((state) => render(state)); // { messages, loading, error, typing, step }
|
|
76
|
+
conversation.start();
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`parseMarkdown(text, { origin, imageOrigins })` returns a plain block tree you can render with anything.
|
|
80
|
+
|
|
81
|
+
## Hibira Chat API
|
|
82
|
+
|
|
83
|
+
With the [Hibira Chat API](https://hibira.io) Hibira stores the messages, so
|
|
84
|
+
you write no `load` or `send` routes. Your server creates conversations and
|
|
85
|
+
gives each signed-in user a **user token** (an HS256 JWT signed with the app
|
|
86
|
+
secret, `{ sub, exp }`); the page talks to Hibira directly.
|
|
87
|
+
|
|
88
|
+
```jsx
|
|
89
|
+
import Pusher from 'pusher-js';
|
|
90
|
+
import { createChatClient } from '@hibira-io/chat-ui/hibira';
|
|
91
|
+
import { Chat, useChatConversation } from '@hibira-io/chat-ui/react';
|
|
92
|
+
|
|
93
|
+
const client = createChatClient({ appId: 'your-app-id', tokenUrl: '/hibira/token' });
|
|
94
|
+
const pusher = new Pusher('your-key', { wsHost: 'ws.hibira.io', userAuthentication: { endpoint: '/pusher/user-auth' } });
|
|
95
|
+
pusher.signin(); // Chat API events arrive on the user's own channel
|
|
96
|
+
|
|
97
|
+
function Trip({ userId }) {
|
|
98
|
+
const conversation = useChatConversation(client, 'trip-1', { pusher, me: userId });
|
|
99
|
+
return <Chat conversation={conversation} me={userId} locale="zh-TW" />;
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The client does the rest of the API as the token's user: `conversations()`
|
|
104
|
+
(with `unread`), `messages()`, `search(id, q)`, `send(id, { text, attachments })`,
|
|
105
|
+
`edit()`, `remove()`, `react(id, seq, emoji, on)`, `read()`, `typing()`,
|
|
106
|
+
`report()` and `upload(id, file)` (presigned, up to 25 MB). Edits, deletes and
|
|
107
|
+
reactions from other members update the thread live through `pusher.user`.
|
|
108
|
+
|
|
109
|
+
## Server (Node)
|
|
110
|
+
|
|
111
|
+
```js
|
|
112
|
+
import Pusher from 'pusher';
|
|
113
|
+
import { createChatServer, agentMetadata } from '@hibira-io/chat-ui/server';
|
|
114
|
+
|
|
115
|
+
const pusher = new Pusher({ appId, key, secret, host: 'api.hibira.io', useTLS: true });
|
|
116
|
+
const chat = createChatServer({ pusher });
|
|
117
|
+
|
|
118
|
+
const typing = chat.agentTyping(channel); // "Reading your question", repeated every 4 s
|
|
119
|
+
typing.step('account'); // "Looking at your account"
|
|
120
|
+
const answer = await yourAgent(question);
|
|
121
|
+
typing.step('writing');
|
|
122
|
+
typing.step('checking');
|
|
123
|
+
const msg = await db.saveMessage({
|
|
124
|
+
sender: 'agent',
|
|
125
|
+
text: answer.text,
|
|
126
|
+
metadata: agentMetadata({ handoff: answer.handoff, suggestions: answer.suggestions }),
|
|
127
|
+
});
|
|
128
|
+
typing.stop();
|
|
129
|
+
await chat.message(channel, { seq: msg.seq, from: 'agent' });
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`agentMetadata` keeps at most three short suggestions and drops any with links, line breaks or markup, since the customer sends them as their own words. Only send the steps your agent really goes through.
|
|
133
|
+
|
|
134
|
+
## Styling
|
|
135
|
+
|
|
136
|
+
Every color is a CSS variable on `.hb-chat`:
|
|
137
|
+
|
|
138
|
+
```css
|
|
139
|
+
.hb-chat {
|
|
140
|
+
--hb-chat-accent: #3e63dd;
|
|
141
|
+
--hb-chat-radius: 10px;
|
|
142
|
+
--hb-chat-max-height: 480px;
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Dark mode follows `prefers-color-scheme`, or set `data-theme="dark"` / `"light"` on any ancestor. Labels can be overridden one by one with `labels={{ handoff: 'With our support team' }}`.
|
|
147
|
+
|
|
148
|
+
## Safety
|
|
149
|
+
|
|
150
|
+
- Message text is never inserted as HTML.
|
|
151
|
+
- Links open only for `http:`, `https:`, `mailto:` and same-site paths, with `rel="noopener noreferrer"`.
|
|
152
|
+
- Images render inline only from `origin` and `imageOrigins`; any other image becomes a plain link, so a message cannot make the page load third-party content.
|
|
153
|
+
- Treat replies from your agent as untrusted text, like any customer message.
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
MIT © Hibira
|
package/conversation.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// A conversation kept up to date in the browser, without a UI framework.
|
|
2
|
+
//
|
|
3
|
+
// Messages are read from your backend (`load`), never from the socket:
|
|
4
|
+
// events on the conversation's channel are hints (PROTOCOL.md). A `message`
|
|
5
|
+
// hint makes it read what it has not seen; a `typing` hint shows that the
|
|
6
|
+
// other side (a person or your agent) is writing, with the agent's `step`.
|
|
7
|
+
// A missed event costs a poll, never a message: it polls slowly while the
|
|
8
|
+
// socket is up and every few seconds without one.
|
|
9
|
+
|
|
10
|
+
/** What an agent can be doing while it writes a reply, in order (PROTOCOL.md). */
|
|
11
|
+
export const STEPS = Object.freeze(['reading', 'account', 'searching', 'writing', 'checking']);
|
|
12
|
+
|
|
13
|
+
/** Polling intervals, with the socket connected (a safety net) and without. */
|
|
14
|
+
export const POLL_LIVE_MS = 60_000;
|
|
15
|
+
export const POLL_MS = 10_000;
|
|
16
|
+
/** How long a typing hint shows without another one. */
|
|
17
|
+
export const TYPING_SHOW_MS = 8_000;
|
|
18
|
+
|
|
19
|
+
/** Merges a page of messages into a list ordered by seq, without repeats. */
|
|
20
|
+
export function mergeMessages(list, page) {
|
|
21
|
+
if (!page?.length) return list;
|
|
22
|
+
const bySeq = new Map(list.map((m) => [m.seq, m]));
|
|
23
|
+
for (const m of page) bySeq.set(m.seq, m);
|
|
24
|
+
return [...bySeq.values()].sort((a, b) => a.seq - b.seq);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const lastSeq = (list) => (list.length ? list[list.length - 1].seq : 0);
|
|
28
|
+
|
|
29
|
+
/** An automatic reply (your agent marks it in metadata). */
|
|
30
|
+
export const isAutomatic = (m) => !!m?.metadata?.assistant;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {{
|
|
34
|
+
* load: (after: number, signal: AbortSignal) => Promise<{ messages: any[] } & Record<string, any>>,
|
|
35
|
+
* send?: (text: string) => Promise<any>,
|
|
36
|
+
* pusher?: any,
|
|
37
|
+
* channel?: string,
|
|
38
|
+
* me?: string,
|
|
39
|
+
* pollMs?: number,
|
|
40
|
+
* livePollMs?: number,
|
|
41
|
+
* onEvent?: (event: string, data: any) => void,
|
|
42
|
+
* }} options
|
|
43
|
+
*/
|
|
44
|
+
export function createConversation(options) {
|
|
45
|
+
if (typeof options?.load !== 'function') throw new TypeError('createConversation() needs a load(after, signal) function.');
|
|
46
|
+
const me = options.me ?? 'customer';
|
|
47
|
+
const pollMs = options.pollMs ?? POLL_MS;
|
|
48
|
+
const livePollMs = options.livePollMs ?? POLL_LIVE_MS;
|
|
49
|
+
let state = { messages: [], loading: true, error: null, typing: false, step: undefined, extra: {} };
|
|
50
|
+
const listeners = new Set();
|
|
51
|
+
let timer = null;
|
|
52
|
+
let typingTimer = null;
|
|
53
|
+
let abort = null;
|
|
54
|
+
let inflight = null;
|
|
55
|
+
let again = false;
|
|
56
|
+
let stopped = true;
|
|
57
|
+
let unbind = [];
|
|
58
|
+
|
|
59
|
+
const set = (patch) => {
|
|
60
|
+
state = { ...state, ...patch };
|
|
61
|
+
listeners.forEach((fn) => fn(state));
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const connected = () => options.pusher?.connection?.state === 'connected';
|
|
65
|
+
|
|
66
|
+
function schedule() {
|
|
67
|
+
if (stopped) return;
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
timer = setTimeout(() => void refresh(), connected() ? livePollMs : pollMs);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function setTyping(on, step) {
|
|
73
|
+
clearTimeout(typingTimer);
|
|
74
|
+
typingTimer = on ? setTimeout(() => setTyping(false), TYPING_SHOW_MS) : null;
|
|
75
|
+
const next = on ? step : undefined;
|
|
76
|
+
if (state.typing !== on || state.step !== next) set({ typing: on, step: next });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function onHint(event, data) {
|
|
80
|
+
const d = data && typeof data === 'object' ? data : {};
|
|
81
|
+
if (event === 'typing') {
|
|
82
|
+
if (d.from !== me) setTyping(true, typeof d.step === 'string' ? d.step : undefined);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (event === 'message') {
|
|
86
|
+
if (d.from !== me) setTyping(false);
|
|
87
|
+
if (typeof d.seq === 'number' && d.seq <= lastSeq(state.messages)) return;
|
|
88
|
+
void refresh();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
options.onEvent?.(event, d);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Reads what is new; calls made while one runs fold into one more. */
|
|
95
|
+
function refresh() {
|
|
96
|
+
if (stopped) return Promise.resolve();
|
|
97
|
+
if (inflight) {
|
|
98
|
+
again = true;
|
|
99
|
+
return inflight;
|
|
100
|
+
}
|
|
101
|
+
const run = async () => {
|
|
102
|
+
do {
|
|
103
|
+
again = false;
|
|
104
|
+
abort = new AbortController();
|
|
105
|
+
try {
|
|
106
|
+
const { messages = [], ...extra } = await options.load(lastSeq(state.messages), abort.signal);
|
|
107
|
+
if (stopped) return;
|
|
108
|
+
set({ messages: mergeMessages(state.messages, messages), extra: { ...state.extra, ...extra }, error: null, loading: false });
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (stopped || err?.name === 'AbortError') return;
|
|
111
|
+
set({ error: err, loading: false });
|
|
112
|
+
}
|
|
113
|
+
} while (again && !stopped);
|
|
114
|
+
};
|
|
115
|
+
inflight = run().finally(() => {
|
|
116
|
+
inflight = null;
|
|
117
|
+
schedule();
|
|
118
|
+
});
|
|
119
|
+
return inflight;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function start() {
|
|
123
|
+
if (!stopped) return stop;
|
|
124
|
+
stopped = false;
|
|
125
|
+
const { pusher, channel } = options;
|
|
126
|
+
if (pusher && channel) {
|
|
127
|
+
const ch = pusher.subscribe(channel);
|
|
128
|
+
const global = (event, data) => {
|
|
129
|
+
if (event.startsWith('pusher:') || event.startsWith('pusher_internal:')) return;
|
|
130
|
+
onHint(event, data);
|
|
131
|
+
};
|
|
132
|
+
const subscribed = () => void refresh();
|
|
133
|
+
ch.bind_global(global);
|
|
134
|
+
ch.bind('pusher:subscription_succeeded', subscribed);
|
|
135
|
+
const state = () => schedule();
|
|
136
|
+
pusher.connection?.bind?.('state_change', state);
|
|
137
|
+
unbind.push(() => {
|
|
138
|
+
ch.unbind_global(global);
|
|
139
|
+
ch.unbind('pusher:subscription_succeeded', subscribed);
|
|
140
|
+
pusher.connection?.unbind?.('state_change', state);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (typeof document !== 'undefined') {
|
|
144
|
+
const vis = () => document.visibilityState === 'visible' && void refresh();
|
|
145
|
+
document.addEventListener('visibilitychange', vis);
|
|
146
|
+
unbind.push(() => document.removeEventListener('visibilitychange', vis));
|
|
147
|
+
}
|
|
148
|
+
void refresh();
|
|
149
|
+
return stop;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function stop() {
|
|
153
|
+
stopped = true;
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
clearTimeout(typingTimer);
|
|
156
|
+
abort?.abort();
|
|
157
|
+
unbind.forEach((fn) => fn());
|
|
158
|
+
unbind = [];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Adds a message the page just sent (the server's copy). */
|
|
162
|
+
function added(message) {
|
|
163
|
+
if (message && typeof message.seq === 'number') set({ messages: mergeMessages(state.messages, [message]) });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Sends through options.send and adds the message it returns. */
|
|
167
|
+
async function send(text) {
|
|
168
|
+
if (!options.send) throw new Error('createConversation() has no send(text) function.');
|
|
169
|
+
const out = await options.send(text);
|
|
170
|
+
added(out?.message ?? out);
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
get: () => state,
|
|
176
|
+
subscribe(fn) {
|
|
177
|
+
listeners.add(fn);
|
|
178
|
+
return () => listeners.delete(fn);
|
|
179
|
+
},
|
|
180
|
+
start,
|
|
181
|
+
stop,
|
|
182
|
+
refresh,
|
|
183
|
+
added,
|
|
184
|
+
send,
|
|
185
|
+
/** For tests and custom transports: deliver a hint as if from the channel. */
|
|
186
|
+
hint: onHint,
|
|
187
|
+
};
|
|
188
|
+
}
|
package/hibira.d.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { ChatMessage, Conversation } from './types.js';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_HOST: string;
|
|
4
|
+
export const CHAT_EVENTS: readonly string[];
|
|
5
|
+
|
|
6
|
+
export interface ChatClientOptions {
|
|
7
|
+
/** The Hibira app ID. */
|
|
8
|
+
appId: string;
|
|
9
|
+
/** The user token, or a function returning one; called again before it expires and after a 401. */
|
|
10
|
+
token?: string | (() => string | Promise<string>);
|
|
11
|
+
/** Instead of token: a URL on your site that answers { token }. */
|
|
12
|
+
tokenUrl?: string;
|
|
13
|
+
/** The HTTP API host (default api.hibira.io); may include http://. */
|
|
14
|
+
host?: string;
|
|
15
|
+
fetch?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Attachment {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
content_type: string;
|
|
22
|
+
size: number;
|
|
23
|
+
/** A download link, valid for an hour. */
|
|
24
|
+
url?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A message of the Hibira Chat API. */
|
|
28
|
+
export interface ChatApiMessage extends ChatMessage {
|
|
29
|
+
id: string;
|
|
30
|
+
kind?: string;
|
|
31
|
+
created_at: string;
|
|
32
|
+
edited_at?: string;
|
|
33
|
+
deleted_at?: string;
|
|
34
|
+
/** Each emoji and the members who reacted, in order. */
|
|
35
|
+
reactions?: Record<string, string[]>;
|
|
36
|
+
attachments?: Attachment[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ChatApiConversation {
|
|
40
|
+
id: string;
|
|
41
|
+
name?: string;
|
|
42
|
+
metadata?: Record<string, unknown>;
|
|
43
|
+
members: { user_id: string; read_seq: number }[];
|
|
44
|
+
last_seq: number;
|
|
45
|
+
created_at: string;
|
|
46
|
+
updated_at: string;
|
|
47
|
+
/** In conversations(): last_seq minus the user's read_seq. */
|
|
48
|
+
unread?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SendInput {
|
|
52
|
+
text?: string;
|
|
53
|
+
name?: string;
|
|
54
|
+
metadata?: Record<string, unknown>;
|
|
55
|
+
attachments?: Attachment[] | { id: string }[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface Report {
|
|
59
|
+
id: string;
|
|
60
|
+
conversation_id: string;
|
|
61
|
+
seq: number;
|
|
62
|
+
reported_by: string;
|
|
63
|
+
reason: string;
|
|
64
|
+
message: ChatApiMessage;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type Opts = { signal?: AbortSignal };
|
|
68
|
+
|
|
69
|
+
export interface ChatClient {
|
|
70
|
+
/** The token's user (sub), once a token is known. */
|
|
71
|
+
user(): string;
|
|
72
|
+
conversations(options?: { limit?: number; before?: string } & Opts): Promise<{ conversations: ChatApiConversation[]; next: string }>;
|
|
73
|
+
conversation(id: string, options?: Opts): Promise<ChatApiConversation>;
|
|
74
|
+
messages(id: string, options?: { after?: number; last?: number; limit?: number } & Opts): Promise<{ messages: ChatApiMessage[]; last_seq: number }>;
|
|
75
|
+
search(id: string, q: string, options?: { before?: number | string; limit?: number } & Opts): Promise<{ messages: ChatApiMessage[]; next: string }>;
|
|
76
|
+
send(id: string, message: string | SendInput): Promise<{ message: ChatApiMessage; push?: { users: number; error?: string } }>;
|
|
77
|
+
edit(id: string, seq: number, change: string | { text?: string; metadata?: Record<string, unknown> }): Promise<ChatApiMessage>;
|
|
78
|
+
remove(id: string, seq: number): Promise<ChatApiMessage>;
|
|
79
|
+
react(id: string, seq: number, emoji: string, on?: boolean): Promise<ChatApiMessage>;
|
|
80
|
+
report(id: string, seq: number, reason?: string): Promise<Report>;
|
|
81
|
+
read(id: string, seq: number): Promise<{ read_seq: number }>;
|
|
82
|
+
typing(id: string, typing?: boolean): Promise<unknown>;
|
|
83
|
+
/** Uploads a File or Blob (up to 25 MB); send the result in a message's attachments. */
|
|
84
|
+
upload(id: string, file: Blob & { name?: string }, options?: { name?: string }): Promise<Attachment>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class ChatApiError extends Error {
|
|
88
|
+
status: number;
|
|
89
|
+
constructor(status: number, message?: string);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createChatClient(options: ChatClientOptions): ChatClient;
|
|
93
|
+
export function tokenUser(token: string): string;
|
|
94
|
+
export function onChatEvents(pusher: unknown, handler: (event: string, data: any) => void): () => void;
|
|
95
|
+
|
|
96
|
+
export interface ChatConversationOptions {
|
|
97
|
+
/** A pusher-js instance signed in as the user (pusher.signin()). Without one it polls. */
|
|
98
|
+
pusher?: unknown;
|
|
99
|
+
/** The user ID (default: the token's sub). */
|
|
100
|
+
me?: string;
|
|
101
|
+
pollMs?: number;
|
|
102
|
+
livePollMs?: number;
|
|
103
|
+
/** chat.read, chat.conversation, chat.left and chat.deleted for this conversation. */
|
|
104
|
+
onEvent?: (event: string, data: any) => void;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function chatConversation(client: ChatClient, id: string, options?: ChatConversationOptions): Conversation;
|