@chat21/chat21-web-widget 5.1.34-rc1 → 5.2.1

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.
Files changed (55) hide show
  1. package/.github/workflows/docker-community-push-latest.yml +13 -23
  2. package/.github/workflows/docker-image-tag-community-tag-push.yml +12 -22
  3. package/CHANGELOG.md +22 -118
  4. package/Dockerfile +4 -4
  5. package/README.md +1 -1
  6. package/docs/ACCESSIBILITY-STATEMENT.md +388 -0
  7. package/docs/TILEDESK_WIDGET_ACCESSIBILITY_ALIGNMENT.md +60 -0
  8. package/docs/TILEDESK_WIDGET_ACCESSIBILITY_STATEMENT_COMPLETE.md +386 -0
  9. package/docs/changelog/this-branch.md +0 -36
  10. package/nginx.conf +2 -22
  11. package/package.json +1 -1
  12. package/src/app/app.component.ts +9 -10
  13. package/src/app/component/conversation-detail/conversation/conversation.component.html +2 -2
  14. package/src/app/component/conversation-detail/conversation/conversation.component.scss +2 -2
  15. package/src/app/component/conversation-detail/conversation/conversation.component.ts +16 -34
  16. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.html +3 -3
  17. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +2 -2
  18. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.spec.ts +0 -1
  19. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +52 -63
  20. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +17 -11
  21. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.spec.ts +10 -4
  22. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +5 -8
  23. package/src/app/component/form/inputs/form-text/form-text.component.ts +1 -1
  24. package/src/app/component/last-message/last-message.component.ts +1 -4
  25. package/src/app/component/message/audio-sync/audio-sync.component.spec.ts +17 -8
  26. package/src/app/component/message/audio-sync/audio-sync.component.ts +96 -25
  27. package/src/app/component/message/bubble-message/bubble-message.component.html +12 -9
  28. package/src/app/component/message/bubble-message/bubble-message.component.spec.ts +38 -45
  29. package/src/app/component/message/bubble-message/bubble-message.component.ts +49 -45
  30. package/src/app/component/message/json-sources/json-sources.component.html +6 -5
  31. package/src/app/component/message/json-sources/json-sources.component.scss +26 -18
  32. package/src/app/component/message/json-sources/json-sources.component.ts +41 -0
  33. package/src/app/providers/global-settings.service.ts +0 -42
  34. package/src/app/providers/json-sources-parser.service.ts +13 -1
  35. package/src/app/providers/translator.service.ts +1 -4
  36. package/src/app/providers/tts-audio-playback-coordinator.service.spec.ts +7 -8
  37. package/src/app/providers/tts-audio-playback-coordinator.service.ts +13 -0
  38. package/src/app/providers/voice/STT&TTS/openai-voice.provider.ts +67 -82
  39. package/src/app/providers/voice/voice.service.spec.ts +35 -35
  40. package/src/app/providers/voice/voice.service.ts +3 -7
  41. package/src/app/sass/_variables.scss +0 -1
  42. package/src/app/utils/globals.ts +2 -8
  43. package/src/assets/i18n/en.json +22 -1
  44. package/src/assets/i18n/es.json +22 -1
  45. package/src/assets/i18n/fr.json +22 -1
  46. package/src/assets/i18n/it.json +22 -1
  47. package/src/assets/twp/index-dev.html +0 -18
  48. package/src/chat21-core/providers/firebase/firebase-init-service.ts +5 -5
  49. package/src/chat21-core/providers/tiledesk/tiledesk-requests.service.ts +1 -1
  50. package/src/chat21-core/utils/utils-message.ts +4 -4
  51. package/src/chat21-core/utils/utils.ts +2 -5
  52. package/src/widget-config-template.json +0 -1
  53. package/src/widget-config.json +28 -30
  54. package/.github/workflows/build.yml +0 -22
  55. package/src/assets/twp/tiledesk_widget_files/widget-css-override-example.css +0 -14
@@ -51,8 +51,11 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
51
51
  private destroyed = false;
52
52
  private playbackRequested = false;
53
53
  private playbackStarted = false;
54
+ private micInterrupted = false;
54
55
  private streamAbort?: AbortController;
55
56
  private mediaSourceObjectUrl?: string;
57
+ private cancelAllSub?: Subscription;
58
+ private micSpeechSub?: Subscription;
56
59
  private stopAllSub?: Subscription;
57
60
  private preemptSub?: Subscription;
