@bloonio/chat-angular 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,958 @@
1
+ import * as i0 from '@angular/core';
2
+ import { InjectionToken, makeEnvironmentProviders, inject, PLATFORM_ID, DestroyRef, signal, computed, afterNextRender, Injectable, ChangeDetectionStrategy, Component, EventEmitter, Output, Input, ViewChild } from '@angular/core';
3
+ import { isPlatformBrowser } from '@angular/common';
4
+ import { isObservable, firstValueFrom } from 'rxjs';
5
+ import { WSClient, ChatClient } from '@bloonio/chat-client-core';
6
+ export { BloonioChatAuthError, BloonioChatError, BloonioChatNetworkError, BloonioChatServerError } from '@bloonio/chat-client-core';
7
+
8
+ /**
9
+ * Dependency-injection plumbing for the Angular chat SDK.
10
+ *
11
+ * Consumers wire the SDK into their app via the standard Angular pattern
12
+ * documented in docs/12 §7.3:
13
+ *
14
+ * // app.config.ts
15
+ * export const appConfig: ApplicationConfig = {
16
+ * providers: [
17
+ * provideBloonioChat({
18
+ * tenantId: env.CHAT_TENANT_ID,
19
+ * publicKey: env.CHAT_PUBLIC_KEY,
20
+ * baseUrl: 'https://chat-relay.bloonio.com',
21
+ * visitorTokenProvider: () => inject(HttpClient).get<{token: string}>(
22
+ * '/api/chat/visitor-token'
23
+ * ).pipe(map(r => r.token)),
24
+ * locale: 'fr',
25
+ * }),
26
+ * ],
27
+ * };
28
+ */
29
+ const BLOONIO_CHAT_CONFIG = new InjectionToken('BLOONIO_CHAT_CONFIG');
30
+ /** Standalone-provider factory.
31
+ *
32
+ * Returns ``EnvironmentProviders`` so consumers can spread it into
33
+ * ``app.config.ts``'s providers array. The service itself is
34
+ * ``providedIn: 'root'`` so we don't need an explicit provider entry — we
35
+ * only need the config token to be registered.
36
+ */
37
+ function provideBloonioChat(config) {
38
+ return makeEnvironmentProviders([
39
+ { provide: BLOONIO_CHAT_CONFIG, useValue: config },
40
+ ]);
41
+ }
42
+ /** Internal accessor used by the components to fail loud when the
43
+ * consumer forgot ``provideBloonioChat()``. */
44
+ function injectBloonioChatConfig() {
45
+ const cfg = inject(BLOONIO_CHAT_CONFIG, { optional: true });
46
+ if (!cfg) {
47
+ throw new Error('[bloonio-chat-angular] provideBloonioChat() was not called in your app.config.ts. ' +
48
+ 'Add it to providers: [...] alongside provideRouter/provideHttpClient.');
49
+ }
50
+ return cfg;
51
+ }
52
+
53
+ /**
54
+ * BloonioChatService — signals-based reactive state container.
55
+ *
56
+ * Mirrors the Preact widget's `Widget.tsx` state machine but uses Angular
57
+ * signals so templates auto-track the slots they read. Public surface:
58
+ *
59
+ * readonly bootstrap = signal<WidgetBootstrapConfig | null>(null);
60
+ * readonly session = signal<CreateVisitorSessionResponse | null>(null);
61
+ * readonly messages = signal<ChatMessage[]>([]);
62
+ * readonly botTyping = signal(false);
63
+ * readonly open = signal(false);
64
+ * readonly connecting= signal(false);
65
+ * readonly unreadCount = computed(...);
66
+ *
67
+ * async open(): Promise<void>
68
+ * close(): void
69
+ * async toggle(): Promise<void>
70
+ * sendMessage(text: string): void
71
+ *
72
+ * SSR-safe: every transport call (fetch, WebSocket) is gated behind a
73
+ * platform check via `afterNextRender()` so server pre-rendering doesn't
74
+ * try to open sockets.
75
+ */
76
+ class BloonioChatService {
77
+ config = injectBloonioChatConfig();
78
+ platformId = inject(PLATFORM_ID);
79
+ destroyRef = inject(DestroyRef);
80
+ /** True when running in the browser (vs. server pre-render). */
81
+ isBrowser = isPlatformBrowser(this.platformId);
82
+ /** Bootstrap config — populated by the first `loadBootstrap()` call. */
83
+ bootstrap = signal(null);
84
+ /** Visitor session (id + JWT + expires_at) — set lazily on first open. */
85
+ session = signal(null);
86
+ /** Message log, oldest → newest. */
87
+ messages = signal([]);
88
+ /** True between bot's typing(start) and typing(stop) frames. */
89
+ botTyping = signal(false);
90
+ /** Panel visibility — drives Launcher / Panel rendering. */
91
+ open = signal(false);
92
+ /** True while `ensureSession()` is in flight (initial WS handshake). */
93
+ connecting = signal(false);
94
+ /** Set when the WS handshake fails permanently (e.g. tenant suspended). */
95
+ fatalError = signal(null);
96
+ /** Number of bot/agent messages received while the panel was closed.
97
+ * Resets to 0 the moment the panel opens. */
98
+ _seenCount = signal(0);
99
+ unreadCount = computed(() => {
100
+ if (this.open())
101
+ return 0;
102
+ const total = this.messages().filter((m) => m.from === 'bot' || m.from === 'agent').length;
103
+ return Math.max(0, total - this._seenCount());
104
+ });
105
+ client = null;
106
+ ws = null;
107
+ bootstrapPromise = null;
108
+ sessionPromise = null;
109
+ constructor() {
110
+ // SSR safety: schedule the initial bootstrap fetch on first browser render.
111
+ afterNextRender(() => {
112
+ this.loadBootstrap();
113
+ });
114
+ this.destroyRef.onDestroy(() => {
115
+ this.ws?.close();
116
+ this.ws = null;
117
+ });
118
+ }
119
+ // ── Public methods ─────────────────────────────────────────────────────
120
+ /** Toggle panel visibility. First open also opens the WS session. */
121
+ async toggle() {
122
+ const next = !this.open();
123
+ this.open.set(next);
124
+ if (next) {
125
+ this._seenCount.set(this.messages().filter((m) => m.from === 'bot' || m.from === 'agent').length);
126
+ await this.ensureSession();
127
+ }
128
+ }
129
+ /** Force the panel open. */
130
+ async openPanel() {
131
+ if (this.open())
132
+ return;
133
+ await this.toggle();
134
+ }
135
+ /** Force the panel closed. Keeps the WS open so reopening is instant. */
136
+ closePanel() {
137
+ this.open.set(false);
138
+ }
139
+ /** Send a visitor message. No-op if the WS isn't connected. */
140
+ sendMessage(content) {
141
+ const trimmed = content.trim();
142
+ if (!trimmed || !this.ws)
143
+ return;
144
+ const clientMsgId = `c_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
145
+ this.messages.update((prev) => [
146
+ ...prev,
147
+ {
148
+ id: clientMsgId,
149
+ client_msg_id: clientMsgId,
150
+ from: 'visitor',
151
+ content: trimmed,
152
+ pending: true,
153
+ },
154
+ ]);
155
+ this.ws.send({ type: 'msg', client_msg_id: clientMsgId, content: trimmed });
156
+ }
157
+ // ── Internals ──────────────────────────────────────────────────────────
158
+ async loadBootstrap() {
159
+ if (!this.isBrowser)
160
+ return null;
161
+ if (this.bootstrap())
162
+ return this.bootstrap();
163
+ if (this.bootstrapPromise)
164
+ return this.bootstrapPromise;
165
+ this.bootstrapPromise = (async () => {
166
+ try {
167
+ const client = this.getClient();
168
+ const cfg = await client.fetchWidgetConfig({
169
+ tenantId: this.config.tenantId,
170
+ widgetPublicKey: this.config.publicKey,
171
+ origin: this.resolvedOrigin(),
172
+ });
173
+ this.bootstrap.set(cfg);
174
+ return cfg;
175
+ }
176
+ catch (err) {
177
+ const msg = err instanceof Error ? err.message : 'unknown error';
178
+ this.fatalError.set(`Bootstrap failed: ${msg}`);
179
+ return null;
180
+ }
181
+ })();
182
+ return this.bootstrapPromise;
183
+ }
184
+ async ensureSession() {
185
+ if (!this.isBrowser)
186
+ return null;
187
+ if (this.session())
188
+ return this.session();
189
+ if (this.sessionPromise)
190
+ return this.sessionPromise;
191
+ this.connecting.set(true);
192
+ this.sessionPromise = (async () => {
193
+ try {
194
+ // Make sure bootstrap is resolved first — the visibility/online
195
+ // flags it carries influence whether we connect at all.
196
+ await this.loadBootstrap();
197
+ const client = this.getClient();
198
+ const session = await client.createVisitorSession({
199
+ tenantId: this.config.tenantId,
200
+ widgetPublicKey: this.config.publicKey,
201
+ origin: this.resolvedOrigin(),
202
+ locale: this.config.locale ?? 'fr',
203
+ metadata: this.config.metadata ?? {},
204
+ });
205
+ this.session.set(session);
206
+ this.openWebSocket(session);
207
+ // Optional: identify the visitor server-side if the consumer wired
208
+ // a token provider.
209
+ this.tryIdentify(session).catch(() => undefined);
210
+ return session;
211
+ }
212
+ catch (err) {
213
+ const msg = err instanceof Error ? err.message : 'unknown error';
214
+ this.fatalError.set(`Session failed: ${msg}`);
215
+ return null;
216
+ }
217
+ finally {
218
+ this.connecting.set(false);
219
+ }
220
+ })();
221
+ return this.sessionPromise;
222
+ }
223
+ openWebSocket(session) {
224
+ if (this.ws)
225
+ return;
226
+ const ws = new WSClient({
227
+ baseUrl: this.config.baseUrl,
228
+ visitorToken: session.visitor_token,
229
+ heartbeatIntervalMs: 30_000,
230
+ }, {
231
+ onWelcome: () => undefined,
232
+ onMessage: (frame) => {
233
+ this.messages.update((prev) => [
234
+ ...prev,
235
+ { id: frame.id, from: frame.from, content: frame.content },
236
+ ]);
237
+ },
238
+ onAck: (frame) => {
239
+ this.messages.update((prev) => prev.map((m) => m.client_msg_id === frame.client_msg_id
240
+ ? { ...m, id: frame.server_msg_id, pending: false }
241
+ : m));
242
+ },
243
+ onTyping: (frame) => {
244
+ if (frame.from === 'bot' || frame.from === 'agent') {
245
+ this.botTyping.set(frame.state === 'start');
246
+ }
247
+ },
248
+ onError: (frame) => {
249
+ this.messages.update((prev) => [
250
+ ...prev,
251
+ { id: `err_${Date.now()}`, from: 'system', content: `Error: ${frame.message}` },
252
+ ]);
253
+ },
254
+ // Phase 10c — agent takeover visibility. Renders a system message
255
+ // and clears any stale bot-typing indicator (the bot's mid-stream
256
+ // turn may have been pre-empted by the operator claim).
257
+ onAgentJoined: (frame) => {
258
+ this.messages.update((prev) => [
259
+ ...prev,
260
+ {
261
+ id: `sys_join_${Date.now()}`,
262
+ from: 'system',
263
+ content: `${frame.agent_display_name} has joined the chat.`,
264
+ },
265
+ ]);
266
+ this.botTyping.set(false);
267
+ },
268
+ onAgentLeft: () => {
269
+ this.messages.update((prev) => [
270
+ ...prev,
271
+ {
272
+ id: `sys_left_${Date.now()}`,
273
+ from: 'system',
274
+ content: 'The agent has left the chat.',
275
+ },
276
+ ]);
277
+ this.botTyping.set(false);
278
+ },
279
+ // Phase 10f — chat_api opened a support ticket.
280
+ 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
+ this.messages.update((prev) => [
287
+ ...prev,
288
+ {
289
+ id: `sys_ticket_${frame.ticket_id}`,
290
+ from: 'system',
291
+ content: `🎫 Support ticket #${frame.ticket_number} created — ` +
292
+ `${frame.category} · ${frame.priority}. ` +
293
+ `We'll follow up ${sla}.`,
294
+ },
295
+ ]);
296
+ },
297
+ });
298
+ this.ws = ws;
299
+ ws.connect();
300
+ }
301
+ async tryIdentify(session) {
302
+ const provider = this.config.visitorTokenProvider;
303
+ if (!provider)
304
+ return;
305
+ let raw;
306
+ const result = provider();
307
+ if (typeof result === 'string') {
308
+ raw = result;
309
+ }
310
+ else if (isObservable(result)) {
311
+ raw = await firstValueFrom(result);
312
+ }
313
+ else {
314
+ raw = await result;
315
+ }
316
+ if (!raw)
317
+ return;
318
+ // Caller's token is `userId:signature` (colon-delimited). Falling back
319
+ // to plain user_id when no colon is present is too easy to misuse —
320
+ // require explicit signature to avoid identity forgery footguns.
321
+ const sep = raw.indexOf(':');
322
+ if (sep < 0) {
323
+ this.fatalError.set('visitorTokenProvider returned a value without "userId:signature" — refusing to identify.');
324
+ return;
325
+ }
326
+ const userId = raw.slice(0, sep);
327
+ const userSignature = raw.slice(sep + 1);
328
+ await this.getClient().identifyVisitor({
329
+ visitorToken: session.visitor_token,
330
+ userId,
331
+ userSignature,
332
+ });
333
+ }
334
+ getClient() {
335
+ if (!this.client) {
336
+ this.client = new ChatClient({ baseUrl: this.config.baseUrl });
337
+ }
338
+ return this.client;
339
+ }
340
+ resolvedOrigin() {
341
+ if (this.config.origin)
342
+ return this.config.origin;
343
+ if (this.isBrowser && typeof window !== 'undefined') {
344
+ return window.location.origin;
345
+ }
346
+ // SSR-safe fallback; the relay validates origins server-side anyway.
347
+ return '';
348
+ }
349
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: BloonioChatService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
350
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: BloonioChatService, providedIn: 'root' });
351
+ }
352
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: BloonioChatService, decorators: [{
353
+ type: Injectable,
354
+ args: [{ providedIn: 'root' }]
355
+ }], ctorParameters: () => [] });
356
+
357
+ /**
358
+ * Floating launcher — circular chat bubble with optional label.
359
+ * Wraps the `BloonioChatService` so consumers just drop
360
+ * `<bloonio-chat-launcher />` somewhere and the SDK handles state.
361
+ */
362
+ class BloonioChatLauncherComponent {
363
+ chat = inject(BloonioChatService);
364
+ position = computed(() => this.chat.bootstrap()?.widget_position ?? 'right');
365
+ /** Tint the bubble with the tenant's brand colour (falls back to the
366
+ * stylesheet default until bootstrap config loads). */
367
+ themeStyle = computed(() => {
368
+ const color = this.chat.bootstrap()?.branding_primary_color;
369
+ return color ? `--bch-primary: ${color}` : '';
370
+ });
371
+ label = computed(() => {
372
+ const b = this.chat.bootstrap();
373
+ return b?.button_label_enabled ? b.button_label_text : null;
374
+ });
375
+ ariaLabel = computed(() => {
376
+ const unread = this.chat.unreadCount();
377
+ return unread > 0
378
+ ? `Open chat (${unread} unread)`
379
+ : 'Open chat';
380
+ });
381
+ onToggle() {
382
+ void this.chat.toggle();
383
+ }
384
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: BloonioChatLauncherComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
385
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: BloonioChatLauncherComponent, isStandalone: true, selector: "bloonio-chat-launcher", ngImport: i0, template: `
386
+ <div class="bch-root" [attr.data-position]="position()" [style]="themeStyle()">
387
+ <button
388
+ type="button"
389
+ class="bch-launcher"
390
+ [attr.aria-label]="ariaLabel()"
391
+ (click)="onToggle()"
392
+ >
393
+ @if (label()) {
394
+ <span class="bch-launcher-label">{{ label() }}</span>
395
+ }
396
+ <span class="bch-launcher-bubble">
397
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
398
+ stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
399
+ <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
+ </svg>
401
+ </span>
402
+ </button>
403
+ </div>
404
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
405
+ }
406
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: BloonioChatLauncherComponent, decorators: [{
407
+ type: Component,
408
+ args: [{
409
+ selector: 'bloonio-chat-launcher',
410
+ standalone: true,
411
+ changeDetection: ChangeDetectionStrategy.OnPush,
412
+ template: `
413
+ <div class="bch-root" [attr.data-position]="position()" [style]="themeStyle()">
414
+ <button
415
+ type="button"
416
+ class="bch-launcher"
417
+ [attr.aria-label]="ariaLabel()"
418
+ (click)="onToggle()"
419
+ >
420
+ @if (label()) {
421
+ <span class="bch-launcher-label">{{ label() }}</span>
422
+ }
423
+ <span class="bch-launcher-bubble">
424
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
425
+ stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
426
+ <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
+ </svg>
428
+ </span>
429
+ </button>
430
+ </div>
431
+ `,
432
+ }]
433
+ }] });
434
+
435
+ /**
436
+ * Visitor input bar — multi-line textarea + send button. Enter sends,
437
+ * Shift+Enter inserts a newline. Emits `send` with the trimmed content
438
+ * once it's non-empty.
439
+ */
440
+ class InputBarComponent {
441
+ disabled = false;
442
+ send = new EventEmitter();
443
+ value = signal('');
444
+ canSend() {
445
+ return !this.disabled && this.value().trim().length > 0;
446
+ }
447
+ onInput(e) {
448
+ this.value.set(e.target.value);
449
+ }
450
+ onKey(e) {
451
+ if (e.key === 'Enter' && !e.shiftKey) {
452
+ e.preventDefault();
453
+ this.submit();
454
+ }
455
+ }
456
+ submit() {
457
+ if (!this.canSend())
458
+ return;
459
+ const trimmed = this.value().trim();
460
+ this.send.emit(trimmed);
461
+ this.value.set('');
462
+ }
463
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: InputBarComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
464
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: InputBarComponent, isStandalone: true, selector: "bloonio-chat-input-bar", inputs: { disabled: "disabled" }, outputs: { send: "send" }, ngImport: i0, template: `
465
+ <div class="bch-input">
466
+ <textarea
467
+ rows="1"
468
+ placeholder="Enter your message..."
469
+ [disabled]="disabled"
470
+ [value]="value()"
471
+ (input)="onInput($event)"
472
+ (keydown)="onKey($event)"
473
+ ></textarea>
474
+ <button
475
+ type="button"
476
+ class="bch-input-send"
477
+ aria-label="Send"
478
+ [disabled]="!canSend()"
479
+ (click)="submit()"
480
+ >
481
+ <svg viewBox="0 0 24 24" fill="currentColor">
482
+ <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
483
+ </svg>
484
+ </button>
485
+ </div>
486
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
487
+ }
488
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: InputBarComponent, decorators: [{
489
+ type: Component,
490
+ args: [{
491
+ selector: 'bloonio-chat-input-bar',
492
+ standalone: true,
493
+ changeDetection: ChangeDetectionStrategy.OnPush,
494
+ template: `
495
+ <div class="bch-input">
496
+ <textarea
497
+ rows="1"
498
+ placeholder="Enter your message..."
499
+ [disabled]="disabled"
500
+ [value]="value()"
501
+ (input)="onInput($event)"
502
+ (keydown)="onKey($event)"
503
+ ></textarea>
504
+ <button
505
+ type="button"
506
+ class="bch-input-send"
507
+ aria-label="Send"
508
+ [disabled]="!canSend()"
509
+ (click)="submit()"
510
+ >
511
+ <svg viewBox="0 0 24 24" fill="currentColor">
512
+ <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
513
+ </svg>
514
+ </button>
515
+ </div>
516
+ `,
517
+ }]
518
+ }], propDecorators: { disabled: [{
519
+ type: Input
520
+ }], send: [{
521
+ type: Output
522
+ }] } });
523
+
524
+ /**
525
+ * Renders the message log + typing indicator. Pure presentational —
526
+ * receives messages + typing flag as inputs so it can also be used
527
+ * outside the panel (custom layouts, debug screens).
528
+ */
529
+ class MessageListComponent {
530
+ messages = [];
531
+ botTyping = false;
532
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MessageListComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
533
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: MessageListComponent, isStandalone: true, selector: "bloonio-chat-message-list", inputs: { messages: "messages", botTyping: "botTyping" }, ngImport: i0, template: `
534
+ @for (m of messages; track m.id) {
535
+ <div
536
+ class="bch-bubble"
537
+ [attr.data-from]="m.from"
538
+ [attr.data-pending]="m.pending ? 'true' : null"
539
+ >
540
+ {{ m.content }}
541
+ </div>
542
+ }
543
+ @if (botTyping) {
544
+ <div class="bch-typing" aria-label="Assistant is typing">
545
+ <span></span><span></span><span></span>
546
+ </div>
547
+ }
548
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
549
+ }
550
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MessageListComponent, decorators: [{
551
+ type: Component,
552
+ args: [{
553
+ selector: 'bloonio-chat-message-list',
554
+ standalone: true,
555
+ changeDetection: ChangeDetectionStrategy.OnPush,
556
+ template: `
557
+ @for (m of messages; track m.id) {
558
+ <div
559
+ class="bch-bubble"
560
+ [attr.data-from]="m.from"
561
+ [attr.data-pending]="m.pending ? 'true' : null"
562
+ >
563
+ {{ m.content }}
564
+ </div>
565
+ }
566
+ @if (botTyping) {
567
+ <div class="bch-typing" aria-label="Assistant is typing">
568
+ <span></span><span></span><span></span>
569
+ </div>
570
+ }
571
+ `,
572
+ }]
573
+ }], propDecorators: { messages: [{
574
+ type: Input,
575
+ args: [{ required: true }]
576
+ }], botTyping: [{
577
+ type: Input
578
+ }] } });
579
+
580
+ /**
581
+ * `<bloonio-chat-quick-replies>` — tenant-configured opening prompt chips,
582
+ * shown under the welcome message until the visitor sends their first
583
+ * message. Tapping a chip sends its text as that first message.
584
+ *
585
+ * Driven by `BloonioChatService` (reads `bootstrap().quick_replies` +
586
+ * `messages()`), so it self-hides once the conversation starts. Used inside
587
+ * both the floating panel and the headless thread.
588
+ */
589
+ class QuickRepliesComponent {
590
+ chat = inject(BloonioChatService);
591
+ /** Show the configured chips only until the visitor's first message. */
592
+ replies = computed(() => {
593
+ const bootstrap = this.chat.bootstrap();
594
+ if (!bootstrap || bootstrap.quick_replies.length === 0)
595
+ return [];
596
+ const visitorSpoke = this.chat.messages().some((m) => m.from === 'visitor');
597
+ return visitorSpoke ? [] : bootstrap.quick_replies;
598
+ });
599
+ pick(text) {
600
+ this.chat.sendMessage(text);
601
+ }
602
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: QuickRepliesComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
603
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: QuickRepliesComponent, isStandalone: true, selector: "bloonio-chat-quick-replies", ngImport: i0, template: `
604
+ @if (replies().length) {
605
+ <div class="bch-quick-replies" role="group" aria-label="Suggested replies">
606
+ @for (reply of replies(); track reply) {
607
+ <button type="button" class="bch-quick-reply" (click)="pick(reply)">
608
+ {{ reply }}
609
+ </button>
610
+ }
611
+ </div>
612
+ }
613
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
614
+ }
615
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: QuickRepliesComponent, decorators: [{
616
+ type: Component,
617
+ args: [{
618
+ selector: 'bloonio-chat-quick-replies',
619
+ standalone: true,
620
+ changeDetection: ChangeDetectionStrategy.OnPush,
621
+ template: `
622
+ @if (replies().length) {
623
+ <div class="bch-quick-replies" role="group" aria-label="Suggested replies">
624
+ @for (reply of replies(); track reply) {
625
+ <button type="button" class="bch-quick-reply" (click)="pick(reply)">
626
+ {{ reply }}
627
+ </button>
628
+ }
629
+ </div>
630
+ }
631
+ `,
632
+ }]
633
+ }] });
634
+
635
+ /**
636
+ * Floating or docked chat panel. Renders the header, message list, input,
637
+ * and footer. Sits inside `<bloonio-chat-launcher>` for the floating
638
+ * variant, or stand-alone for `mode="docked"`/`mode="inline"`.
639
+ */
640
+ class BloonioChatPanelComponent {
641
+ /** floating = paired with launcher (default). docked/inline = always-on
642
+ * inside the page flow (use for SSR landing pages or sidebar layouts). */
643
+ mode = 'floating';
644
+ chat = inject(BloonioChatService);
645
+ messagesRef;
646
+ lastScrollLen = signal(-1);
647
+ position = computed(() => this.chat.bootstrap()?.widget_position ?? 'right');
648
+ visible = computed(() => {
649
+ // Floating mode: only when service.open() is true.
650
+ // Docked/inline: always rendered if bootstrap is loaded.
651
+ if (this.mode === 'floating')
652
+ return this.chat.open();
653
+ return !!this.chat.bootstrap();
654
+ });
655
+ themeStyle = computed(() => {
656
+ const color = this.chat.bootstrap()?.branding_primary_color;
657
+ return color ? `--bch-primary: ${color}` : '';
658
+ });
659
+ displayName = computed(() => this.chat.bootstrap()?.branding_display_name
660
+ ?? this.chat.bootstrap()?.tenant_name
661
+ ?? 'Support');
662
+ logoUrl = computed(() => this.chat.bootstrap()?.branding_logo_url ?? null);
663
+ initials = computed(() => this.displayName()
664
+ .split(/\s+/)
665
+ .slice(0, 2)
666
+ .map((w) => w[0]?.toUpperCase() ?? '')
667
+ .join('') || '?');
668
+ statusText = computed(() => {
669
+ const b = this.chat.bootstrap();
670
+ if (!b)
671
+ return '';
672
+ return b.is_online ? b.online_status_text : b.offline_status_text;
673
+ });
674
+ isOnlineStr = computed(() => String(this.chat.bootstrap()?.is_online ?? false));
675
+ visibleMessages = computed(() => {
676
+ const b = this.chat.bootstrap();
677
+ const messages = this.chat.messages();
678
+ if (b && !b.is_online && messages.length === 0) {
679
+ return [
680
+ {
681
+ id: 'sys_offline',
682
+ from: 'system',
683
+ content: b.offline_message,
684
+ },
685
+ ];
686
+ }
687
+ return messages;
688
+ });
689
+ ngAfterViewChecked() {
690
+ // Scroll-to-bottom whenever message count changes. We compare against a
691
+ // stamped length so we don't churn when only typing/status updates fire.
692
+ const el = this.messagesRef?.nativeElement;
693
+ if (!el)
694
+ return;
695
+ const len = this.visibleMessages().length + (this.chat.botTyping() ? 1 : 0);
696
+ if (len !== this.lastScrollLen()) {
697
+ el.scrollTop = el.scrollHeight;
698
+ this.lastScrollLen.set(len);
699
+ }
700
+ }
701
+ onClose() {
702
+ this.chat.closePanel();
703
+ }
704
+ onSend(content) {
705
+ this.chat.sendMessage(content);
706
+ }
707
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: BloonioChatPanelComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
708
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: BloonioChatPanelComponent, isStandalone: true, selector: "bloonio-chat-panel", inputs: { mode: "mode" }, viewQueries: [{ propertyName: "messagesRef", first: true, predicate: ["messages"], descendants: true }], ngImport: i0, template: `
709
+ <div
710
+ class="bch-root"
711
+ [class.bch-open]="visible()"
712
+ [attr.data-position]="position()"
713
+ [attr.data-mode]="mode"
714
+ >
715
+ <div class="bch-panel" [style]="themeStyle()">
716
+ <header class="bch-header">
717
+ <div class="bch-header-avatar">
718
+ @if (logoUrl()) {
719
+ <img [src]="logoUrl()" alt="" />
720
+ } @else {
721
+ <span style="font-weight:600;font-size:15px">{{ initials() }}</span>
722
+ }
723
+ </div>
724
+ <div class="bch-header-title">
725
+ <div class="bch-header-name">Chat with {{ displayName() }}</div>
726
+ <div class="bch-header-status">
727
+ <span class="bch-header-status-dot" [attr.data-online]="isOnlineStr()"></span>
728
+ {{ statusText() }}
729
+ </div>
730
+ </div>
731
+ <button
732
+ type="button"
733
+ class="bch-header-close"
734
+ aria-label="Close"
735
+ (click)="onClose()"
736
+ >
737
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
738
+ stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
739
+ <line x1="18" y1="6" x2="6" y2="18" />
740
+ <line x1="6" y1="6" x2="18" y2="18" />
741
+ </svg>
742
+ </button>
743
+ </header>
744
+
745
+ <div class="bch-messages" #messages>
746
+ <bloonio-chat-message-list
747
+ [messages]="visibleMessages()"
748
+ [botTyping]="chat.botTyping()"
749
+ />
750
+ </div>
751
+
752
+ <bloonio-chat-quick-replies />
753
+
754
+ <bloonio-chat-input-bar
755
+ [disabled]="chat.connecting()"
756
+ (send)="onSend($event)"
757
+ />
758
+
759
+ <div class="bch-footer">
760
+ Powered by
761
+ <a href="https://bloonio.com" target="_blank" rel="noopener noreferrer">Bloonio</a>
762
+ </div>
763
+ </div>
764
+ </div>
765
+ `, 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
+ }
767
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: BloonioChatPanelComponent, decorators: [{
768
+ type: Component,
769
+ args: [{
770
+ selector: 'bloonio-chat-panel',
771
+ standalone: true,
772
+ imports: [InputBarComponent, MessageListComponent, QuickRepliesComponent],
773
+ changeDetection: ChangeDetectionStrategy.OnPush,
774
+ template: `
775
+ <div
776
+ class="bch-root"
777
+ [class.bch-open]="visible()"
778
+ [attr.data-position]="position()"
779
+ [attr.data-mode]="mode"
780
+ >
781
+ <div class="bch-panel" [style]="themeStyle()">
782
+ <header class="bch-header">
783
+ <div class="bch-header-avatar">
784
+ @if (logoUrl()) {
785
+ <img [src]="logoUrl()" alt="" />
786
+ } @else {
787
+ <span style="font-weight:600;font-size:15px">{{ initials() }}</span>
788
+ }
789
+ </div>
790
+ <div class="bch-header-title">
791
+ <div class="bch-header-name">Chat with {{ displayName() }}</div>
792
+ <div class="bch-header-status">
793
+ <span class="bch-header-status-dot" [attr.data-online]="isOnlineStr()"></span>
794
+ {{ statusText() }}
795
+ </div>
796
+ </div>
797
+ <button
798
+ type="button"
799
+ class="bch-header-close"
800
+ aria-label="Close"
801
+ (click)="onClose()"
802
+ >
803
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
804
+ stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
805
+ <line x1="18" y1="6" x2="6" y2="18" />
806
+ <line x1="6" y1="6" x2="18" y2="18" />
807
+ </svg>
808
+ </button>
809
+ </header>
810
+
811
+ <div class="bch-messages" #messages>
812
+ <bloonio-chat-message-list
813
+ [messages]="visibleMessages()"
814
+ [botTyping]="chat.botTyping()"
815
+ />
816
+ </div>
817
+
818
+ <bloonio-chat-quick-replies />
819
+
820
+ <bloonio-chat-input-bar
821
+ [disabled]="chat.connecting()"
822
+ (send)="onSend($event)"
823
+ />
824
+
825
+ <div class="bch-footer">
826
+ Powered by
827
+ <a href="https://bloonio.com" target="_blank" rel="noopener noreferrer">Bloonio</a>
828
+ </div>
829
+ </div>
830
+ </div>
831
+ `,
832
+ }]
833
+ }], propDecorators: { mode: [{
834
+ type: Input
835
+ }], messagesRef: [{
836
+ type: ViewChild,
837
+ args: ['messages']
838
+ }] } });
839
+
840
+ /**
841
+ * `<bloonio-chat-thread>` — headless variant of the panel. Renders just
842
+ * the message log + input, no header, no floating chrome. Used when the
843
+ * tenant wants to embed the chat *inside* their own layout (e.g. inside
844
+ * a support modal, a sidebar, a help center page).
845
+ *
846
+ * Theming and behaviour are otherwise identical — the same
847
+ * `BloonioChatService` drives state, so a thread and a panel side-by-side
848
+ * stay in sync.
849
+ */
850
+ class BloonioChatThreadComponent {
851
+ chat = inject(BloonioChatService);
852
+ messagesRef;
853
+ lastScrollLen = signal(-1);
854
+ themeStyle = computed(() => {
855
+ const color = this.chat.bootstrap()?.branding_primary_color;
856
+ return color ? `--bch-primary: ${color}` : '';
857
+ });
858
+ visibleMessages = computed(() => {
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
+ });
872
+ ngAfterViewChecked() {
873
+ const el = this.messagesRef?.nativeElement;
874
+ if (!el)
875
+ return;
876
+ const len = this.visibleMessages().length + (this.chat.botTyping() ? 1 : 0);
877
+ if (len !== this.lastScrollLen()) {
878
+ el.scrollTop = el.scrollHeight;
879
+ this.lastScrollLen.set(len);
880
+ }
881
+ }
882
+ /** Convenience: thread is meant to be always-on, so it opens the
883
+ * service-level session as soon as it's instantiated.
884
+ * (Floating panel waits for the launcher click.) */
885
+ constructor() {
886
+ void this.chat.openPanel();
887
+ }
888
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: BloonioChatThreadComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
889
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: BloonioChatThreadComponent, isStandalone: true, selector: "bloonio-chat-thread", viewQueries: [{ propertyName: "messagesRef", first: true, predicate: ["messages"], descendants: true }], ngImport: i0, template: `
890
+ <div class="bch-root" data-mode="inline" [style]="themeStyle()">
891
+ <div class="bch-panel">
892
+ <div class="bch-messages" #messages>
893
+ <bloonio-chat-message-list
894
+ [messages]="visibleMessages()"
895
+ [botTyping]="chat.botTyping()"
896
+ />
897
+ </div>
898
+ <bloonio-chat-quick-replies />
899
+ <bloonio-chat-input-bar
900
+ [disabled]="chat.connecting()"
901
+ (send)="chat.sendMessage($event)"
902
+ />
903
+ </div>
904
+ </div>
905
+ `, 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
+ }
907
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: BloonioChatThreadComponent, decorators: [{
908
+ type: Component,
909
+ args: [{
910
+ selector: 'bloonio-chat-thread',
911
+ standalone: true,
912
+ imports: [InputBarComponent, MessageListComponent, QuickRepliesComponent],
913
+ changeDetection: ChangeDetectionStrategy.OnPush,
914
+ template: `
915
+ <div class="bch-root" data-mode="inline" [style]="themeStyle()">
916
+ <div class="bch-panel">
917
+ <div class="bch-messages" #messages>
918
+ <bloonio-chat-message-list
919
+ [messages]="visibleMessages()"
920
+ [botTyping]="chat.botTyping()"
921
+ />
922
+ </div>
923
+ <bloonio-chat-quick-replies />
924
+ <bloonio-chat-input-bar
925
+ [disabled]="chat.connecting()"
926
+ (send)="chat.sendMessage($event)"
927
+ />
928
+ </div>
929
+ </div>
930
+ `,
931
+ }]
932
+ }], ctorParameters: () => [], propDecorators: { messagesRef: [{
933
+ type: ViewChild,
934
+ args: ['messages']
935
+ }] } });
936
+
937
+ /**
938
+ * @bloonio/chat-angular — Angular library for the bloonio chat PaaS.
939
+ *
940
+ * Public surface:
941
+ * - {@link provideBloonioChat} — environment-provider factory for app.config.ts
942
+ * - {@link BloonioChatService} — signals-based reactive state
943
+ * - Three standalone components:
944
+ * <bloonio-chat-launcher> floating bubble (collapsed)
945
+ * <bloonio-chat-panel> expandable card (the full chat UX)
946
+ * <bloonio-chat-thread> headless variant for inline embedding
947
+ *
948
+ * Styles ship separately at `@bloonio/chat-angular/styles.css` — import once
949
+ * in your global stylesheet (see README).
950
+ */
951
+ const VERSION = '0.1.0';
952
+
953
+ /**
954
+ * Generated bundle index. Do not edit.
955
+ */
956
+
957
+ export { BLOONIO_CHAT_CONFIG, BloonioChatLauncherComponent, BloonioChatPanelComponent, BloonioChatService, BloonioChatThreadComponent, InputBarComponent, MessageListComponent, QuickRepliesComponent, VERSION, injectBloonioChatConfig, provideBloonioChat };
958
+ //# sourceMappingURL=bloonio-chat-angular.mjs.map