@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.
- package/.github/workflows/docker-community-push-latest.yml +13 -23
- package/.github/workflows/docker-image-tag-community-tag-push.yml +12 -22
- package/CHANGELOG.md +22 -118
- package/Dockerfile +4 -4
- package/README.md +1 -1
- package/docs/ACCESSIBILITY-STATEMENT.md +388 -0
- package/docs/TILEDESK_WIDGET_ACCESSIBILITY_ALIGNMENT.md +60 -0
- package/docs/TILEDESK_WIDGET_ACCESSIBILITY_STATEMENT_COMPLETE.md +386 -0
- package/docs/changelog/this-branch.md +0 -36
- package/nginx.conf +2 -22
- package/package.json +1 -1
- package/src/app/app.component.ts +9 -10
- package/src/app/component/conversation-detail/conversation/conversation.component.html +2 -2
- package/src/app/component/conversation-detail/conversation/conversation.component.scss +2 -2
- package/src/app/component/conversation-detail/conversation/conversation.component.ts +16 -34
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.html +3 -3
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +2 -2
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.spec.ts +0 -1
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +52 -63
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +17 -11
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.spec.ts +10 -4
- package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +5 -8
- package/src/app/component/form/inputs/form-text/form-text.component.ts +1 -1
- package/src/app/component/last-message/last-message.component.ts +1 -4
- package/src/app/component/message/audio-sync/audio-sync.component.spec.ts +17 -8
- package/src/app/component/message/audio-sync/audio-sync.component.ts +96 -25
- package/src/app/component/message/bubble-message/bubble-message.component.html +12 -9
- package/src/app/component/message/bubble-message/bubble-message.component.spec.ts +38 -45
- package/src/app/component/message/bubble-message/bubble-message.component.ts +49 -45
- package/src/app/component/message/json-sources/json-sources.component.html +6 -5
- package/src/app/component/message/json-sources/json-sources.component.scss +26 -18
- package/src/app/component/message/json-sources/json-sources.component.ts +41 -0
- package/src/app/providers/global-settings.service.ts +0 -42
- package/src/app/providers/json-sources-parser.service.ts +13 -1
- package/src/app/providers/translator.service.ts +1 -4
- package/src/app/providers/tts-audio-playback-coordinator.service.spec.ts +7 -8
- package/src/app/providers/tts-audio-playback-coordinator.service.ts +13 -0
- package/src/app/providers/voice/STT&TTS/openai-voice.provider.ts +67 -82
- package/src/app/providers/voice/voice.service.spec.ts +35 -35
- package/src/app/providers/voice/voice.service.ts +3 -7
- package/src/app/sass/_variables.scss +0 -1
- package/src/app/utils/globals.ts +2 -8
- package/src/assets/i18n/en.json +22 -1
- package/src/assets/i18n/es.json +22 -1
- package/src/assets/i18n/fr.json +22 -1
- package/src/assets/i18n/it.json +22 -1
- package/src/assets/twp/index-dev.html +0 -18
- package/src/chat21-core/providers/firebase/firebase-init-service.ts +5 -5
- package/src/chat21-core/providers/tiledesk/tiledesk-requests.service.ts +1 -1
- package/src/chat21-core/utils/utils-message.ts +4 -4
- package/src/chat21-core/utils/utils.ts +2 -5
- package/src/widget-config-template.json +0 -1
- package/src/widget-config.json +28 -30
- package/.github/workflows/build.yml +0 -22
- 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
|
|
468
|
-
|
|
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
|
|
605
|
+
const requestBody = this.buildTtsRequestBody(voiceSettings);
|
|
530
606
|
void this.fetchAsBlobAndPlay(audio, endpoint, jwt, requestBody);
|
|
531
607
|
}
|
|
532
608
|
|
|
533
|
-
private buildTtsRequestBody(voiceSettings: unknown
|
|
609
|
+
private buildTtsRequestBody(voiceSettings: unknown): unknown {
|
|
534
610
|
const text = this.message?.text ?? '';
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
) {
|
|
540
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
text
|
|
550
|
-
|
|
551
|
-
|
|
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 &&
|
|
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 {
|
|
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$:
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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('
|
|
81
|
-
it('should
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
112
|
+
describe('ngOnChanges', () => {
|
|
113
|
+
it('should compute sizeImage from message metadata object', () => {
|
|
120
114
|
component.message = {
|
|
121
115
|
...textMessage,
|
|
122
|
-
metadata: { width:
|
|
116
|
+
metadata: { width: 100, height: 50 },
|
|
123
117
|
};
|
|
124
118
|
component.ngOnChanges();
|
|
125
|
-
expect(component.sizeImage.width).
|
|
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).
|
|
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
|
|
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 {
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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:
|
|
125
|
-
|
|
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 {
|