58
61
 
@@ -93,6 +96,28 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
93
96
  (this.message?.uid && String(this.message.uid).trim()) ||
94
97
  `tts-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
95
98
 
99
+ // Se l’utente parla al microfono mentre sta ascoltando, interrompi TUTTO (corrente + coda).
100
+ this.micSpeechSub = this.voiceService.speechStart$.subscribe(() => {
101
+ if (this.destroyed) {
102
+ return;
103
+ }
104
+ // interrompi solo se questo messaggio era in riproduzione o in attesa
105
+ if (this.playbackStarted || this.playbackRequested) {
106
+ this.micInterrupted = true;
107
+ this.ttsPlayback.cancelAll();
108
+ this.interruptPlaybackAndRevealText();
109
+ }
110
+ });
111
+
112
+ // Stop globale (es. mic) notificato dal coordinatore: ogni istanza deve fermarsi e mostrare testo intero.
113
+ this.cancelAllSub = this.ttsPlayback.cancelAll$.subscribe(() => {
114
+ if (this.destroyed) {
115
+ return;
116
+ }
117
+ this.micInterrupted = true;
118
+ this.interruptPlaybackAndRevealText();
119
+ });
120
+
96
121
  this.onPlaybackEnded = () => {
97
122
  this.playbackStarted = false;
98
123
  this.cleanupStreaming();
@@ -144,12 +169,19 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
144
169
  this.cdr.detectChanges();
145
170
 
146
171
  setTimeout(() => {
147
- if (this.playbackRequested || this.destroyed) {
172
+ if (this.playbackRequested || this.destroyed || this.micInterrupted) {
173
+ if (this.micInterrupted) {
174
+ this.markAllWordsPast();
175
+ if (this.message) {
176
+ this.message.isJustRecived = false;
177
+ }
178
+ this.cdr.detectChanges();
179
+ }
148
180
  return;
149
181
  }
150
182
  this.playbackRequested = true;
151
183
  this.ttsPlayback.requestStart(this.playbackOwnerId, () => {
152
- if (this.destroyed) {
184
+ if (this.destroyed || this.micInterrupted) {
153
185
  this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
154
186
  return;
155
187
  }
@@ -208,6 +240,8 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
208
240
  this.destroyed = true;
209
241
  this.playbackStarted = false;
210
242
  this.cleanupStreaming();
243
+ this.cancelAllSub?.unsubscribe();
244
+ this.micSpeechSub?.unsubscribe();
211
245
  this.stopAllSub?.unsubscribe();
212
246
  this.stopAllSub = undefined;
213
247
  this.preemptSub?.unsubscribe();
@@ -235,6 +269,31 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
235
269
  }
236
270
  }
237
271
 
272
+ private interruptPlaybackAndRevealText(): void {
273
+ this.playbackStarted = false;
274
+ this.cleanupStreaming();
275
+
276
+ const audio = this.audioRef?.nativeElement;
277
+ if (audio) {
278
+ try {
279
+ audio.pause();
280
+ audio.currentTime = 0;
281
+ } catch {
282
+ /* ignore */
283
+ }
284
+ }
285
+
286
+ // Rimuove se era in coda (o rilascia se era corrente).
287
+ this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
288
+
289
+ // Mostra tutto il testo (niente "future" invisibili).
290
+ this.markAllWordsPast();
291
+ if (this.message) {
292
+ this.message.isJustRecived = false;
293
+ }
294
+ this.cdr.detectChanges();
295
+ }
296
+
238
297
  private startPlayback(audio: HTMLAudioElement): void {
239
298
  const messageSrc = (this.message as any)?.metadata?.src as string | undefined;
240
299
 
@@ -332,8 +391,10 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
332
391
  try {
333
392
  const headers: Record<string, string> = {
334
393
  'Content-Type': 'application/json',
335
- 'Authorization': `${jwt}`
336
394
  };
395
+ if (jwt) {
396
+ headers['Authorization'] = jwt;
397
+ }
337
398
 
338
399
  const response = await fetch(endpoint, {
339
400
  method: 'POST',
@@ -464,8 +525,21 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
464
525
  }
465
526
 
466
527
  private getJwtToken(): string | null {
467
- const token = (this.globals?.tiledeskToken || this.globals?.jwt || '').trim();
468
- return token.length > 0 ? token : null;
528
+ const raw = (this.globals?.tiledeskToken || this.globals?.jwt || '')
529
+ .trim()
530
+ .replace(/^(JWT|Bearer)\s+/i, '')
531
+ .trim();
532
+ return raw.length > 0 ? `JWT ${raw}` : null;
533
+ }
534
+
535
+ /**
536
+ * Extracts the Tiledesk requestId from a Chat21 recipient string.
537
+ * Format: `support-group-<projectId>-<requestId>`
538
+ */
539
+ private parseRequestId(recipient: string): string | null {
540
+ const parts = recipient.split('-');
541
+ if (parts.length < 4) return null;
542
+ return parts.slice(3).join('-') || null;
469
543
  }
470
544
 
471
545
  private getVoiceSettingsBody(): unknown {
@@ -495,8 +569,10 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
495
569
  try {
496
570
  const headers: Record<string, string> = {
497
571
  'Content-Type': 'application/json',
498
- 'Authorization': `${jwt}`
499
572
  };
573
+ if (jwt) {
574
+ headers['Authorization'] = jwt;
575
+ }
500
576
 
501
577
  const response = await fetch(endpoint, {
502
578
  method: 'POST',
@@ -526,30 +602,25 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
526
602
  private fetchFullFileFromEndpoint(audio: HTMLAudioElement, endpoint: string): void {
527
603
  const jwt = this.getJwtToken();
528
604
  const voiceSettings = this.getVoiceSettingsBody();
529
- const requestBody = this.buildTtsRequestBody(voiceSettings, false);
605
+ const requestBody = this.buildTtsRequestBody(voiceSettings);
530
606
  void this.fetchAsBlobAndPlay(audio, endpoint, jwt, requestBody);
531
607
  }
532
608
 
533
- private buildTtsRequestBody(voiceSettings: unknown, streaming = true): unknown {
609
+ private buildTtsRequestBody(voiceSettings: unknown): unknown {
534
610
  const text = this.message?.text ?? '';
535
- if (
536
- voiceSettings &&
537
- typeof voiceSettings === 'object' &&
538
- !Array.isArray(voiceSettings)
539
- ) {
540
- return {
541
- outputFormat: BROWSER_TTS_OUTPUT_FORMAT,
542
- ...(voiceSettings as Record<string, unknown>),
543
- text,
544
- streaming,
545
- };
611
+ const projectId = String(this.globals?.projectid ?? '').trim();
612
+ const requestId = this.parseRequestId(this.globals?.recipientId ?? '');
613
+ const base: Record<string, unknown> = { outputFormat: BROWSER_TTS_OUTPUT_FORMAT, text };
614
+ if (projectId) base['projectId'] = projectId;
615
+ if (requestId) {
616
+ base['requestId'] = requestId;
546
617
  }
547
- return {
548
- voiceSettings,
549
- text,
550
- streaming,
551
- outputFormat: BROWSER_TTS_OUTPUT_FORMAT,
552
- };
618
+ if (voiceSettings && typeof voiceSettings === 'object' && !Array.isArray(voiceSettings)) {
619
+ // Spread provider-specific fields (provider, voiceId, model, language, …) at top level.
620
+ // Keep `text` last so it cannot be overridden by voiceSettings.
621
+ return { ...base, ...(voiceSettings as Record<string, unknown>), text };
622
+ }
623
+ return base;
553
624
  }
554
625
 
555
626
  private markAllWordsPast(): void {
@@ -1,4 +1,4 @@
1
- <div class="bubble-message messages primary-color">
1
+ <div class="bubble-message messages primary-color" *ngIf="hasRenderableContent()">
2
2
  <div>
3
3
 
4
4
  <div *ngIf="messageType(MESSAGE_TYPE_OTHERS, message) && !isSameSender"
@@ -28,12 +28,7 @@
28
28
  [stylesMap]="stylesMap"
29
29
  [translationMap]="translationMap">
30
30
  </chat-audio>
31
-
32
- <!-- Json sources -->
33
- <chat-json-sources *ngIf="jsonSources !== null && jsonSources.length > 0"
34
- [items]="jsonSources"
35
- (onElementRendered)="onElementRenderedFN($event)">
36
- </chat-json-sources>
31
+
37
32
  <!-- TTS player: only when voice proxy is NOT active (avoids double playback)
38
33
  and the message was not already played by the proxy (avoids replay on session end) -->
39
34
  <chat-audio-sync *ngIf="isAudioTTS(message) && !(voiceService.isWssVoiceActive$ | async) && !voiceService.wasProxyHandled(message?.uid)"
@@ -41,6 +36,13 @@
41
36
  [color]="fontColor">
42
37
  </chat-audio-sync>
43
38
 
39
+ <chat-json-sources *ngIf="jsonSources !== null && jsonSources.length > 0"
40
+ [items]="jsonSources"
41
+ [displayFields]="jsonSourcesDisplayFields"
42
+ [backgroundColor]="jsonSourcesBackgroundColor"
43
+ (onElementRendered)="onElementRenderedFN($event)">
44
+ </chat-json-sources>
45
+
44
46
  <!-- Karaoke display for TTS messages while a WSS voice session is active -->
45
47
  <p *ngIf="isAudioTTS(message) && (voiceService.isWssVoiceActive$ | async) && _wssKaraokeWords$"
46
48
  class="wss-karaoke"
@@ -58,6 +60,7 @@
58
60
  [color]="fontColor">
59
61
  </chat-text>
60
62
 
63
+
61
64
  <!-- <chat-frame *ngIf="message.metadata && message.metadata.type && message.metadata.type.includes('video')"
62
65
  [metadata]="message.metadata"
63
66
  [width]="message.metadata.width"
@@ -68,7 +71,7 @@
68
71
  <!-- <div *ngIf="message.type == 'text'"> -->
69
72
 
70
73
  <!-- tooltip="{{message.timestamp | dateAgo}} ({{message.timestamp | date:'shortDate'}} {{message.timestamp | date:'HH:mm:ss'}})" placement="bottom" -->
71
- <div *ngIf="message?.text && (!isAudio(message) && !isAudioTTS(message)) && !isJsonSources(message)" >
74
+ <div *ngIf="(message?.text && !isAudio(message) && !isAudioTTS(message)) && !isJsonSources(message)">
72
75
 
73
76
  <!-- Word-by-word streaming reveal during an active voice session -->
74
77
  <p *ngIf="_isStreaming" class="streaming-text" [style.color]="fontColor">
@@ -78,7 +81,7 @@
78
81
  </p>
79
82
 
80
83
  <!-- [htmlEnabled]="(message?.type==='html')? true : false" -->
81
- <chat-text *ngIf="message?.type !=='html'"
84
+ <chat-text *ngIf="jsonSources === null && message?.type !=='html' && !_isStreaming"
82
85
  [text]="message?.text"
83
86
  [color]="fontColor"
84
87
  (onBeforeMessageRender)="onBeforeMessageRenderFN($event)"
@@ -1,10 +1,10 @@
1
1
  import { NO_ERRORS_SCHEMA } from '@angular/core';
2
2
  import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
3
3
  import { By } from '@angular/platform-browser';
4
- import { of } from 'rxjs';
5
4
  import { MAX_WIDTH_IMAGES, MIN_WIDTH_IMAGES } from 'src/chat21-core/utils/constants';
6
- import { VoiceService } from 'src/app/providers/voice/voice.service';
5
+ import { calcImageSize } from 'src/chat21-core/utils/utils-message';
7
6
  import { JsonSourcesParserService } from 'src/app/providers/json-sources-parser.service';
7
+ import { VoiceService } from 'src/app/providers/voice/voice.service';
8
8
 
9
9
  import { BubbleMessageComponent } from './bubble-message.component';
10
10
 
@@ -12,15 +12,20 @@ describe('BubbleMessageComponent', () => {
12
12
  let component: BubbleMessageComponent;
13
13
  let fixture: ComponentFixture<BubbleMessageComponent>;
14
14
 
15
+ const jsonSourcesParserMock = {
16
+ getUrlPreviewPayload: () => null,
17
+ parseBaseFromMessage: () => null,
18
+ enrichSources: jasmine.createSpy('enrichSources').and.resolveTo([]),
19
+ };
20
+
15
21
  const voiceServiceMock = {
16
22
  isWssVoiceActive: false,
17
23
  markProxyHandled: jasmine.createSpy('markProxyHandled'),
18
- voiceTtsKaraoke$: of({ text: '', words: [], activeIndex: -1 }),
19
- };
20
-
21
- const jsonSourcesParserMock = {
22
- parseBaseFromMessage: jasmine.createSpy('parseBaseFromMessage').and.returnValue(null),
23
- enrichSources: jasmine.createSpy('enrichSources').and.resolveTo(null),
24
+ voiceTtsKaraoke$: {
25
+ pipe: () => ({
26
+ subscribe: () => ({ unsubscribe: () => undefined }),
27
+ }),
28
+ },
24
29
  };
25
30
 
26
31
  const textMessage: any = {
@@ -43,8 +48,8 @@ describe('BubbleMessageComponent', () => {
43
48
  declarations: [BubbleMessageComponent],
44
49
  schemas: [NO_ERRORS_SCHEMA],
45
50
  providers: [
46
- { provide: VoiceService, useValue: voiceServiceMock },
47
51
  { provide: JsonSourcesParserService, useValue: jsonSourcesParserMock },
52
+ { provide: VoiceService, useValue: voiceServiceMock },
48
53
  ],
49
54
  }).compileComponents();
50
55
  }));
@@ -77,59 +82,47 @@ describe('BubbleMessageComponent', () => {
77
82
  expect(textChild.properties.text).toEqual(textMessage.text);
78
83
  });
79
84
 
80
- describe('ngOnChanges', () => {
81
- it('should compute sizeImage from message metadata object', () => {
82
- component.message = {
83
- ...textMessage,
84
- metadata: { width: 100, height: 50 },
85
- };
86
- component.ngOnChanges();
87
- expect(component.sizeImage.width).toBe(100);
85
+ describe('calcImageSize', () => {
86
+ it('should scale down when width exceeds MAX_WIDTH_IMAGES', () => {
87
+ const meta = { width: MAX_WIDTH_IMAGES * 2, height: 100 };
88
+ const s = calcImageSize(meta);
89
+ expect(s.width).toBe(MAX_WIDTH_IMAGES);
88
90
  });
89
91
 
90
- it('should cap width when metadata exceeds MAX_WIDTH_IMAGES (calcImageSize)', () => {
91
- component.message = {
92
- ...textMessage,
93
- metadata: { width: MAX_WIDTH_IMAGES * 2, height: 100 },
94
- };
95
- component.ngOnChanges();
96
- expect(component.sizeImage.width).toBe(MAX_WIDTH_IMAGES);
92
+ it('should apply MIN_WIDTH when thumbnail width is small', () => {
93
+ const meta = { width: 40, height: 80 };
94
+ const s = calcImageSize(meta);
95
+ expect(s.width).toBe(MIN_WIDTH_IMAGES);
97
96
  });
98
97
 
99
- it('should scale up narrow thumbnails when width <= 55 (calcImageSize)', () => {
100
- component.message = {
101
- ...textMessage,
102
- metadata: { width: 40, height: 80 },
103
- };
104
- component.ngOnChanges();
105
- expect(component.sizeImage.width).toBe(MIN_WIDTH_IMAGES);
106
- expect(component.sizeImage.height).toBe(MIN_WIDTH_IMAGES / (40 / 80));
98
+ it('should keep metadata dimensions for mid-sized images', () => {
99
+ const meta = { width: 120, height: 60 };
100
+ const s = calcImageSize(meta);
101
+ expect(s.width).toBe(120);
102
+ expect(s.height).toBe(60);
107
103
  });
108
104
 
109
- it('should keep metadata dimensions for mid-sized images', () => {
110
- component.message = {
111
- ...textMessage,
112
- metadata: { width: 120, height: 60 },
113
- };
114
- component.ngOnChanges();
115
- expect(component.sizeImage.width).toBe(120);
116
- expect(component.sizeImage.height).toBe(60);
105
+ it('should return raw metadata when width branch not matched', () => {
106
+ const s = calcImageSize({ width: undefined, height: 10 });
107
+ expect(s.width).toBeUndefined();
108
+ expect(s.height).toBe(10);
117
109
  });
110
+ });
118
111
 
119
- it('should leave width undefined when metadata has no width (calcImageSize)', () => {
112
+ describe('ngOnChanges', () => {
113
+ it('should compute sizeImage from message metadata object', () => {
120
114
  component.message = {
121
115
  ...textMessage,
122
- metadata: { width: undefined, height: 10 },
116
+ metadata: { width: 100, height: 50 },
123
117
  };
124
118
  component.ngOnChanges();
125
- expect(component.sizeImage.width).toBeUndefined();
126
- expect(component.sizeImage.height).toBe(10);
119
+ expect(component.sizeImage.width).toBe(100);
127
120
  });
128
121
 
129
122
  it('should ignore non-object metadata', () => {
130
123
  component.message = { ...textMessage, metadata: 'x' as any };
131
124
  component.ngOnChanges();
132
- expect(component.sizeImage).toEqual({ width: 0, height: 0 });
125
+ expect(component.sizeImage).toBeUndefined();
133
126
  });
134
127
 
135
128
  it('should derive fullnameColor from fontColor', () => {
@@ -3,13 +3,13 @@ import { Observable, Subscription } from 'rxjs';
3
3
  import { map, startWith } from 'rxjs/operators';
4
4
  import { DomSanitizer } from '@angular/platform-browser';
5
5
  import { MessageModel } from 'src/chat21-core/models/message';
6
- import { MESSAGE_TYPE_MINE, MESSAGE_TYPE_OTHERS, TYPE_MSG_URL_PREVIEW } from 'src/chat21-core/utils/constants';
6
+ import { MESSAGE_TYPE_MINE, MESSAGE_TYPE_OTHERS } from 'src/chat21-core/utils/constants';
7
7
  import { convertColorToRGBA } from 'src/chat21-core/utils/utils';
8
- import { JsonSourcesParserService } from 'src/app/providers/json-sources-parser.service';
9
8
  import { calcImageSize, isAudio, isAudioTTS, isFile, isFrame, isImage, isJsonSources, messageType } from 'src/chat21-core/utils/utils-message';
10
9
  import { getColorBck } from 'src/chat21-core/utils/utils-user';
11
- import { VoiceService } from 'src/app/providers/voice/voice.service';
10
+ import { JsonSourcesParserService, UrlPreviewDisplayFields } from 'src/app/providers/json-sources-parser.service';
12
11
  import { JsonSourceItem } from '../json-sources/json-sources.component';
12
+ import { VoiceService } from 'src/app/providers/voice/voice.service';
13
13
  import { VoiceTtsKaraokeWord } from 'src/app/providers/voice/voice-streaming.types';
14
14
 
15
15
  @Component({
@@ -24,6 +24,7 @@ export class BubbleMessageComponent implements OnInit, OnDestroy {
24
24
  @Input() fontColor: string;
25
25
  @Input() stylesMap: Map<string, string>;
26
26
  @Input() translationMap: Map<string, string>;
27
+
27
28
  /** When true, a newly-arrived bot text message reveals its words one by one. */
28
29
  @Input() streamOnArrival = false;
29
30
  /** One-shot flag: set once in ngOnChanges, never reverts so animation isn't replayed. */
@@ -53,30 +54,6 @@ export class BubbleMessageComponent implements OnInit, OnDestroy {
53
54
  return !!(msg.text && String(msg.text).trim().length > 0);
54
55
  }
55
56
 
56
- readonly isImage = isImage;
57
- readonly isFile = isFile;
58
- readonly isFrame = isFrame;
59
- readonly isAudio = isAudio;
60
- readonly isJsonSources = isJsonSources;
61
- readonly isAudioTTS = isAudioTTS;
62
- readonly messageType = messageType;
63
- readonly convertColorToRGBA = convertColorToRGBA;
64
- readonly MESSAGE_TYPE_MINE = MESSAGE_TYPE_MINE;
65
- readonly MESSAGE_TYPE_OTHERS = MESSAGE_TYPE_OTHERS;
66
-
67
- sizeImage: { width: number; height: number } = { width: 0, height: 0 };
68
- fullnameColor: string = '';
69
- jsonSources: JsonSourceItem[] | null = null;
70
- isUrlPreviewMessage = false;
71
-
72
- private urlPreviewReqId = 0;
73
-
74
- constructor(
75
- public sanitizer: DomSanitizer,
76
- public voiceService: VoiceService,
77
- private jsonSourcesParser: JsonSourcesParserService
78
- ) { }
79
-
80
57
  ngOnInit() {
81
58
  // If this TTS message arrived while the voice proxy was active, mark it so
82
59
  // audio-sync never replays it after the session ends.
@@ -109,6 +86,32 @@ export class BubbleMessageComponent implements OnInit, OnDestroy {
109
86
  this._kSub = undefined;
110
87
  }
111
88
 
89
+ readonly isImage = isImage;
90
+ readonly isFile = isFile;
91
+ readonly isFrame = isFrame;
92
+ readonly isAudio = isAudio;
93
+ readonly isAudioTTS = isAudioTTS;
94
+ readonly isJsonSources = isJsonSources;
95
+ readonly messageType = messageType;
96
+ readonly convertColorToRGBA = convertColorToRGBA;
97
+ readonly MESSAGE_TYPE_MINE = MESSAGE_TYPE_MINE;
98
+ readonly MESSAGE_TYPE_OTHERS = MESSAGE_TYPE_OTHERS;
99
+
100
+ sizeImage: { width: number; height: number };
101
+ fullnameColor: string;
102
+ jsonSources: JsonSourceItem[] | null = null;
103
+ isUrlPreviewMessage = false;
104
+ jsonSourcesDisplayFields?: UrlPreviewDisplayFields;
105
+ jsonSourcesBackgroundColor?: string;
106
+
107
+ private urlPreviewReqId = 0;
108
+
109
+ constructor(
110
+ public sanitizer: DomSanitizer,
111
+ private jsonSourcesParser: JsonSourcesParserService,
112
+ public voiceService: VoiceService,
113
+ ) {}
114
+
112
115
  ngOnChanges(): void {
113
116
  if (this.message?.metadata && typeof this.message.metadata === 'object') {
114
117
  this.sizeImage = calcImageSize(this.message.metadata);
@@ -142,18 +145,27 @@ export class BubbleMessageComponent implements OnInit, OnDestroy {
142
145
  this.message.isJustRecived = false;
143
146
  }
144
147
 
145
- if (this.message?.type !== TYPE_MSG_URL_PREVIEW) {
146
- this.jsonSources = null;
147
- return;
148
- }
148
+ // Reset on every message change: we must not "leak" sources across different messages.
149
+ this.jsonSources = null;
150
+ this.jsonSourcesDisplayFields = undefined;
151
+ this.jsonSourcesBackgroundColor = undefined;
149
152
 
150
153
  // url_preview payload can live on message root OR inside metadata/attributes depending on the integration.
151
- const urlPreviewLike =
152
- this.message?.type === TYPE_MSG_URL_PREVIEW
153
- || this.message?.metadata?.type === TYPE_MSG_URL_PREVIEW
154
- || this.message?.attributes?.type === TYPE_MSG_URL_PREVIEW;
155
- this.isUrlPreviewMessage = !!urlPreviewLike;
156
- if (urlPreviewLike) this.loadJsonSourcesFromUrlPreviewMessage();
154
+ const urlPreviewPayload = this.jsonSourcesParser.getUrlPreviewPayload(this.message);
155
+ this.isUrlPreviewMessage = !!urlPreviewPayload;
156
+ if (urlPreviewPayload) {
157
+ this.jsonSourcesDisplayFields = urlPreviewPayload.displayFields;
158
+ this.jsonSourcesBackgroundColor = urlPreviewPayload.previewBackgroundColor;
159
+ this.loadJsonSourcesFromUrlPreviewMessage();
160
+ }
161
+ }
162
+
163
+ trackWord(_index: number, item: { word: string; index: number }): number {
164
+ return item.index;
165
+ }
166
+
167
+ trackKaraokeWord(index: number): number {
168
+ return index;
157
169
  }
158
170
 
159
171
  private async loadJsonSourcesFromUrlPreviewMessage(): Promise<void> {
@@ -169,14 +181,6 @@ export class BubbleMessageComponent implements OnInit, OnDestroy {
169
181
  this.jsonSources = enriched;
170
182
  }
171
183
 
172
- trackWord(_index: number, item: { word: string; index: number }): number {
173
- return item.index;
174
- }
175
-
176
- trackKaraokeWord(index: number): number {
177
- return index;
178
- }
179
-
180
184
  onBeforeMessageRenderFN(event: any): void {
181
185
  this.onBeforeMessageRender.emit({ message: this.message, sanitizer: this.sanitizer, messageEl: event.messageEl, component: event.component });
182
186
  }
@@ -1,4 +1,5 @@
1
- <div class="sources-panel" *ngIf="items && items.length > 0">
1
+ <div class="sources-panel" *ngIf="items && items.length > 0"
2
+ [style.background]="backgroundColor || null">
2
3
 
3
4
  <div class="sources-header">
4
5
  <div class="sources-favicons">
@@ -18,16 +19,16 @@
18
19
  rel="noopener noreferrer">
19
20
 
20
21
  <div class="source-row__left">
21
- <div class="source-row__title">{{ item.title }}</div>
22
- <div class="source-row__desc" *ngIf="item.description">{{ item.description }}</div>
22
+ <div class="source-row__title">{{ getTitleText(item) }}</div>
23
+ <div class="source-row__desc" *ngIf="isFieldVisible('description') && item.description">{{ item.description }}</div>
23
24
  <div class="source-row__meta">
24
25
  <img class="source-row__favicon" *ngIf="getFavicon(item)" [src]="getFavicon(item)" alt="" loading="lazy" />
25
26
  <span class="source-row__host">{{ getHostname(item) }}</span>
26
27
  </div>
27
28
  </div>
28
29
 
29
- <div class="source-row__thumb" *ngIf="item.image">
30
- <img [src]="item.image" alt="" loading="lazy" (error)="$event.target.closest('.source-row__thumb').remove()" />
30
+ <div class="source-row__thumb" *ngIf="isFieldVisible('image') && item.image">
31
+ <img [src]="getThumbUrl(item)" alt="" loading="lazy" (error)="$event.target.closest('.source-row__thumb').remove()" />
31
32
  </div>
32
33
  </a>
33
34
 
@@ -56,7 +56,7 @@
56
56
  .sources-panel {
57
57
  display: flex;
58
58
  flex-direction: column;
59
- padding: 18px 12px 10px 12px;
59
+ padding: 10px 12px 10px 12px;
60
60
  background: var(--panel-bck);
61
61
  border-radius: 18px;
62
62
  font-size: 14px;
@@ -79,15 +79,14 @@
79
79
  width: 100%;
80
80
  box-sizing: border-box;
81
81
 
82
- border-bottom: 1px solid var(--row-sep);
83
- border-bottom-left-radius: 0;
84
- border-bottom-right-radius: 0;
82
+ &:not(:last-of-type) {
83
+ border-bottom: 1px solid var(--row-sep);
84
+ border-bottom-left-radius: 0;
85
+ border-bottom-right-radius: 0;
86
+ }
85
87
 
86
88
  &:hover {
87
89
  background: rgba(255, 255, 255, 0.55);
88
- .source-row__title {
89
- text-decoration: underline;
90
- }
91
90
  }
92
91
 
93
92
  // & + & {
@@ -105,31 +104,40 @@
105
104
  }
106
105
 
107
106
  .source-row__title {
107
+ font-size: 14px;
108
+ color: #0a0a0a;
108
109
  font-weight: 500;
109
- color: var(--text);
110
- font-size: var(--title-font-size);
110
+ font-family: Arial, sans-serif;
111
+ white-space: normal;
112
+ -webkit-font-smoothing: auto;
113
+ font-style: normal;
114
+ text-decoration: none;
111
115
  line-height: var(--title-line-height);
112
116
  margin: 0px;
113
117
  min-width: 0;
114
118
  overflow: hidden;
115
119
  text-overflow: ellipsis;
116
120
  display: -webkit-box;
117
- line-clamp: 2;
118
121
  -webkit-line-clamp: 2;
119
122
  -webkit-box-orient: vertical;
120
- white-space: normal;
121
123
  }
122
124
 
123
125
  .source-row__desc {
124
- font-size: var(--desc-font-size);
125
- line-height: var(--desc-line-height);
126
+ font-size: 12px;
127
+ color: #56595e;
128
+ /* font-weight: bold; */
129
+ font-family: Arial, sans-serif;
130
+ white-space: normal;
131
+ /* -webkit-font-smoothing: auto; */
132
+ font-style: normal;
133
+ text-decoration: none;
134
+ line-height: 1.3;
126
135
  overflow: hidden;
127
136
  text-overflow: ellipsis;
128
137
  display: -webkit-box;
129
138
  line-clamp: 2;
130
139
  -webkit-line-clamp: 2;
131
140
  -webkit-box-orient: vertical;
132
- color: #56595e;
133
141
  margin: 0;
134
142
  }
135
143
 
@@ -144,11 +152,11 @@
144
152
  .source-row__favicon {
145
153
  width: 16px;
146
154
  height: 16px;
147
- border-radius: 50%;
155
+ // border-radius: 50%;
148
156
  flex: 0 0 auto;
149
- border: 2px solid #fff;
150
- background: rgba(255, 255, 255, 0.7);
151
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
157
+ // border: 2px solid #fff;
158
+ // background: rgba(255, 255, 255, 0.7);
159
+ // box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
152
160
  }
153
161
 
154
162
  .source-row__host {