@bloonio/chat-angular 0.1.0 → 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/CHANGELOG.md +54 -0
- package/README.md +120 -6
- package/fesm2022/bloonio-chat-angular.mjs +557 -168
- package/fesm2022/bloonio-chat-angular.mjs.map +1 -1
- package/package.json +5 -7
- package/src/lib/styles/chat.css +16 -3
- package/types/bloonio-chat-angular.d.ts +330 -0
- package/esm2022/bloonio-chat-angular.mjs +0 -5
- package/esm2022/index.mjs +0 -25
- package/esm2022/lib/bloonio-chat.service.mjs +0 -311
- package/esm2022/lib/bloonio-chat.token.mjs +0 -46
- package/esm2022/lib/components/input-bar.component.mjs +0 -91
- package/esm2022/lib/components/launcher.component.mjs +0 -81
- package/esm2022/lib/components/message-list.component.mjs +0 -58
- package/esm2022/lib/components/panel.component.mjs +0 -211
- package/esm2022/lib/components/quick-replies.component.mjs +0 -58
- package/esm2022/lib/components/thread.component.mjs +0 -103
- package/index.d.ts +0 -27
- package/lib/bloonio-chat.service.d.ts +0 -79
- package/lib/bloonio-chat.token.d.ts +0 -67
- package/lib/components/input-bar.component.d.ts +0 -18
- package/lib/components/launcher.component.d.ts +0 -14
- package/lib/components/message-list.component.d.ts +0 -8
- package/lib/components/panel.component.d.ts +0 -31
- package/lib/components/quick-replies.component.d.ts +0 -10
- package/lib/components/thread.component.d.ts +0 -27
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { InjectionToken, makeEnvironmentProviders, inject, PLATFORM_ID, DestroyRef, signal,
|
|
2
|
+
import { InjectionToken, makeEnvironmentProviders, inject, computed, PLATFORM_ID, DestroyRef, signal, afterNextRender, Injectable, effect, ViewChild, ChangeDetectionStrategy, Component, EventEmitter, Output, Input, Injector } from '@angular/core';
|
|
3
3
|
import { isPlatformBrowser } from '@angular/common';
|
|
4
4
|
import { isObservable, firstValueFrom } from 'rxjs';
|
|
5
|
-
import { WSClient, ChatClient } from '@bloonio/chat-client-core';
|
|
5
|
+
import { BloonioChatServerError, tenantKey, WSClient, ChatClient } from '@bloonio/chat-client-core';
|
|
6
6
|
export { BloonioChatAuthError, BloonioChatError, BloonioChatNetworkError, BloonioChatServerError } from '@bloonio/chat-client-core';
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -17,7 +17,7 @@ export { BloonioChatAuthError, BloonioChatError, BloonioChatNetworkError, Blooni
|
|
|
17
17
|
* provideBloonioChat({
|
|
18
18
|
* tenantId: env.CHAT_TENANT_ID,
|
|
19
19
|
* publicKey: env.CHAT_PUBLIC_KEY,
|
|
20
|
-
* baseUrl: 'https://chat-relay.
|
|
20
|
+
* baseUrl: 'https://chat-relay.example.com',
|
|
21
21
|
* visitorTokenProvider: () => inject(HttpClient).get<{token: string}>(
|
|
22
22
|
* '/api/chat/visitor-token'
|
|
23
23
|
* ).pipe(map(r => r.token)),
|
|
@@ -26,6 +26,10 @@ export { BloonioChatAuthError, BloonioChatError, BloonioChatNetworkError, Blooni
|
|
|
26
26
|
* ],
|
|
27
27
|
* };
|
|
28
28
|
*/
|
|
29
|
+
/** Reads a config entry that is either a value or a function. */
|
|
30
|
+
function readOption(option) {
|
|
31
|
+
return typeof option === 'function' ? option() : option;
|
|
32
|
+
}
|
|
29
33
|
const BLOONIO_CHAT_CONFIG = new InjectionToken('BLOONIO_CHAT_CONFIG');
|
|
30
34
|
/** Standalone-provider factory.
|
|
31
35
|
*
|
|
@@ -34,9 +38,15 @@ const BLOONIO_CHAT_CONFIG = new InjectionToken('BLOONIO_CHAT_CONFIG');
|
|
|
34
38
|
* ``providedIn: 'root'`` so we don't need an explicit provider entry — we
|
|
35
39
|
* only need the config token to be registered.
|
|
36
40
|
*/
|
|
37
|
-
function provideBloonioChat(
|
|
41
|
+
function provideBloonioChat(
|
|
42
|
+
/** The config, or a factory run in an injection context (so it can
|
|
43
|
+
* `inject()` the host's language signal, for instance). */
|
|
44
|
+
config) {
|
|
38
45
|
return makeEnvironmentProviders([
|
|
39
|
-
{
|
|
46
|
+
{
|
|
47
|
+
provide: BLOONIO_CHAT_CONFIG,
|
|
48
|
+
useFactory: () => (typeof config === 'function' ? config() : config),
|
|
49
|
+
},
|
|
40
50
|
]);
|
|
41
51
|
}
|
|
42
52
|
/** Internal accessor used by the components to fail loud when the
|
|
@@ -50,6 +60,86 @@ function injectBloonioChatConfig() {
|
|
|
50
60
|
return cfg;
|
|
51
61
|
}
|
|
52
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Built-in strings for everything the widget renders itself. Text that comes
|
|
65
|
+
* from the relay (status texts, offline message, quick replies, messages) is
|
|
66
|
+
* shown as received.
|
|
67
|
+
*/
|
|
68
|
+
/** "4h" up to a day, "3d" beyond. */
|
|
69
|
+
const sla = (hours, hour, day) => hours <= 24 ? `${hours}${hour}` : `${Math.round(hours / 24)}${day}`;
|
|
70
|
+
const STRINGS = {
|
|
71
|
+
en: {
|
|
72
|
+
placeholder: 'Enter your message...',
|
|
73
|
+
send: 'Send',
|
|
74
|
+
close: 'Close',
|
|
75
|
+
openChat: 'Open chat',
|
|
76
|
+
closeChat: 'Close chat',
|
|
77
|
+
unread: (n) => `Open chat (${n} unread)`,
|
|
78
|
+
typing: 'Assistant is typing',
|
|
79
|
+
suggestedReplies: 'Suggested replies',
|
|
80
|
+
chatWith: (name) => `Chat with ${name}`,
|
|
81
|
+
support: 'Support',
|
|
82
|
+
poweredBy: 'Powered by',
|
|
83
|
+
connecting: 'Connecting…',
|
|
84
|
+
unavailable: 'Chat is unavailable right now. Please try again later.',
|
|
85
|
+
error: (message) => `Error: ${message}`,
|
|
86
|
+
agentJoined: (name) => `${name} has joined the chat`,
|
|
87
|
+
agentLeft: 'The agent has left the chat',
|
|
88
|
+
ticketCreated: (n, category, priority, hours) => `Support ticket #${n} created (${category}, ${priority}). ` +
|
|
89
|
+
`We'll follow up within ${sla(hours, 'h', 'd')}.`,
|
|
90
|
+
},
|
|
91
|
+
fr: {
|
|
92
|
+
placeholder: 'Votre message',
|
|
93
|
+
send: 'Envoyer',
|
|
94
|
+
close: 'Fermer',
|
|
95
|
+
openChat: 'Ouvrir la conversation',
|
|
96
|
+
closeChat: 'Fermer la conversation',
|
|
97
|
+
unread: (n) => `Ouvrir la conversation (${n} non lu${n > 1 ? 's' : ''})`,
|
|
98
|
+
typing: 'Réponse en cours de rédaction',
|
|
99
|
+
suggestedReplies: 'Réponses suggérées',
|
|
100
|
+
chatWith: (name) => `Discuter avec ${name}`,
|
|
101
|
+
support: 'Assistance',
|
|
102
|
+
poweredBy: 'Propulsé par',
|
|
103
|
+
connecting: 'Connexion…',
|
|
104
|
+
unavailable: 'La conversation est indisponible pour le moment. Réessayez plus tard.',
|
|
105
|
+
error: (message) => `Erreur : ${message}`,
|
|
106
|
+
agentJoined: (name) => `${name} a rejoint la conversation`,
|
|
107
|
+
agentLeft: 'Le conseiller a quitté la conversation',
|
|
108
|
+
ticketCreated: (n, category, priority, hours) => `Demande d'assistance n° ${n} créée (${category}, ${priority}). ` +
|
|
109
|
+
`Nous revenons vers vous sous ${sla(hours, ' h', ' j')}.`,
|
|
110
|
+
},
|
|
111
|
+
zh: {
|
|
112
|
+
placeholder: '输入消息',
|
|
113
|
+
send: '发送',
|
|
114
|
+
close: '关闭',
|
|
115
|
+
openChat: '打开聊天',
|
|
116
|
+
closeChat: '关闭聊天',
|
|
117
|
+
unread: (n) => `打开聊天(${n} 条未读)`,
|
|
118
|
+
typing: '对方正在输入',
|
|
119
|
+
suggestedReplies: '快捷回复',
|
|
120
|
+
chatWith: (name) => `与 ${name} 聊天`,
|
|
121
|
+
support: '客服',
|
|
122
|
+
poweredBy: '技术支持',
|
|
123
|
+
connecting: '正在连接…',
|
|
124
|
+
unavailable: '聊天暂时无法使用,请稍后再试。',
|
|
125
|
+
error: (message) => `错误:${message}`,
|
|
126
|
+
agentJoined: (name) => `${name} 已加入对话`,
|
|
127
|
+
agentLeft: '客服已离开对话',
|
|
128
|
+
ticketCreated: (n, category, priority, hours) => `工单 #${n} 已创建(${category},${priority})。` +
|
|
129
|
+
`我们将在 ${sla(hours, ' 小时', ' 天')}内回复您。`,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
/** The strings of the configured locale, re-read whenever a signal behind
|
|
133
|
+
* it changes. Anything other than fr / en / zh (after trimming a region:
|
|
134
|
+
* "fr-FR") is "en". */
|
|
135
|
+
function injectChatStrings() {
|
|
136
|
+
const { locale } = injectBloonioChatConfig();
|
|
137
|
+
return computed(() => {
|
|
138
|
+
const code = String(readOption(locale) ?? '').slice(0, 2).toLowerCase();
|
|
139
|
+
return STRINGS[code in STRINGS ? code : 'en'];
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
53
143
|
/**
|
|
54
144
|
* BloonioChatService — signals-based reactive state container.
|
|
55
145
|
*
|
|
@@ -69,49 +159,128 @@ function injectBloonioChatConfig() {
|
|
|
69
159
|
* async toggle(): Promise<void>
|
|
70
160
|
* sendMessage(text: string): void
|
|
71
161
|
*
|
|
72
|
-
* SSR-safe: every transport call (fetch, WebSocket)
|
|
73
|
-
* platform check via `afterNextRender()` so server
|
|
74
|
-
* try to open sockets.
|
|
162
|
+
* SSR-safe: every transport call (fetch, WebSocket) and every storage access
|
|
163
|
+
* is gated behind a platform check via `afterNextRender()` so server
|
|
164
|
+
* pre-rendering doesn't try to open sockets.
|
|
165
|
+
*
|
|
166
|
+
* Resume: the visitor session is kept in `localStorage` per tenant. On the
|
|
167
|
+
* next page load the token is refreshed, the thread replayed and the socket
|
|
168
|
+
* reopened, so a visitor waiting for an agent keeps the conversation.
|
|
75
169
|
*/
|
|
170
|
+
/** `sender` of a stored relay message → the widget's `from`. */
|
|
171
|
+
const HISTORY_FROM = {
|
|
172
|
+
visitor: 'visitor',
|
|
173
|
+
operator: 'agent',
|
|
174
|
+
bot: 'bot',
|
|
175
|
+
};
|
|
176
|
+
/** Socket close codes the relay uses to refuse a visitor for good. */
|
|
177
|
+
const WS_BAD_TOKEN = 4401;
|
|
178
|
+
const WS_TENANT_UNAVAILABLE = 4403;
|
|
179
|
+
const WS_SESSION_EXPIRED = 4404;
|
|
180
|
+
/** The relay sends one typing "start" per bot answer and the "stop" can be
|
|
181
|
+
* lost, so the dots give up after this long without news. Long enough for
|
|
182
|
+
* a slow answer, which clears them itself when it arrives. */
|
|
183
|
+
const TYPING_TIMEOUT_MS = 30_000;
|
|
184
|
+
const isAnswer = (m) => m.from === 'bot' || m.from === 'agent';
|
|
76
185
|
class BloonioChatService {
|
|
77
186
|
config = injectBloonioChatConfig();
|
|
78
187
|
platformId = inject(PLATFORM_ID);
|
|
79
188
|
destroyRef = inject(DestroyRef);
|
|
189
|
+
t = injectChatStrings();
|
|
80
190
|
/** True when running in the browser (vs. server pre-render). */
|
|
81
191
|
isBrowser = isPlatformBrowser(this.platformId);
|
|
192
|
+
/** Identified visitors are not resumed: a stored thread would outlive a
|
|
193
|
+
* logout and show to the next account on the same browser.
|
|
194
|
+
* shortcut: scope the storage key by user id to resume them too. */
|
|
195
|
+
canPersist = this.isBrowser && !this.config.visitorTokenProvider;
|
|
82
196
|
/** Bootstrap config — populated by the first `loadBootstrap()` call. */
|
|
83
|
-
bootstrap = signal(null
|
|
84
|
-
|
|
85
|
-
session
|
|
197
|
+
bootstrap = signal(null, /* @ts-ignore */
|
|
198
|
+
...(ngDevMode ? [{ debugName: "bootstrap" }] : /* istanbul ignore next */ []));
|
|
199
|
+
/** Visitor session (id + JWT + expires_at) — set lazily on first open,
|
|
200
|
+
* or on load when a stored session resumes. */
|
|
201
|
+
session = signal(null, /* @ts-ignore */
|
|
202
|
+
...(ngDevMode ? [{ debugName: "session" }] : /* istanbul ignore next */ []));
|
|
86
203
|
/** Message log, oldest → newest. */
|
|
87
|
-
messages = signal([]
|
|
204
|
+
messages = signal([], /* @ts-ignore */
|
|
205
|
+
...(ngDevMode ? [{ debugName: "messages" }] : /* istanbul ignore next */ []));
|
|
88
206
|
/** True between bot's typing(start) and typing(stop) frames. */
|
|
89
|
-
botTyping = signal(false
|
|
207
|
+
botTyping = signal(false, /* @ts-ignore */
|
|
208
|
+
...(ngDevMode ? [{ debugName: "botTyping" }] : /* istanbul ignore next */ []));
|
|
90
209
|
/** Panel visibility — drives Launcher / Panel rendering. */
|
|
91
|
-
open = signal(false
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
210
|
+
open = signal(false, /* @ts-ignore */
|
|
211
|
+
...(ngDevMode ? [{ debugName: "open" }] : /* istanbul ignore next */ []));
|
|
212
|
+
/** True from the start of `ensureSession()` until the relay's welcome
|
|
213
|
+
* frame: while it is set, nothing can be sent yet. */
|
|
214
|
+
connecting = signal(false, /* @ts-ignore */
|
|
215
|
+
...(ngDevMode ? [{ debugName: "connecting" }] : /* istanbul ignore next */ []));
|
|
216
|
+
/** Set when bootstrap or the session fails; cleared by the next attempt
|
|
217
|
+
* (reopening the panel retries). Diagnostic text, not shown to visitors. */
|
|
218
|
+
fatalError = signal(null, /* @ts-ignore */
|
|
219
|
+
...(ngDevMode ? [{ debugName: "fatalError" }] : /* istanbul ignore next */ []));
|
|
220
|
+
/** True once a reload replayed an existing thread: the host's greeting
|
|
221
|
+
* opens new conversations only. */
|
|
222
|
+
replayed = signal(false, /* @ts-ignore */
|
|
223
|
+
...(ngDevMode ? [{ debugName: "replayed" }] : /* istanbul ignore next */ []));
|
|
224
|
+
/** What the panel renders: the log, under the host's greeting when the
|
|
225
|
+
* conversation is new, plus one status line when the chat is connecting,
|
|
226
|
+
* unavailable, or closed for the day with nothing said yet. The greeting
|
|
227
|
+
* and the status lines are local: they are not in `messages`, never sent
|
|
228
|
+
* and never stored. */
|
|
229
|
+
visibleMessages = computed(() => {
|
|
230
|
+
const messages = this.messages();
|
|
231
|
+
const line = (id, from, content) => ({
|
|
232
|
+
id,
|
|
233
|
+
from,
|
|
234
|
+
content,
|
|
235
|
+
});
|
|
236
|
+
if (this.fatalError() && !this.session()) {
|
|
237
|
+
return [...messages, line('sys_unavailable', 'system', this.t().unavailable)];
|
|
238
|
+
}
|
|
239
|
+
const greeting = this.replayed() ? '' : readOption(this.config.greeting);
|
|
240
|
+
const log = greeting ? [line('sys_greeting', 'bot', greeting), ...messages] : messages;
|
|
241
|
+
if (messages.length)
|
|
242
|
+
return log;
|
|
243
|
+
if (this.connecting()) {
|
|
244
|
+
return [...log, line('sys_connecting', 'system', this.t().connecting)];
|
|
245
|
+
}
|
|
246
|
+
// The host's greeting stands in for the tenant's offline message.
|
|
247
|
+
const b = this.bootstrap();
|
|
248
|
+
return b && !b.is_online && !greeting
|
|
249
|
+
? [line('sys_offline', 'system', b.offline_message)]
|
|
250
|
+
: log;
|
|
251
|
+
}, /* @ts-ignore */
|
|
252
|
+
...(ngDevMode ? [{ debugName: "visibleMessages" }] : /* istanbul ignore next */ []));
|
|
96
253
|
/** Number of bot/agent messages received while the panel was closed.
|
|
97
|
-
* Resets to 0 the moment the panel opens
|
|
98
|
-
|
|
254
|
+
* Resets to 0 the moment the panel opens; what arrived while it was open
|
|
255
|
+
* has been seen too. */
|
|
256
|
+
_seenCount = signal(0, /* @ts-ignore */
|
|
257
|
+
...(ngDevMode ? [{ debugName: "_seenCount" }] : /* istanbul ignore next */ []));
|
|
99
258
|
unreadCount = computed(() => {
|
|
100
259
|
if (this.open())
|
|
101
260
|
return 0;
|
|
102
|
-
const total = this.messages().filter(
|
|
261
|
+
const total = this.messages().filter(isAnswer).length;
|
|
103
262
|
return Math.max(0, total - this._seenCount());
|
|
104
|
-
}
|
|
263
|
+
}, /* @ts-ignore */
|
|
264
|
+
...(ngDevMode ? [{ debugName: "unreadCount" }] : /* istanbul ignore next */ []));
|
|
105
265
|
client = null;
|
|
106
266
|
ws = null;
|
|
107
267
|
bootstrapPromise = null;
|
|
108
268
|
sessionPromise = null;
|
|
269
|
+
/** Sockets the relay refused in a row (reset by a welcome frame). */
|
|
270
|
+
wsRejections = 0;
|
|
271
|
+
typingTimer;
|
|
109
272
|
constructor() {
|
|
110
273
|
// SSR safety: schedule the initial bootstrap fetch on first browser render.
|
|
111
274
|
afterNextRender(() => {
|
|
112
|
-
this.loadBootstrap()
|
|
275
|
+
void this.loadBootstrap().then((bootstrap) => {
|
|
276
|
+
// A returning visitor reconnects in the background, so an agent's
|
|
277
|
+
// reply still arrives while the panel is closed.
|
|
278
|
+
if (bootstrap && this.readStored())
|
|
279
|
+
void this.ensureSession();
|
|
280
|
+
});
|
|
113
281
|
});
|
|
114
282
|
this.destroyRef.onDestroy(() => {
|
|
283
|
+
this.setTyping(false);
|
|
115
284
|
this.ws?.close();
|
|
116
285
|
this.ws = null;
|
|
117
286
|
});
|
|
@@ -120,11 +289,10 @@ class BloonioChatService {
|
|
|
120
289
|
/** Toggle panel visibility. First open also opens the WS session. */
|
|
121
290
|
async toggle() {
|
|
122
291
|
const next = !this.open();
|
|
292
|
+
this.markSeen();
|
|
123
293
|
this.open.set(next);
|
|
124
|
-
if (next)
|
|
125
|
-
this._seenCount.set(this.messages().filter((m) => m.from === 'bot' || m.from === 'agent').length);
|
|
294
|
+
if (next)
|
|
126
295
|
await this.ensureSession();
|
|
127
|
-
}
|
|
128
296
|
}
|
|
129
297
|
/** Force the panel open. */
|
|
130
298
|
async openPanel() {
|
|
@@ -134,6 +302,7 @@ class BloonioChatService {
|
|
|
134
302
|
}
|
|
135
303
|
/** Force the panel closed. Keeps the WS open so reopening is instant. */
|
|
136
304
|
closePanel() {
|
|
305
|
+
this.markSeen();
|
|
137
306
|
this.open.set(false);
|
|
138
307
|
}
|
|
139
308
|
/** Send a visitor message. No-op if the WS isn't connected. */
|
|
@@ -155,6 +324,17 @@ class BloonioChatService {
|
|
|
155
324
|
this.ws.send({ type: 'msg', client_msg_id: clientMsgId, content: trimmed });
|
|
156
325
|
}
|
|
157
326
|
// ── Internals ──────────────────────────────────────────────────────────
|
|
327
|
+
/** Everything received so far counts as read (on open and on close). */
|
|
328
|
+
markSeen() {
|
|
329
|
+
this._seenCount.set(this.messages().filter(isAnswer).length);
|
|
330
|
+
}
|
|
331
|
+
setTyping(typing) {
|
|
332
|
+
clearTimeout(this.typingTimer);
|
|
333
|
+
this.botTyping.set(typing);
|
|
334
|
+
if (typing) {
|
|
335
|
+
this.typingTimer = setTimeout(() => this.botTyping.set(false), TYPING_TIMEOUT_MS);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
158
338
|
async loadBootstrap() {
|
|
159
339
|
if (!this.isBrowser)
|
|
160
340
|
return null;
|
|
@@ -179,8 +359,15 @@ class BloonioChatService {
|
|
|
179
359
|
return null;
|
|
180
360
|
}
|
|
181
361
|
})();
|
|
362
|
+
// A failed bootstrap is fetched again on the next attempt.
|
|
363
|
+
void this.bootstrapPromise.then((cfg) => {
|
|
364
|
+
if (!cfg)
|
|
365
|
+
this.bootstrapPromise = null;
|
|
366
|
+
});
|
|
182
367
|
return this.bootstrapPromise;
|
|
183
368
|
}
|
|
369
|
+
/** Resume the stored session, or — once the panel is open — mint a new
|
|
370
|
+
* one. A closed panel never creates a session by itself. */
|
|
184
371
|
async ensureSession() {
|
|
185
372
|
if (!this.isBrowser)
|
|
186
373
|
return null;
|
|
@@ -189,19 +376,18 @@ class BloonioChatService {
|
|
|
189
376
|
if (this.sessionPromise)
|
|
190
377
|
return this.sessionPromise;
|
|
191
378
|
this.connecting.set(true);
|
|
379
|
+
this.fatalError.set(null);
|
|
192
380
|
this.sessionPromise = (async () => {
|
|
193
381
|
try {
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
await this.loadBootstrap();
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
metadata: this.config.metadata ?? {},
|
|
204
|
-
});
|
|
382
|
+
// Bootstrap first: without it the relay is unreachable or refuses
|
|
383
|
+
// this origin, and `fatalError` already says why.
|
|
384
|
+
const bootstrap = await this.loadBootstrap();
|
|
385
|
+
if (!bootstrap)
|
|
386
|
+
return null;
|
|
387
|
+
const session = (await this.resumeSession(bootstrap)) ??
|
|
388
|
+
(this.open() ? await this.createSession() : null);
|
|
389
|
+
if (!session)
|
|
390
|
+
return null;
|
|
205
391
|
this.session.set(session);
|
|
206
392
|
this.openWebSocket(session);
|
|
207
393
|
// Optional: identify the visitor server-side if the consumer wired
|
|
@@ -215,11 +401,128 @@ class BloonioChatService {
|
|
|
215
401
|
return null;
|
|
216
402
|
}
|
|
217
403
|
finally {
|
|
218
|
-
|
|
404
|
+
// With a session, `connecting` lasts until the socket's welcome.
|
|
405
|
+
// Without one, let the next open (or load) try again.
|
|
406
|
+
if (!this.session()) {
|
|
407
|
+
this.connecting.set(false);
|
|
408
|
+
this.sessionPromise = null;
|
|
409
|
+
}
|
|
219
410
|
}
|
|
220
411
|
})();
|
|
221
412
|
return this.sessionPromise;
|
|
222
413
|
}
|
|
414
|
+
async createSession() {
|
|
415
|
+
const session = await this.getClient().createVisitorSession({
|
|
416
|
+
tenantId: this.config.tenantId,
|
|
417
|
+
widgetPublicKey: this.config.publicKey,
|
|
418
|
+
origin: this.resolvedOrigin(),
|
|
419
|
+
// The configured value as given (a function is read now). With none,
|
|
420
|
+
// "fr": what 0.1.x sent, kept so existing hosts see no change.
|
|
421
|
+
locale: readOption(this.config.locale) ?? 'fr',
|
|
422
|
+
metadata: this.config.metadata ?? {},
|
|
423
|
+
});
|
|
424
|
+
this.writeStored(session);
|
|
425
|
+
return session;
|
|
426
|
+
}
|
|
427
|
+
/** Rejoin the stored session: refresh its token (the relay accepts an
|
|
428
|
+
* expired one while the session lives), replay the thread, hand back the
|
|
429
|
+
* same session. Null when there is none or it cannot be resumed — the
|
|
430
|
+
* caller then starts a fresh one, and the visitor sees no error. */
|
|
431
|
+
async resumeSession(bootstrap) {
|
|
432
|
+
const stored = this.readStored();
|
|
433
|
+
if (!stored)
|
|
434
|
+
return null;
|
|
435
|
+
try {
|
|
436
|
+
const refreshed = await this.relay('POST', '/refresh/visitor-token', stored.visitor_token);
|
|
437
|
+
if (typeof refreshed?.visitor_token !== 'string') {
|
|
438
|
+
throw new BloonioChatServerError('malformed refresh response', 400, refreshed);
|
|
439
|
+
}
|
|
440
|
+
// shortcut: replays the newest page only (50 messages), page with
|
|
441
|
+
// `next_before` if longer threads need their full history.
|
|
442
|
+
const history = await this.relay('GET', '/fetch/visitor-messages', refreshed.visitor_token);
|
|
443
|
+
const messages = (history.messages ?? []).map((m) => ({
|
|
444
|
+
id: m.message_id,
|
|
445
|
+
from: HISTORY_FROM[m.sender] ?? 'system',
|
|
446
|
+
content: m.content,
|
|
447
|
+
}));
|
|
448
|
+
this.messages.set(messages);
|
|
449
|
+
this.replayed.set(messages.length > 0);
|
|
450
|
+
// Replayed answers are not "unread".
|
|
451
|
+
this.markSeen();
|
|
452
|
+
this.writeStored(refreshed);
|
|
453
|
+
return { ...refreshed, widget_config: bootstrap };
|
|
454
|
+
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
// The relay refused the session (4xx): forget it. A network failure
|
|
457
|
+
// or a 5xx keeps it, so the next page load can still resume.
|
|
458
|
+
if (err instanceof BloonioChatServerError && err.status < 500) {
|
|
459
|
+
this.writeStored(null);
|
|
460
|
+
}
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
/** The two visitor endpoints `ChatClient` (core 0.1.0) does not wrap. */
|
|
465
|
+
async relay(method, path, visitorToken) {
|
|
466
|
+
const res = await fetch(`${this.config.baseUrl.replace(/\/+$/, '')}/api/v1${path}`, {
|
|
467
|
+
method,
|
|
468
|
+
headers: { Authorization: `Bearer ${visitorToken}` },
|
|
469
|
+
signal: AbortSignal.timeout(10_000),
|
|
470
|
+
});
|
|
471
|
+
if (!res.ok) {
|
|
472
|
+
throw new BloonioChatServerError(`HTTP ${res.status}`, res.status, null);
|
|
473
|
+
}
|
|
474
|
+
const json = await res.json();
|
|
475
|
+
return (json?.data ?? json);
|
|
476
|
+
}
|
|
477
|
+
readStored() {
|
|
478
|
+
if (!this.canPersist)
|
|
479
|
+
return null;
|
|
480
|
+
try {
|
|
481
|
+
const stored = JSON.parse(localStorage.getItem(this.storageKey()) ?? 'null');
|
|
482
|
+
return typeof stored?.visitor_token === 'string' ? stored : null;
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
return null; // storage blocked, or the entry is not ours
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
writeStored(session) {
|
|
489
|
+
if (!this.canPersist)
|
|
490
|
+
return;
|
|
491
|
+
try {
|
|
492
|
+
if (!session)
|
|
493
|
+
return localStorage.removeItem(this.storageKey());
|
|
494
|
+
const { session_id, visitor_token, expires_at } = session;
|
|
495
|
+
localStorage.setItem(this.storageKey(), JSON.stringify({ session_id, visitor_token, expires_at }));
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
// Storage unavailable: the conversation just won't survive a reload.
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
storageKey() {
|
|
502
|
+
return tenantKey(this.config.tenantId, 'session');
|
|
503
|
+
}
|
|
504
|
+
/** The relay closed the socket for good. WSClient would retry the same
|
|
505
|
+
* dead token forever, so stop it and recover here. */
|
|
506
|
+
onSocketRejected(ws, code) {
|
|
507
|
+
ws.close();
|
|
508
|
+
if (this.ws !== ws)
|
|
509
|
+
return;
|
|
510
|
+
this.ws = null;
|
|
511
|
+
this.session.set(null);
|
|
512
|
+
this.sessionPromise = null;
|
|
513
|
+
this.setTyping(false);
|
|
514
|
+
this.wsRejections += 1;
|
|
515
|
+
if (code === WS_TENANT_UNAVAILABLE || this.wsRejections > 2) {
|
|
516
|
+
this.connecting.set(false);
|
|
517
|
+
this.fatalError.set(`Socket refused (${code})`);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
// First refusal: the token may only have expired, and resuming
|
|
521
|
+
// refreshes it. Refused again: drop the session and start fresh.
|
|
522
|
+
if (this.wsRejections > 1)
|
|
523
|
+
this.writeStored(null);
|
|
524
|
+
void this.ensureSession();
|
|
525
|
+
}
|
|
223
526
|
openWebSocket(session) {
|
|
224
527
|
if (this.ws)
|
|
225
528
|
return;
|
|
@@ -228,8 +531,25 @@ class BloonioChatService {
|
|
|
228
531
|
visitorToken: session.visitor_token,
|
|
229
532
|
heartbeatIntervalMs: 30_000,
|
|
230
533
|
}, {
|
|
231
|
-
onWelcome: () =>
|
|
534
|
+
onWelcome: () => {
|
|
535
|
+
this.wsRejections = 0;
|
|
536
|
+
this.connecting.set(false);
|
|
537
|
+
},
|
|
538
|
+
onClose: ({ code }) => {
|
|
539
|
+
if (code !== WS_BAD_TOKEN &&
|
|
540
|
+
code !== WS_SESSION_EXPIRED &&
|
|
541
|
+
code !== WS_TENANT_UNAVAILABLE) {
|
|
542
|
+
return; // a dropped connection: WSClient reconnects by itself
|
|
543
|
+
}
|
|
544
|
+
// WSClient schedules its reconnect right after this callback, so
|
|
545
|
+
// it can only be cancelled one tick later.
|
|
546
|
+
queueMicrotask(() => this.onSocketRejected(ws, code));
|
|
547
|
+
},
|
|
232
548
|
onMessage: (frame) => {
|
|
549
|
+
// The answer is here: whoever was typing has finished, even if
|
|
550
|
+
// the "stop" frame never comes.
|
|
551
|
+
if (frame.from !== 'system')
|
|
552
|
+
this.setTyping(false);
|
|
233
553
|
this.messages.update((prev) => [
|
|
234
554
|
...prev,
|
|
235
555
|
{ id: frame.id, from: frame.from, content: frame.content },
|
|
@@ -242,13 +562,17 @@ class BloonioChatService {
|
|
|
242
562
|
},
|
|
243
563
|
onTyping: (frame) => {
|
|
244
564
|
if (frame.from === 'bot' || frame.from === 'agent') {
|
|
245
|
-
this.
|
|
565
|
+
this.setTyping(frame.state === 'start');
|
|
246
566
|
}
|
|
247
567
|
},
|
|
248
568
|
onError: (frame) => {
|
|
249
569
|
this.messages.update((prev) => [
|
|
250
570
|
...prev,
|
|
251
|
-
{
|
|
571
|
+
{
|
|
572
|
+
id: `err_${Date.now()}`,
|
|
573
|
+
from: 'system',
|
|
574
|
+
content: this.t().error(frame.message),
|
|
575
|
+
},
|
|
252
576
|
]);
|
|
253
577
|
},
|
|
254
578
|
// Phase 10c — agent takeover visibility. Renders a system message
|
|
@@ -260,10 +584,10 @@ class BloonioChatService {
|
|
|
260
584
|
{
|
|
261
585
|
id: `sys_join_${Date.now()}`,
|
|
262
586
|
from: 'system',
|
|
263
|
-
content:
|
|
587
|
+
content: this.t().agentJoined(frame.agent_display_name),
|
|
264
588
|
},
|
|
265
589
|
]);
|
|
266
|
-
this.
|
|
590
|
+
this.setTyping(false);
|
|
267
591
|
},
|
|
268
592
|
onAgentLeft: () => {
|
|
269
593
|
this.messages.update((prev) => [
|
|
@@ -271,26 +595,19 @@ class BloonioChatService {
|
|
|
271
595
|
{
|
|
272
596
|
id: `sys_left_${Date.now()}`,
|
|
273
597
|
from: 'system',
|
|
274
|
-
content:
|
|
598
|
+
content: this.t().agentLeft,
|
|
275
599
|
},
|
|
276
600
|
]);
|
|
277
|
-
this.
|
|
601
|
+
this.setTyping(false);
|
|
278
602
|
},
|
|
279
603
|
// Phase 10f — chat_api opened a support ticket.
|
|
280
604
|
onTicketCreated: (frame) => {
|
|
281
|
-
const sla = frame.sla_hours === 1
|
|
282
|
-
? 'within 1h'
|
|
283
|
-
: frame.sla_hours <= 24
|
|
284
|
-
? `within ${frame.sla_hours}h`
|
|
285
|
-
: `within ${Math.round(frame.sla_hours / 24)}d`;
|
|
286
605
|
this.messages.update((prev) => [
|
|
287
606
|
...prev,
|
|
288
607
|
{
|
|
289
608
|
id: `sys_ticket_${frame.ticket_id}`,
|
|
290
609
|
from: 'system',
|
|
291
|
-
content:
|
|
292
|
-
`${frame.category} · ${frame.priority}. ` +
|
|
293
|
-
`We'll follow up ${sla}.`,
|
|
610
|
+
content: this.t().ticketCreated(frame.ticket_number, frame.category, frame.priority, frame.sla_hours),
|
|
294
611
|
},
|
|
295
612
|
]);
|
|
296
613
|
},
|
|
@@ -346,10 +663,10 @@ class BloonioChatService {
|
|
|
346
663
|
// SSR-safe fallback; the relay validates origins server-side anyway.
|
|
347
664
|
return '';
|
|
348
665
|
}
|
|
349
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "
|
|
350
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "
|
|
666
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: BloonioChatService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
667
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: BloonioChatService, providedIn: 'root' });
|
|
351
668
|
}
|
|
352
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "
|
|
669
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: BloonioChatService, decorators: [{
|
|
353
670
|
type: Injectable,
|
|
354
671
|
args: [{ providedIn: 'root' }]
|
|
355
672
|
}], ctorParameters: () => [] });
|
|
@@ -361,33 +678,58 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
361
678
|
*/
|
|
362
679
|
class BloonioChatLauncherComponent {
|
|
363
680
|
chat = inject(BloonioChatService);
|
|
364
|
-
|
|
681
|
+
t = injectChatStrings();
|
|
682
|
+
config = injectBloonioChatConfig();
|
|
683
|
+
button;
|
|
684
|
+
constructor() {
|
|
685
|
+
let wasOpen = false;
|
|
686
|
+
effect(() => {
|
|
687
|
+
const open = this.chat.open();
|
|
688
|
+
const button = this.button?.nativeElement;
|
|
689
|
+
// Closing from inside the panel (Escape, its close button) hands the
|
|
690
|
+
// keyboard focus back to the launcher instead of dropping it on <body>.
|
|
691
|
+
if (wasOpen && !open && button?.ownerDocument.activeElement?.closest('.bch-panel')) {
|
|
692
|
+
button.focus();
|
|
693
|
+
}
|
|
694
|
+
wasOpen = open;
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
position = computed(() => this.chat.bootstrap()?.widget_position ?? 'right', /* @ts-ignore */
|
|
698
|
+
...(ngDevMode ? [{ debugName: "position" }] : /* istanbul ignore next */ []));
|
|
365
699
|
/** Tint the bubble with the tenant's brand colour (falls back to the
|
|
366
700
|
* stylesheet default until bootstrap config loads). */
|
|
367
701
|
themeStyle = computed(() => {
|
|
368
702
|
const color = this.chat.bootstrap()?.branding_primary_color;
|
|
369
703
|
return color ? `--bch-primary: ${color}` : '';
|
|
370
|
-
}
|
|
704
|
+
}, /* @ts-ignore */
|
|
705
|
+
...(ngDevMode ? [{ debugName: "themeStyle" }] : /* istanbul ignore next */ []));
|
|
371
706
|
label = computed(() => {
|
|
707
|
+
const own = readOption(this.config.launcherLabel);
|
|
708
|
+
if (own !== undefined)
|
|
709
|
+
return own || null;
|
|
372
710
|
const b = this.chat.bootstrap();
|
|
373
711
|
return b?.button_label_enabled ? b.button_label_text : null;
|
|
374
|
-
}
|
|
712
|
+
}, /* @ts-ignore */
|
|
713
|
+
...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
|
|
375
714
|
ariaLabel = computed(() => {
|
|
715
|
+
if (this.chat.open())
|
|
716
|
+
return this.t().closeChat;
|
|
376
717
|
const unread = this.chat.unreadCount();
|
|
377
|
-
return unread > 0
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
});
|
|
718
|
+
return unread > 0 ? this.t().unread(unread) : this.t().openChat;
|
|
719
|
+
}, /* @ts-ignore */
|
|
720
|
+
...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
|
|
381
721
|
onToggle() {
|
|
382
722
|
void this.chat.toggle();
|
|
383
723
|
}
|
|
384
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "
|
|
385
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "
|
|
724
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: BloonioChatLauncherComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
725
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: BloonioChatLauncherComponent, isStandalone: true, selector: "bloonio-chat-launcher", viewQueries: [{ propertyName: "button", first: true, predicate: ["button"], descendants: true }], ngImport: i0, template: `
|
|
386
726
|
<div class="bch-root" [attr.data-position]="position()" [style]="themeStyle()">
|
|
387
727
|
<button
|
|
728
|
+
#button
|
|
388
729
|
type="button"
|
|
389
730
|
class="bch-launcher"
|
|
390
731
|
[attr.aria-label]="ariaLabel()"
|
|
732
|
+
[attr.aria-expanded]="chat.open()"
|
|
391
733
|
(click)="onToggle()"
|
|
392
734
|
>
|
|
393
735
|
@if (label()) {
|
|
@@ -398,12 +740,16 @@ class BloonioChatLauncherComponent {
|
|
|
398
740
|
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
|
399
741
|
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
|
400
742
|
</svg>
|
|
743
|
+
<!-- Hidden from assistive tech: the count is in the button's name. -->
|
|
744
|
+
@if (chat.unreadCount(); as unread) {
|
|
745
|
+
<span class="bch-launcher-badge" aria-hidden="true">{{ unread > 9 ? '9+' : unread }}</span>
|
|
746
|
+
}
|
|
401
747
|
</span>
|
|
402
748
|
</button>
|
|
403
749
|
</div>
|
|
404
750
|
`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
405
751
|
}
|
|
406
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "
|
|
752
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: BloonioChatLauncherComponent, decorators: [{
|
|
407
753
|
type: Component,
|
|
408
754
|
args: [{
|
|
409
755
|
selector: 'bloonio-chat-launcher',
|
|
@@ -412,9 +758,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
412
758
|
template: `
|
|
413
759
|
<div class="bch-root" [attr.data-position]="position()" [style]="themeStyle()">
|
|
414
760
|
<button
|
|
761
|
+
#button
|
|
415
762
|
type="button"
|
|
416
763
|
class="bch-launcher"
|
|
417
764
|
[attr.aria-label]="ariaLabel()"
|
|
765
|
+
[attr.aria-expanded]="chat.open()"
|
|
418
766
|
(click)="onToggle()"
|
|
419
767
|
>
|
|
420
768
|
@if (label()) {
|
|
@@ -425,12 +773,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
425
773
|
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
|
426
774
|
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
|
427
775
|
</svg>
|
|
776
|
+
<!-- Hidden from assistive tech: the count is in the button's name. -->
|
|
777
|
+
@if (chat.unreadCount(); as unread) {
|
|
778
|
+
<span class="bch-launcher-badge" aria-hidden="true">{{ unread > 9 ? '9+' : unread }}</span>
|
|
779
|
+
}
|
|
428
780
|
</span>
|
|
429
781
|
</button>
|
|
430
782
|
</div>
|
|
431
783
|
`,
|
|
432
784
|
}]
|
|
433
|
-
}]
|
|
785
|
+
}], ctorParameters: () => [], propDecorators: { button: [{
|
|
786
|
+
type: ViewChild,
|
|
787
|
+
args: ['button']
|
|
788
|
+
}] } });
|
|
434
789
|
|
|
435
790
|
/**
|
|
436
791
|
* Visitor input bar — multi-line textarea + send button. Enter sends,
|
|
@@ -440,7 +795,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
440
795
|
class InputBarComponent {
|
|
441
796
|
disabled = false;
|
|
442
797
|
send = new EventEmitter();
|
|
443
|
-
|
|
798
|
+
t = injectChatStrings();
|
|
799
|
+
value = signal('', /* @ts-ignore */
|
|
800
|
+
...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
|
|
444
801
|
canSend() {
|
|
445
802
|
return !this.disabled && this.value().trim().length > 0;
|
|
446
803
|
}
|
|
@@ -460,12 +817,13 @@ class InputBarComponent {
|
|
|
460
817
|
this.send.emit(trimmed);
|
|
461
818
|
this.value.set('');
|
|
462
819
|
}
|
|
463
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "
|
|
464
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "
|
|
820
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: InputBarComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
821
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.1.5", type: InputBarComponent, isStandalone: true, selector: "bloonio-chat-input-bar", inputs: { disabled: "disabled" }, outputs: { send: "send" }, ngImport: i0, template: `
|
|
465
822
|
<div class="bch-input">
|
|
466
823
|
<textarea
|
|
467
824
|
rows="1"
|
|
468
|
-
placeholder="
|
|
825
|
+
[placeholder]="t().placeholder"
|
|
826
|
+
[attr.aria-label]="t().placeholder"
|
|
469
827
|
[disabled]="disabled"
|
|
470
828
|
[value]="value()"
|
|
471
829
|
(input)="onInput($event)"
|
|
@@ -474,7 +832,7 @@ class InputBarComponent {
|
|
|
474
832
|
<button
|
|
475
833
|
type="button"
|
|
476
834
|
class="bch-input-send"
|
|
477
|
-
aria-label="
|
|
835
|
+
[attr.aria-label]="t().send"
|
|
478
836
|
[disabled]="!canSend()"
|
|
479
837
|
(click)="submit()"
|
|
480
838
|
>
|
|
@@ -485,7 +843,7 @@ class InputBarComponent {
|
|
|
485
843
|
</div>
|
|
486
844
|
`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
487
845
|
}
|
|
488
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "
|
|
846
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: InputBarComponent, decorators: [{
|
|
489
847
|
type: Component,
|
|
490
848
|
args: [{
|
|
491
849
|
selector: 'bloonio-chat-input-bar',
|
|
@@ -495,7 +853,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
495
853
|
<div class="bch-input">
|
|
496
854
|
<textarea
|
|
497
855
|
rows="1"
|
|
498
|
-
placeholder="
|
|
856
|
+
[placeholder]="t().placeholder"
|
|
857
|
+
[attr.aria-label]="t().placeholder"
|
|
499
858
|
[disabled]="disabled"
|
|
500
859
|
[value]="value()"
|
|
501
860
|
(input)="onInput($event)"
|
|
@@ -504,7 +863,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
504
863
|
<button
|
|
505
864
|
type="button"
|
|
506
865
|
class="bch-input-send"
|
|
507
|
-
aria-label="
|
|
866
|
+
[attr.aria-label]="t().send"
|
|
508
867
|
[disabled]="!canSend()"
|
|
509
868
|
(click)="submit()"
|
|
510
869
|
>
|
|
@@ -529,42 +888,46 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
529
888
|
class MessageListComponent {
|
|
530
889
|
messages = [];
|
|
531
890
|
botTyping = false;
|
|
532
|
-
|
|
533
|
-
static
|
|
891
|
+
t = injectChatStrings();
|
|
892
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: MessageListComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
893
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: MessageListComponent, isStandalone: true, selector: "bloonio-chat-message-list", inputs: { messages: "messages", botTyping: "botTyping" }, host: { properties: { "style.display": "\"contents\"" } }, ngImport: i0, template: `
|
|
534
894
|
@for (m of messages; track m.id) {
|
|
895
|
+
<!-- No whitespace around the text: bubbles are pre-wrap and would show it. -->
|
|
535
896
|
<div
|
|
536
897
|
class="bch-bubble"
|
|
537
898
|
[attr.data-from]="m.from"
|
|
538
899
|
[attr.data-pending]="m.pending ? 'true' : null"
|
|
539
|
-
>
|
|
540
|
-
{{ m.content }}
|
|
541
|
-
</div>
|
|
900
|
+
>{{ m.content }}</div>
|
|
542
901
|
}
|
|
543
902
|
@if (botTyping) {
|
|
544
|
-
<div class="bch-typing" aria-label="
|
|
903
|
+
<div class="bch-typing" role="status" [attr.aria-label]="t().typing">
|
|
545
904
|
<span></span><span></span><span></span>
|
|
546
905
|
</div>
|
|
547
906
|
}
|
|
548
907
|
`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
549
908
|
}
|
|
550
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "
|
|
909
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: MessageListComponent, decorators: [{
|
|
551
910
|
type: Component,
|
|
552
911
|
args: [{
|
|
553
912
|
selector: 'bloonio-chat-message-list',
|
|
554
913
|
standalone: true,
|
|
555
914
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
915
|
+
// The bubbles are laid out by the parent's flex column (`.bch-messages`:
|
|
916
|
+
// visitor right, answers left, notices centred). Without this the host
|
|
917
|
+
// box sits in between and every bubble falls back to the left. A binding,
|
|
918
|
+
// not a style attribute, so a strict CSP does not drop it.
|
|
919
|
+
host: { '[style.display]': '"contents"' },
|
|
556
920
|
template: `
|
|
557
921
|
@for (m of messages; track m.id) {
|
|
922
|
+
<!-- No whitespace around the text: bubbles are pre-wrap and would show it. -->
|
|
558
923
|
<div
|
|
559
924
|
class="bch-bubble"
|
|
560
925
|
[attr.data-from]="m.from"
|
|
561
926
|
[attr.data-pending]="m.pending ? 'true' : null"
|
|
562
|
-
>
|
|
563
|
-
{{ m.content }}
|
|
564
|
-
</div>
|
|
927
|
+
>{{ m.content }}</div>
|
|
565
928
|
}
|
|
566
929
|
@if (botTyping) {
|
|
567
|
-
<div class="bch-typing" aria-label="
|
|
930
|
+
<div class="bch-typing" role="status" [attr.aria-label]="t().typing">
|
|
568
931
|
<span></span><span></span><span></span>
|
|
569
932
|
</div>
|
|
570
933
|
}
|
|
@@ -588,21 +951,27 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
588
951
|
*/
|
|
589
952
|
class QuickRepliesComponent {
|
|
590
953
|
chat = inject(BloonioChatService);
|
|
591
|
-
|
|
954
|
+
t = injectChatStrings();
|
|
955
|
+
config = injectBloonioChatConfig();
|
|
956
|
+
/** Show the chips (the host's, else the tenant's) only until the
|
|
957
|
+
* visitor's first message, and only once a tap can actually be sent. */
|
|
592
958
|
replies = computed(() => {
|
|
593
|
-
|
|
594
|
-
if (!bootstrap || bootstrap.quick_replies.length === 0)
|
|
959
|
+
if (!this.chat.session() || this.chat.connecting())
|
|
595
960
|
return [];
|
|
961
|
+
const replies = readOption(this.config.quickReplies) ??
|
|
962
|
+
this.chat.bootstrap()?.quick_replies ??
|
|
963
|
+
[];
|
|
596
964
|
const visitorSpoke = this.chat.messages().some((m) => m.from === 'visitor');
|
|
597
|
-
return visitorSpoke ? [] :
|
|
598
|
-
}
|
|
965
|
+
return visitorSpoke ? [] : replies;
|
|
966
|
+
}, /* @ts-ignore */
|
|
967
|
+
...(ngDevMode ? [{ debugName: "replies" }] : /* istanbul ignore next */ []));
|
|
599
968
|
pick(text) {
|
|
600
969
|
this.chat.sendMessage(text);
|
|
601
970
|
}
|
|
602
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "
|
|
603
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "
|
|
971
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: QuickRepliesComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
972
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: QuickRepliesComponent, isStandalone: true, selector: "bloonio-chat-quick-replies", ngImport: i0, template: `
|
|
604
973
|
@if (replies().length) {
|
|
605
|
-
<div class="bch-quick-replies" role="group" aria-label="
|
|
974
|
+
<div class="bch-quick-replies" role="group" [attr.aria-label]="t().suggestedReplies">
|
|
606
975
|
@for (reply of replies(); track reply) {
|
|
607
976
|
<button type="button" class="bch-quick-reply" (click)="pick(reply)">
|
|
608
977
|
{{ reply }}
|
|
@@ -612,7 +981,7 @@ class QuickRepliesComponent {
|
|
|
612
981
|
}
|
|
613
982
|
`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
614
983
|
}
|
|
615
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "
|
|
984
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: QuickRepliesComponent, decorators: [{
|
|
616
985
|
type: Component,
|
|
617
986
|
args: [{
|
|
618
987
|
selector: 'bloonio-chat-quick-replies',
|
|
@@ -620,7 +989,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
620
989
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
621
990
|
template: `
|
|
622
991
|
@if (replies().length) {
|
|
623
|
-
<div class="bch-quick-replies" role="group" aria-label="
|
|
992
|
+
<div class="bch-quick-replies" role="group" [attr.aria-label]="t().suggestedReplies">
|
|
624
993
|
@for (reply of replies(); track reply) {
|
|
625
994
|
<button type="button" class="bch-quick-reply" (click)="pick(reply)">
|
|
626
995
|
{{ reply }}
|
|
@@ -642,57 +1011,70 @@ class BloonioChatPanelComponent {
|
|
|
642
1011
|
* inside the page flow (use for SSR landing pages or sidebar layouts). */
|
|
643
1012
|
mode = 'floating';
|
|
644
1013
|
chat = inject(BloonioChatService);
|
|
1014
|
+
t = injectChatStrings();
|
|
1015
|
+
config = injectBloonioChatConfig();
|
|
1016
|
+
injector = inject(Injector);
|
|
645
1017
|
messagesRef;
|
|
646
|
-
|
|
647
|
-
|
|
1018
|
+
panelRef;
|
|
1019
|
+
lastScrollLen = signal(-1, /* @ts-ignore */
|
|
1020
|
+
...(ngDevMode ? [{ debugName: "lastScrollLen" }] : /* istanbul ignore next */ []));
|
|
1021
|
+
constructor() {
|
|
1022
|
+
effect(() => {
|
|
1023
|
+
if (this.mode !== 'floating' || !this.chat.open())
|
|
1024
|
+
return;
|
|
1025
|
+
// Opening moves focus into the dialog (so Escape and Tab work from
|
|
1026
|
+
// there). The panel is still `visibility: hidden` until this render
|
|
1027
|
+
// lands, hence after it.
|
|
1028
|
+
afterNextRender(() => this.panelRef?.nativeElement.focus(), {
|
|
1029
|
+
injector: this.injector,
|
|
1030
|
+
});
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
position = computed(() => this.chat.bootstrap()?.widget_position ?? 'right', /* @ts-ignore */
|
|
1034
|
+
...(ngDevMode ? [{ debugName: "position" }] : /* istanbul ignore next */ []));
|
|
648
1035
|
visible = computed(() => {
|
|
649
1036
|
// Floating mode: only when service.open() is true.
|
|
650
1037
|
// Docked/inline: always rendered if bootstrap is loaded.
|
|
651
1038
|
if (this.mode === 'floating')
|
|
652
1039
|
return this.chat.open();
|
|
653
1040
|
return !!this.chat.bootstrap();
|
|
654
|
-
}
|
|
1041
|
+
}, /* @ts-ignore */
|
|
1042
|
+
...(ngDevMode ? [{ debugName: "visible" }] : /* istanbul ignore next */ []));
|
|
655
1043
|
themeStyle = computed(() => {
|
|
656
1044
|
const color = this.chat.bootstrap()?.branding_primary_color;
|
|
657
1045
|
return color ? `--bch-primary: ${color}` : '';
|
|
658
|
-
}
|
|
1046
|
+
}, /* @ts-ignore */
|
|
1047
|
+
...(ngDevMode ? [{ debugName: "themeStyle" }] : /* istanbul ignore next */ []));
|
|
659
1048
|
displayName = computed(() => this.chat.bootstrap()?.branding_display_name
|
|
660
1049
|
?? this.chat.bootstrap()?.tenant_name
|
|
661
|
-
??
|
|
662
|
-
|
|
1050
|
+
?? this.t().support, /* @ts-ignore */
|
|
1051
|
+
...(ngDevMode ? [{ debugName: "displayName" }] : /* istanbul ignore next */ []));
|
|
1052
|
+
logoUrl = computed(() => this.chat.bootstrap()?.branding_logo_url ?? null, /* @ts-ignore */
|
|
1053
|
+
...(ngDevMode ? [{ debugName: "logoUrl" }] : /* istanbul ignore next */ []));
|
|
663
1054
|
initials = computed(() => this.displayName()
|
|
664
1055
|
.split(/\s+/)
|
|
665
1056
|
.slice(0, 2)
|
|
666
1057
|
.map((w) => w[0]?.toUpperCase() ?? '')
|
|
667
|
-
.join('') || '?'
|
|
1058
|
+
.join('') || '?', /* @ts-ignore */
|
|
1059
|
+
...(ngDevMode ? [{ debugName: "initials" }] : /* istanbul ignore next */ []));
|
|
668
1060
|
statusText = computed(() => {
|
|
669
1061
|
const b = this.chat.bootstrap();
|
|
670
1062
|
if (!b)
|
|
671
1063
|
return '';
|
|
672
|
-
return b.is_online
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
return [
|
|
680
|
-
{
|
|
681
|
-
id: 'sys_offline',
|
|
682
|
-
from: 'system',
|
|
683
|
-
content: b.offline_message,
|
|
684
|
-
},
|
|
685
|
-
];
|
|
686
|
-
}
|
|
687
|
-
return messages;
|
|
688
|
-
});
|
|
1064
|
+
return b.is_online
|
|
1065
|
+
? readOption(this.config.onlineStatusText) ?? b.online_status_text
|
|
1066
|
+
: readOption(this.config.offlineStatusText) ?? b.offline_status_text;
|
|
1067
|
+
}, /* @ts-ignore */
|
|
1068
|
+
...(ngDevMode ? [{ debugName: "statusText" }] : /* istanbul ignore next */ []));
|
|
1069
|
+
isOnlineStr = computed(() => String(this.chat.bootstrap()?.is_online ?? false), /* @ts-ignore */
|
|
1070
|
+
...(ngDevMode ? [{ debugName: "isOnlineStr" }] : /* istanbul ignore next */ []));
|
|
689
1071
|
ngAfterViewChecked() {
|
|
690
1072
|
// Scroll-to-bottom whenever message count changes. We compare against a
|
|
691
1073
|
// stamped length so we don't churn when only typing/status updates fire.
|
|
692
1074
|
const el = this.messagesRef?.nativeElement;
|
|
693
1075
|
if (!el)
|
|
694
1076
|
return;
|
|
695
|
-
const len = this.visibleMessages().length + (this.chat.botTyping() ? 1 : 0);
|
|
1077
|
+
const len = this.chat.visibleMessages().length + (this.chat.botTyping() ? 1 : 0);
|
|
696
1078
|
if (len !== this.lastScrollLen()) {
|
|
697
1079
|
el.scrollTop = el.scrollHeight;
|
|
698
1080
|
this.lastScrollLen.set(len);
|
|
@@ -704,25 +1086,33 @@ class BloonioChatPanelComponent {
|
|
|
704
1086
|
onSend(content) {
|
|
705
1087
|
this.chat.sendMessage(content);
|
|
706
1088
|
}
|
|
707
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "
|
|
708
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "
|
|
1089
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: BloonioChatPanelComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1090
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: BloonioChatPanelComponent, isStandalone: true, selector: "bloonio-chat-panel", inputs: { mode: "mode" }, viewQueries: [{ propertyName: "messagesRef", first: true, predicate: ["messages"], descendants: true }, { propertyName: "panelRef", first: true, predicate: ["panel"], descendants: true }], ngImport: i0, template: `
|
|
709
1091
|
<div
|
|
710
1092
|
class="bch-root"
|
|
711
1093
|
[class.bch-open]="visible()"
|
|
712
1094
|
[attr.data-position]="position()"
|
|
713
1095
|
[attr.data-mode]="mode"
|
|
714
1096
|
>
|
|
715
|
-
<div
|
|
1097
|
+
<div
|
|
1098
|
+
#panel
|
|
1099
|
+
class="bch-panel"
|
|
1100
|
+
[style]="themeStyle()"
|
|
1101
|
+
[attr.role]="mode === 'floating' ? 'dialog' : null"
|
|
1102
|
+
[attr.aria-label]="mode === 'floating' ? t().chatWith(displayName()) : null"
|
|
1103
|
+
[attr.tabindex]="mode === 'floating' ? -1 : null"
|
|
1104
|
+
(keydown.escape)="mode === 'floating' && onClose()"
|
|
1105
|
+
>
|
|
716
1106
|
<header class="bch-header">
|
|
717
1107
|
<div class="bch-header-avatar">
|
|
718
1108
|
@if (logoUrl()) {
|
|
719
1109
|
<img [src]="logoUrl()" alt="" />
|
|
720
1110
|
} @else {
|
|
721
|
-
<span
|
|
1111
|
+
<span>{{ initials() }}</span>
|
|
722
1112
|
}
|
|
723
1113
|
</div>
|
|
724
1114
|
<div class="bch-header-title">
|
|
725
|
-
<div class="bch-header-name">
|
|
1115
|
+
<div class="bch-header-name">{{ t().chatWith(displayName()) }}</div>
|
|
726
1116
|
<div class="bch-header-status">
|
|
727
1117
|
<span class="bch-header-status-dot" [attr.data-online]="isOnlineStr()"></span>
|
|
728
1118
|
{{ statusText() }}
|
|
@@ -731,7 +1121,7 @@ class BloonioChatPanelComponent {
|
|
|
731
1121
|
<button
|
|
732
1122
|
type="button"
|
|
733
1123
|
class="bch-header-close"
|
|
734
|
-
aria-label="
|
|
1124
|
+
[attr.aria-label]="t().close"
|
|
735
1125
|
(click)="onClose()"
|
|
736
1126
|
>
|
|
737
1127
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
@@ -742,9 +1132,9 @@ class BloonioChatPanelComponent {
|
|
|
742
1132
|
</button>
|
|
743
1133
|
</header>
|
|
744
1134
|
|
|
745
|
-
<div class="bch-messages" #messages>
|
|
1135
|
+
<div class="bch-messages" role="log" #messages>
|
|
746
1136
|
<bloonio-chat-message-list
|
|
747
|
-
[messages]="visibleMessages()"
|
|
1137
|
+
[messages]="chat.visibleMessages()"
|
|
748
1138
|
[botTyping]="chat.botTyping()"
|
|
749
1139
|
/>
|
|
750
1140
|
</div>
|
|
@@ -752,19 +1142,19 @@ class BloonioChatPanelComponent {
|
|
|
752
1142
|
<bloonio-chat-quick-replies />
|
|
753
1143
|
|
|
754
1144
|
<bloonio-chat-input-bar
|
|
755
|
-
[disabled]="chat.connecting()"
|
|
1145
|
+
[disabled]="chat.connecting() || !chat.session()"
|
|
756
1146
|
(send)="onSend($event)"
|
|
757
1147
|
/>
|
|
758
1148
|
|
|
759
1149
|
<div class="bch-footer">
|
|
760
|
-
|
|
1150
|
+
{{ t().poweredBy }}
|
|
761
1151
|
<a href="https://bloonio.com" target="_blank" rel="noopener noreferrer">Bloonio</a>
|
|
762
1152
|
</div>
|
|
763
1153
|
</div>
|
|
764
1154
|
</div>
|
|
765
1155
|
`, isInline: true, dependencies: [{ kind: "component", type: InputBarComponent, selector: "bloonio-chat-input-bar", inputs: ["disabled"], outputs: ["send"] }, { kind: "component", type: MessageListComponent, selector: "bloonio-chat-message-list", inputs: ["messages", "botTyping"] }, { kind: "component", type: QuickRepliesComponent, selector: "bloonio-chat-quick-replies" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
766
1156
|
}
|
|
767
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "
|
|
1157
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: BloonioChatPanelComponent, decorators: [{
|
|
768
1158
|
type: Component,
|
|
769
1159
|
args: [{
|
|
770
1160
|
selector: 'bloonio-chat-panel',
|
|
@@ -778,17 +1168,25 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
778
1168
|
[attr.data-position]="position()"
|
|
779
1169
|
[attr.data-mode]="mode"
|
|
780
1170
|
>
|
|
781
|
-
<div
|
|
1171
|
+
<div
|
|
1172
|
+
#panel
|
|
1173
|
+
class="bch-panel"
|
|
1174
|
+
[style]="themeStyle()"
|
|
1175
|
+
[attr.role]="mode === 'floating' ? 'dialog' : null"
|
|
1176
|
+
[attr.aria-label]="mode === 'floating' ? t().chatWith(displayName()) : null"
|
|
1177
|
+
[attr.tabindex]="mode === 'floating' ? -1 : null"
|
|
1178
|
+
(keydown.escape)="mode === 'floating' && onClose()"
|
|
1179
|
+
>
|
|
782
1180
|
<header class="bch-header">
|
|
783
1181
|
<div class="bch-header-avatar">
|
|
784
1182
|
@if (logoUrl()) {
|
|
785
1183
|
<img [src]="logoUrl()" alt="" />
|
|
786
1184
|
} @else {
|
|
787
|
-
<span
|
|
1185
|
+
<span>{{ initials() }}</span>
|
|
788
1186
|
}
|
|
789
1187
|
</div>
|
|
790
1188
|
<div class="bch-header-title">
|
|
791
|
-
<div class="bch-header-name">
|
|
1189
|
+
<div class="bch-header-name">{{ t().chatWith(displayName()) }}</div>
|
|
792
1190
|
<div class="bch-header-status">
|
|
793
1191
|
<span class="bch-header-status-dot" [attr.data-online]="isOnlineStr()"></span>
|
|
794
1192
|
{{ statusText() }}
|
|
@@ -797,7 +1195,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
797
1195
|
<button
|
|
798
1196
|
type="button"
|
|
799
1197
|
class="bch-header-close"
|
|
800
|
-
aria-label="
|
|
1198
|
+
[attr.aria-label]="t().close"
|
|
801
1199
|
(click)="onClose()"
|
|
802
1200
|
>
|
|
803
1201
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
@@ -808,9 +1206,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
808
1206
|
</button>
|
|
809
1207
|
</header>
|
|
810
1208
|
|
|
811
|
-
<div class="bch-messages" #messages>
|
|
1209
|
+
<div class="bch-messages" role="log" #messages>
|
|
812
1210
|
<bloonio-chat-message-list
|
|
813
|
-
[messages]="visibleMessages()"
|
|
1211
|
+
[messages]="chat.visibleMessages()"
|
|
814
1212
|
[botTyping]="chat.botTyping()"
|
|
815
1213
|
/>
|
|
816
1214
|
</div>
|
|
@@ -818,23 +1216,26 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
818
1216
|
<bloonio-chat-quick-replies />
|
|
819
1217
|
|
|
820
1218
|
<bloonio-chat-input-bar
|
|
821
|
-
[disabled]="chat.connecting()"
|
|
1219
|
+
[disabled]="chat.connecting() || !chat.session()"
|
|
822
1220
|
(send)="onSend($event)"
|
|
823
1221
|
/>
|
|
824
1222
|
|
|
825
1223
|
<div class="bch-footer">
|
|
826
|
-
|
|
1224
|
+
{{ t().poweredBy }}
|
|
827
1225
|
<a href="https://bloonio.com" target="_blank" rel="noopener noreferrer">Bloonio</a>
|
|
828
1226
|
</div>
|
|
829
1227
|
</div>
|
|
830
1228
|
</div>
|
|
831
1229
|
`,
|
|
832
1230
|
}]
|
|
833
|
-
}], propDecorators: { mode: [{
|
|
1231
|
+
}], ctorParameters: () => [], propDecorators: { mode: [{
|
|
834
1232
|
type: Input
|
|
835
1233
|
}], messagesRef: [{
|
|
836
1234
|
type: ViewChild,
|
|
837
1235
|
args: ['messages']
|
|
1236
|
+
}], panelRef: [{
|
|
1237
|
+
type: ViewChild,
|
|
1238
|
+
args: ['panel']
|
|
838
1239
|
}] } });
|
|
839
1240
|
|
|
840
1241
|
/**
|
|
@@ -850,30 +1251,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
850
1251
|
class BloonioChatThreadComponent {
|
|
851
1252
|
chat = inject(BloonioChatService);
|
|
852
1253
|
messagesRef;
|
|
853
|
-
lastScrollLen = signal(-1
|
|
1254
|
+
lastScrollLen = signal(-1, /* @ts-ignore */
|
|
1255
|
+
...(ngDevMode ? [{ debugName: "lastScrollLen" }] : /* istanbul ignore next */ []));
|
|
854
1256
|
themeStyle = computed(() => {
|
|
855
1257
|
const color = this.chat.bootstrap()?.branding_primary_color;
|
|
856
1258
|
return color ? `--bch-primary: ${color}` : '';
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
const b = this.chat.bootstrap();
|
|
860
|
-
const messages = this.chat.messages();
|
|
861
|
-
if (b && !b.is_online && messages.length === 0) {
|
|
862
|
-
return [
|
|
863
|
-
{
|
|
864
|
-
id: 'sys_offline',
|
|
865
|
-
from: 'system',
|
|
866
|
-
content: b.offline_message,
|
|
867
|
-
},
|
|
868
|
-
];
|
|
869
|
-
}
|
|
870
|
-
return messages;
|
|
871
|
-
});
|
|
1259
|
+
}, /* @ts-ignore */
|
|
1260
|
+
...(ngDevMode ? [{ debugName: "themeStyle" }] : /* istanbul ignore next */ []));
|
|
872
1261
|
ngAfterViewChecked() {
|
|
873
1262
|
const el = this.messagesRef?.nativeElement;
|
|
874
1263
|
if (!el)
|
|
875
1264
|
return;
|
|
876
|
-
const len = this.visibleMessages().length + (this.chat.botTyping() ? 1 : 0);
|
|
1265
|
+
const len = this.chat.visibleMessages().length + (this.chat.botTyping() ? 1 : 0);
|
|
877
1266
|
if (len !== this.lastScrollLen()) {
|
|
878
1267
|
el.scrollTop = el.scrollHeight;
|
|
879
1268
|
this.lastScrollLen.set(len);
|
|
@@ -885,26 +1274,26 @@ class BloonioChatThreadComponent {
|
|
|
885
1274
|
constructor() {
|
|
886
1275
|
void this.chat.openPanel();
|
|
887
1276
|
}
|
|
888
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "
|
|
889
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "
|
|
1277
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: BloonioChatThreadComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1278
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.1.5", type: BloonioChatThreadComponent, isStandalone: true, selector: "bloonio-chat-thread", viewQueries: [{ propertyName: "messagesRef", first: true, predicate: ["messages"], descendants: true }], ngImport: i0, template: `
|
|
890
1279
|
<div class="bch-root" data-mode="inline" [style]="themeStyle()">
|
|
891
1280
|
<div class="bch-panel">
|
|
892
|
-
<div class="bch-messages" #messages>
|
|
1281
|
+
<div class="bch-messages" role="log" #messages>
|
|
893
1282
|
<bloonio-chat-message-list
|
|
894
|
-
[messages]="visibleMessages()"
|
|
1283
|
+
[messages]="chat.visibleMessages()"
|
|
895
1284
|
[botTyping]="chat.botTyping()"
|
|
896
1285
|
/>
|
|
897
1286
|
</div>
|
|
898
1287
|
<bloonio-chat-quick-replies />
|
|
899
1288
|
<bloonio-chat-input-bar
|
|
900
|
-
[disabled]="chat.connecting()"
|
|
1289
|
+
[disabled]="chat.connecting() || !chat.session()"
|
|
901
1290
|
(send)="chat.sendMessage($event)"
|
|
902
1291
|
/>
|
|
903
1292
|
</div>
|
|
904
1293
|
</div>
|
|
905
1294
|
`, isInline: true, dependencies: [{ kind: "component", type: InputBarComponent, selector: "bloonio-chat-input-bar", inputs: ["disabled"], outputs: ["send"] }, { kind: "component", type: MessageListComponent, selector: "bloonio-chat-message-list", inputs: ["messages", "botTyping"] }, { kind: "component", type: QuickRepliesComponent, selector: "bloonio-chat-quick-replies" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
906
1295
|
}
|
|
907
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "
|
|
1296
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: BloonioChatThreadComponent, decorators: [{
|
|
908
1297
|
type: Component,
|
|
909
1298
|
args: [{
|
|
910
1299
|
selector: 'bloonio-chat-thread',
|
|
@@ -914,15 +1303,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
914
1303
|
template: `
|
|
915
1304
|
<div class="bch-root" data-mode="inline" [style]="themeStyle()">
|
|
916
1305
|
<div class="bch-panel">
|
|
917
|
-
<div class="bch-messages" #messages>
|
|
1306
|
+
<div class="bch-messages" role="log" #messages>
|
|
918
1307
|
<bloonio-chat-message-list
|
|
919
|
-
[messages]="visibleMessages()"
|
|
1308
|
+
[messages]="chat.visibleMessages()"
|
|
920
1309
|
[botTyping]="chat.botTyping()"
|
|
921
1310
|
/>
|
|
922
1311
|
</div>
|
|
923
1312
|
<bloonio-chat-quick-replies />
|
|
924
1313
|
<bloonio-chat-input-bar
|
|
925
|
-
[disabled]="chat.connecting()"
|
|
1314
|
+
[disabled]="chat.connecting() || !chat.session()"
|
|
926
1315
|
(send)="chat.sendMessage($event)"
|
|
927
1316
|
/>
|
|
928
1317
|
</div>
|
|
@@ -948,7 +1337,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
948
1337
|
* Styles ship separately at `@bloonio/chat-angular/styles.css` — import once
|
|
949
1338
|
* in your global stylesheet (see README).
|
|
950
1339
|
*/
|
|
951
|
-
const VERSION = '0.
|
|
1340
|
+
const VERSION = '0.2.0';
|
|
952
1341
|
|
|
953
1342
|
/**
|
|
954
1343
|
* Generated bundle index. Do not edit.
|