@chat21/chat21-web-widget 5.1.32-rc3 → 5.1.32-rc8

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 (22) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/package.json +1 -1
  3. package/src/app/app.module.ts +2 -0
  4. package/src/app/component/conversation-detail/conversation/conversation.component.html +2 -1
  5. package/src/app/component/conversation-detail/conversation/conversation.component.scss +10 -0
  6. package/src/app/component/conversation-detail/conversation/conversation.component.ts +11 -0
  7. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.html +2 -2
  8. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +9 -0
  9. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +4 -18
  10. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +12 -51
  11. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +20 -26
  12. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.html +19 -0
  13. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.scss +26 -0
  14. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.ts +62 -0
  15. package/src/app/component/message/audio-sync/audio-sync.component.html +0 -1
  16. package/src/app/component/message/audio-sync/audio-sync.component.ts +352 -16
  17. package/src/app/providers/tts-audio-playback-coordinator.service.ts +71 -0
  18. package/src/app/providers/voice/voice.service.ts +34 -4
  19. package/src/app/sass/_variables.scss +2 -0
  20. package/src/launch.js +41 -32
  21. package/src/launch_template.js +41 -32
  22. package/deploy_amazon_prod.sh +0 -41
package/CHANGELOG.md CHANGED
@@ -6,10 +6,27 @@
6
6
  ### **Copyrigth**:
7
7
  *Tiledesk SRL*
8
8
 
9
+ # 5.1.32-rc8
10
+ - **changed**: updated the dev environment defaults to align with the stage setup (remote config URL, API endpoints, logging level, storage prefix, and related settings)
11
+
12
+ # 5.1.32-rc7
13
+ - **added**: `StreamAudioSpectrum` component for audio visualization in the streaming footer UI
14
+ - **added**: TTS playback coordinator queue — ensures TTS messages play sequentially without interrupting the previous one
15
+ - **changed**: `chat-audio-sync` — updated TTS audio handling to support streaming playback and improved autoplay/animation timing
16
+ - **changed**: iframe loader (`launch.js`, `launch_template.js`) — streamlined loading logic and improved error handling, with fixes for localhost environments
17
+
18
+ # 5.1.32-rc4
19
+ - **added**: “Close stream” control (`.close-stream-button`) — content and sheet bottom offset in fullscreen using `--chat-footer-stream-button-height` only while the stream is listening (`isStreamAudioActive`); variables in `_variables.scss`.
20
+ - **added**: `VoiceService.discardCurrentRecordingSegment()` — when a message arrives from another sender during streaming, the current WebM segment is discarded (no upload) without stopping mic/VAD; `interruptStreamDueToPeerMessage()` in the footer no longer clears `isStreamAudioActive`.
21
+ - **changed**: `#streamAudioAlert` — band above the footer with a frosted-glass look (`backdrop-filter`, semi-transparent `color-mix`).
22
+
9
23
  # 5.1.32-rc3
24
+ - **changed**: `nginx.conf` (Docker image) — explicit MIME types for `.mjs`, `.wasm`, `.onnx` and `default_type` at `http` level (avoids `text/plain` on ONNX/VAD modules behind containerized deploys).
25
+ - **chore**: removed deprecated Amazon beta/prod deploy scripts from the repository.
10
26
 
11
27
  # 5.1.32-rc2
12
28
  - **bug fixed**: minor streaming icon UI fixed
29
+ - **changed**: Refactor stream audio button UI in the conversation footer (layout / classes).
13
30
 
14
31
  # 5.1.32-rc1
15
32
  - **added**: Voice pipeline — VAD (`@ricky0123/vad-web`) with ONNX Runtime WASM served from `/assets/onnx` (`copy-onnx-wasm`), `VoiceService` with `audioSegment$` (WebM segments) and optional STT/TTS via unified OpenAI provider using `HttpClient`, transcript / error fields on segment payloads.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@chat21/chat21-web-widget",
3
3
  "author": "Tiledesk SRL",
4
- "version": "5.1.32-rc3",
4
+ "version": "5.1.32-rc8",
5
5
  "license": "MIT",
6
6
  "homepage": "https://www.tiledesk.com",
7
7
  "repository": {
@@ -16,6 +16,7 @@ import { ConversationFooterComponent } from './component/conversation-detail/con
16
16
  import { ConversationInternalFrameComponent } from './component/conversation-detail/conversation-internal-frame/conversation-internal-frame.component';
17
17
  import { ConversationPreviewComponent } from './component/conversation-detail/conversation-preview/conversation-preview.component';
18
18
  import { ConversationAudioRecorderComponent } from './component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component';
19
+ import { StreamAudioSpectrumComponent } from './component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component';
19
20
  /** CONVERSATION-DETAIL COMPONENTS */
20
21
  import { BubbleMessageComponent } from './component/message/bubble-message/bubble-message.component';
21
22
  import { AvatarComponent } from './component/message/avatar/avatar.component';
@@ -294,6 +295,7 @@ export function uploadFactory(http: HttpClient, appConfig: AppConfigService, app
294
295
  ConversationPreviewComponent,
295
296
  ConversationInternalFrameComponent,
296
297
  ConversationAudioRecorderComponent,
298
+ StreamAudioSpectrumComponent,
297
299
  BubbleMessageComponent,
298
300
  AvatarComponent,
299
301
  FrameComponent,
@@ -4,7 +4,8 @@
4
4
  <div id="chat21-conversation-component"
5
5
  #afConversationComponent
6
6
  tabindex="1500"
7
- aria-modal="true">
7
+ aria-modal="true"
8
+ [class.chat21-conversation--close-stream-active]="closeStreamButtonActiveForSheetBottom()">
8
9
 
9
10
  <!-- HEADER -->
10
11
  <chat-conversation-header
@@ -242,6 +242,16 @@ dialog:-internal-dialog-in-top-layer{
242
242
  ::ng-deep .chat21-sheet-content{
243
243
  bottom: calc(var(--chat-footer-logo-height) + var(--chat-footer-height) + var(--chat-footer-close-button-height) + 34px)!important;
244
244
  }
245
+
246
+ /* Con `.close-stream-button` (stream in ascolto): spazio per alert stream sopra il footer */
247
+ #chat21-conversation-component.chat21-conversation--close-stream-active ::ng-deep .chat21-sheet-content {
248
+ bottom: calc(
249
+ var(--chat-footer-logo-height) +
250
+ var(--chat-footer-height) +
251
+ var(--chat-footer-stream-button-height) +
252
+ 34px
253
+ ) !important;
254
+ }
245
255
 
246
256
  }
247
257
 
@@ -827,6 +827,10 @@ export class ConversationComponent implements OnInit, AfterViewInit, OnChanges {
827
827
  this.showThinkingMessage = false;
828
828
  }
829
829
 
830
+ if (this.isStreamAudioActive && msg.sender !== this.senderId) {
831
+ this.conversationFooter?.interruptStreamDueToPeerMessage();
832
+ }
833
+
830
834
  that.newMessageAdded(msg);
831
835
  // Update badge based on the latest message received from the server.
832
836
  // We rely on `messages` being kept in-sync by the conversation handler.
@@ -1414,6 +1418,13 @@ export class ConversationComponent implements OnInit, AfterViewInit, OnChanges {
1414
1418
  }
1415
1419
  // =========== END: event emitter function ====== //
1416
1420
 
1421
+ /**
1422
+ * True quando è visibile il pulsante chiudi stream (`.close-stream-button`, `isStreamAudioActive`).
1423
+ * Solo in quel caso il bottom del foglio include `--chat-footer-stream-button-height`.
1424
+ */
1425
+ closeStreamButtonActiveForSheetBottom(): boolean {
1426
+ return !!(this.g?.showAudioStreamFooterButton && this.isStreamAudioActive);
1427
+ }
1417
1428
 
1418
1429
  openInputFiles() {
1419
1430
  alert('ok');
@@ -49,7 +49,7 @@
49
49
  <!-- message RECIPIENT:: -->
50
50
  <div role="messaggio" *ngIf="messageType(MESSAGE_TYPE_OTHERS, message)" class="msg_container base_receive">
51
51
 
52
- <chat-avatar-image *ngIf="!isSameSender(message?.sender, i)"
52
+ <chat-avatar-image *ngIf="!isSameSender(message?.sender, i) && !isStreamAudioActive"
53
53
  [ngClass]="{'slide-in-left': false}"
54
54
  [senderID]="message?.sender"
55
55
  [senderFullname]="message?.sender_fullname"
@@ -62,7 +62,7 @@
62
62
  [ngClass]="{'slide-in-left': false}"
63
63
  [class.no-background]="(isImage(message) || isFrame(message) || isCarousel(message)) && ((message?.text && message?.text.trim() === '') || !message?.text)"
64
64
  [class.emoticon]="isEmojii(message?.text)"
65
- [style.margin-left]="isSameSender(message?.sender, i)? 'calc(var(--avatar-width) + 10px)': null"
65
+ [style.margin-left]="isStreamAudioActive ? '0px' : (isSameSender(message?.sender, i) ? 'calc(var(--avatar-width) + 10px)' : null)"
66
66
  [ngStyle]="{'background': stylesMap.get('bubbleReceivedBackground'), 'color': stylesMap.get('bubbleReceivedTextColor'), 'width':isFrame(message) ?'100%' : null}"
67
67
  [isSameSender]="isSameSender(message?.sender, i)"
68
68
  [message]="message"
@@ -270,6 +270,15 @@
270
270
  }// end c21-body-container
271
271
  }// end c21-body
272
272
 
273
+ /* Solo con pulsante chiudi stream (stream in ascolto): altezza extra come #streamAudioAlert */
274
+ :host-context(#chat21-conversation-component.chat21-conversation--close-stream-active) .c21-body .c21-body-container .c21-body-content .chat21-sheet-content {
275
+ bottom: calc(
276
+ var(--chat-footer-logo-height) +
277
+ var(--chat-footer-height) +
278
+ var(--chat-footer-stream-button-height)
279
+ );
280
+ }
281
+
273
282
  @keyframes thinking-dot {
274
283
  0%, 80%, 100% {
275
284
  opacity: 0.2;
@@ -14,24 +14,10 @@
14
14
 
15
15
  <!-- STREAM AUDIO: cerchio con onde animate -->
16
16
  <div id="streamAudioAlert" *ngIf="!hideTextAreaContent && isStreamAudioActive" class="fade-in-bottom stream-audio-alert" [class.hideTextReply]="hideTextReply" role="status" [attr.aria-label]="translationMap?.get('STREAM_AUDIO_LISTENING') || 'Stream audio attivo'">
17
- <div class="stream-audio-alert__orb" [ngStyle]="{ color: stylesMap?.get('themeColor') }">
18
- <svg class="stream-audio-alert__svg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
19
- <circle cx="50" cy="50" r="46" fill="currentColor" opacity="0.14"/>
20
- <g class="stream-audio-alert__waves" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
21
- <g class="stream-audio-alert__wave-layer stream-audio-alert__wave-layer--1">
22
- <path [attr.d]="wavePath1"></path>
23
- </g>
24
-
25
- <g class="stream-audio-alert__wave-layer stream-audio-alert__wave-layer--2">
26
- <path [attr.d]="wavePath2"></path>
27
- </g>
28
-
29
- <g class="stream-audio-alert__wave-layer stream-audio-alert__wave-layer--3">
30
- <path [attr.d]="wavePath3"></path>
31
- </g>
32
- </g>
33
- </svg>
34
- </div>
17
+ <chat-stream-audio-spectrum
18
+ [volume]="currentVolume"
19
+ [accentColor]="stylesMap?.get('themeColor')">
20
+ </chat-stream-audio-spectrum>
35
21
  </div>
36
22
 
37
23
  </div>
@@ -393,67 +393,28 @@ textarea:active{
393
393
  #streamAudioAlert {
394
394
  bottom: 100%;
395
395
  width: 100%;
396
- min-height: 96px;
396
+ min-height: var(--chat-footer-stream-button-height);
397
397
  display: flex;
398
398
  align-items: center;
399
399
  justify-content: center;
400
- background-color: var(--content-background-color);
401
400
  position: absolute;
402
- padding: 10px 0;
401
+ padding: var(--chat-footer-stream-button-padding);
402
+ /* Satinato / vetro: più trasparenza, blur più marcato */
403
+ background-color: color-mix(in srgb, var(--content-background-color) 34%, transparent);
404
+ backdrop-filter: blur(20px) saturate(1.2);
405
+ -webkit-backdrop-filter: blur(20px) saturate(1.2);
406
+ box-shadow:
407
+ inset 0 1px 0 rgba(255, 255, 255, 0.28),
408
+ 0 1px 0 rgba(0, 0, 0, 0.05);
403
409
 
404
410
  &.hideTextReply {
405
411
  position: unset;
406
412
  min-height: auto;
407
413
  padding: 16px 0;
408
414
  box-shadow: none;
409
- }
410
- }
411
-
412
- .stream-audio-alert__orb {
413
- display: flex;
414
- align-items: center;
415
- justify-content: center;
416
- width: 88px;
417
- height: 88px;
418
- border-radius: 50%;
419
- border: 2px solid currentColor;
420
- background: var(--content-background-color);
421
- box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.04);
422
- }
423
-
424
- .stream-audio-alert__svg {
425
- width: 72px;
426
- height: 72px;
427
- display: block;
428
- }
429
-
430
- .stream-audio-alert__wave-layer {
431
- transform-origin: 50px 50px;
432
- transform-box: fill-box;
433
- animation: stream-wave-float 1.35s ease-in-out infinite;
434
- }
435
-
436
- .stream-audio-alert__wave-layer--1 {
437
- animation-delay: 0s;
438
- }
439
-
440
- .stream-audio-alert__wave-layer--2 {
441
- animation-delay: 0.18s;
442
- }
443
-
444
- .stream-audio-alert__wave-layer--3 {
445
- animation-delay: 0.36s;
446
- }
447
-
448
- @keyframes stream-wave-float {
449
- 0%,
450
- 100% {
451
- transform: translateY(0);
452
- opacity: 0.85;
453
- }
454
- 50% {
455
- transform: translateY(-6px);
456
- opacity: 1;
415
+ backdrop-filter: none;
416
+ -webkit-backdrop-filter: none;
417
+ background-color: var(--content-background-color);
457
418
  }
458
419
  }
459
420
 
@@ -97,10 +97,8 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
97
97
  private voiceAudioSubscription?: Subscription;
98
98
  /** Sottoscrizione al volume audio (real-time) dal {@link VoiceService}. */
99
99
  private voiceVolumeSubscription?: Subscription;
100
+ /** Passato a {@link StreamAudioSpectrumComponent} per disegnare la linea spettro. */
100
101
  currentVolume = 0;
101
- wavePath1 = '';
102
- wavePath2 = '';
103
- wavePath3 = '';
104
102
 
105
103
  file_size_limit = FILE_SIZE_LIMIT;
106
104
  attachmentTooltip: string = '';
@@ -118,7 +116,6 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
118
116
  // this.updateAttachmentTooltip();
119
117
  }
120
118
 
121
-
122
119
  ngOnChanges(changes: SimpleChanges){
123
120
  if(changes['conversationWith'] && changes['conversationWith'].currentValue !== undefined){
124
121
  this.conversationHandlerService = this.chatManager.getConversationHandlerByConversationId(this.conversationWith);
@@ -176,40 +173,36 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
176
173
  });
177
174
  this.voiceVolumeSubscription = this.voiceService.volume$.subscribe((volume) => {
178
175
  this.currentVolume = volume;
179
- this.updateWave(volume);
180
176
  });
181
177
  await this.voiceService.startSession();
182
178
  }
183
179
 
184
- async stopVoice() {
180
+ async stopVoice(options?: { discardInProgressSegment?: boolean }) {
185
181
  this.voiceAudioSubscription?.unsubscribe();
186
182
  this.voiceAudioSubscription = undefined;
187
183
 
188
184
  this.voiceVolumeSubscription?.unsubscribe();
189
185
  this.voiceVolumeSubscription = undefined;
190
186
 
191
- await this.voiceService.stopSession();
192
- }
193
-
194
- updateWave(volume: number) {
195
- const intensity = Math.min(volume / 80, 1); // più sensibile
196
-
197
- const amp1 = 4 + intensity * 22;
198
- const amp2 = 2 + intensity * 16;
199
- const amp3 = 1 + intensity * 12;
200
-
201
- this.wavePath1 = this.buildWave(42, amp1);
202
- this.wavePath2 = this.buildWave(50, amp2);
203
- this.wavePath3 = this.buildWave(58, amp3);
187
+ await this.voiceService.stopSession(options);
188
+ this.currentVolume = 0;
204
189
  }
205
190
 
206
- buildWave(y: number, amp: number): string {
207
- return `
208
- M6 ${y}
209
- Q24 ${y - amp} 42 ${y}
210
- T78 ${y}
211
- T98 ${y}
212
- `;
191
+ /**
192
+ * CHIAMATO DA: conversation.component.ts
193
+ * Messaggio in arrivo da un altro mittente mentre lo stream è attivo: scarta solo il segmento
194
+ * registrato in quel momento (nessun upload); mic + VAD restano attivi, `isStreamAudioActive` true.
195
+ */
196
+ interruptStreamDueToPeerMessage(): void {
197
+ if (!this.isStreamAudioActive) {
198
+ return;
199
+ }
200
+ this.logger.log('[CONV-FOOTER] discard recording segment: incoming message from peer (stream stays on)');
201
+ try {
202
+ this.voiceService.discardCurrentRecordingSegment();
203
+ } catch (e) {
204
+ this.logger.error('[CONV-FOOTER] interruptStreamDueToPeerMessage', e);
205
+ }
213
206
  }
214
207
 
215
208
  ngOnDestroy() {
@@ -741,6 +734,7 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
741
734
  const turningOn = !this.isStreamAudioActive;
742
735
  if (turningOn) {
743
736
  try {
737
+ this.currentVolume = 0;
744
738
  await this.initVoice();
745
739
  this.isStreamAudioActive = true;
746
740
  } catch (e) {
@@ -0,0 +1,19 @@
1
+ <div class="stream-audio-spectrum__orb" [ngStyle]="accentColor ? { color: accentColor } : null">
2
+ <svg class="stream-audio-spectrum__svg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
3
+ <defs>
4
+ <linearGradient [attr.id]="gradientId" x1="12" y1="50" x2="88" y2="50" gradientUnits="userSpaceOnUse">
5
+ <stop offset="0%" stop-color="currentColor" stop-opacity="0.45"/>
6
+ <stop offset="50%" stop-color="currentColor" stop-opacity="1"/>
7
+ <stop offset="100%" stop-color="currentColor" stop-opacity="0.45"/>
8
+ </linearGradient>
9
+ </defs>
10
+ <circle cx="50" cy="50" r="46" fill="currentColor" opacity="0.14"/>
11
+ <path class="stream-audio-spectrum__line"
12
+ [attr.d]="spectrumLinePath"
13
+ fill="none"
14
+ [attr.stroke]="'url(#' + gradientId + ')'"
15
+ stroke-width="2.4"
16
+ stroke-linecap="round"
17
+ stroke-linejoin="round"/>
18
+ </svg>
19
+ </div>
@@ -0,0 +1,26 @@
1
+ :host {
2
+ display: block;
3
+ }
4
+
5
+ .stream-audio-spectrum__orb {
6
+ display: flex;
7
+ align-items: center;
8
+ justify-content: center;
9
+ width: 88px;
10
+ height: 88px;
11
+ border-radius: 50%;
12
+ border: 2px solid currentColor;
13
+ background: var(--content-background-color);
14
+ box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.04);
15
+ }
16
+
17
+ .stream-audio-spectrum__svg {
18
+ width: 72px;
19
+ height: 72px;
20
+ display: block;
21
+ }
22
+
23
+ .stream-audio-spectrum__line {
24
+ pointer-events: none;
25
+ filter: drop-shadow(0 0 1px color-mix(in srgb, currentColor 35%, transparent));
26
+ }
@@ -0,0 +1,62 @@
1
+ import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
2
+
3
+ /**
4
+ * Icona stream: cerchio con linea orizzontale tipo spettro, reattiva al volume del microfono.
5
+ * Il parent (es. conversation-footer) aggiorna solo {@link volume} da VoiceService.
6
+ */
7
+ @Component({
8
+ selector: 'chat-stream-audio-spectrum',
9
+ templateUrl: './stream-audio-spectrum.component.html',
10
+ styleUrl: './stream-audio-spectrum.component.scss',
11
+ })
12
+ export class StreamAudioSpectrumComponent implements OnInit, OnChanges {
13
+ private static gradSeq = 0;
14
+ readonly gradientId = `streamSpectrumGrad-${++StreamAudioSpectrumComponent.gradSeq}`;
15
+
16
+ /** Volume normalizzato come emesso da VoiceService (stessa scala del footer). */
17
+ @Input() volume = 0;
18
+ /** Colore tema (stroke / gradient); opzionale. */
19
+ @Input() accentColor?: string;
20
+
21
+ spectrumLinePath = 'M12,50 L88,50';
22
+
23
+ ngOnInit(): void {
24
+ this.refreshPath();
25
+ }
26
+
27
+ ngOnChanges(changes: SimpleChanges): void {
28
+ if (changes['volume']) {
29
+ this.refreshPath();
30
+ }
31
+ }
32
+
33
+ private refreshPath(): void {
34
+ const intensity = Math.min(this.volume / 80, 1);
35
+ const t = Date.now() / 175;
36
+ this.spectrumLinePath = this.buildSpectrumLinePath(intensity, t);
37
+ }
38
+
39
+ private buildSpectrumLinePath(intensity: number, t: number): string {
40
+ const x0 = 12;
41
+ const x1 = 88;
42
+ const cy = 50;
43
+ const segments = 80;
44
+ const amp = 1.2 + intensity * 16;
45
+ const parts: string[] = [];
46
+ for (let i = 0; i <= segments; i++) {
47
+ const p = i / segments;
48
+ const x = x0 + p * (x1 - x0);
49
+ const u = p * Math.PI * 6;
50
+ const wobble =
51
+ Math.sin(u + t) * 0.34 +
52
+ Math.sin(u * 2.35 + t * 1.12) * 0.24 +
53
+ Math.sin(u * 4.2 + t * 0.72) * 0.18 +
54
+ Math.sin(u * 6.8 + t * 1.05) * 0.14 +
55
+ Math.sin(u * 9.1 + t * 0.88) * 0.1;
56
+ const y = cy + amp * wobble;
57
+ const yClamped = Math.min(68, Math.max(32, y));
58
+ parts.push(i === 0 ? `M${x.toFixed(2)},${yClamped.toFixed(2)}` : `L${x.toFixed(2)},${yClamped.toFixed(2)}`);
59
+ }
60
+ return parts.join('');
61
+ }
62
+ }
@@ -2,7 +2,6 @@
2
2
 
3
3
  <audio
4
4
  #audioPlayer
5
- [src]="message?.metadata?.src"
6
5
  (timeupdate)="onTimeUpdate()"
7
6
  style="display:none">
8
7
  </audio>
@@ -10,6 +10,8 @@ import {
10
10
  ViewChild,
11
11
  } from '@angular/core';
12
12
  import { MessageModel } from 'src/chat21-core/models/message';
13
+ import { TtsAudioPlaybackCoordinator } from 'src/app/providers/tts-audio-playback-coordinator.service';
14
+ import { Globals } from 'src/app/utils/globals';
13
15
 
14
16
  /** HAVE_METADATA: metadati già disponibili (tipico audio servito da cache). */
15
17
  const HAVE_METADATA = 1;
@@ -41,7 +43,19 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
41
43
  private onMetadataLoaded: () => void;
42
44
  private onPlaybackEnded: () => void;
43
45
 
44
- constructor(private readonly cdr: ChangeDetectorRef) {}
46
+ /** Id univoco per il coordinatore (di solito `message.uid`). */
47
+ private playbackOwnerId = '';
48
+ private destroyed = false;
49
+ private playbackRequested = false;
50
+ private playbackStarted = false;
51
+ private streamAbort?: AbortController;
52
+ private mediaSourceObjectUrl?: string;
53
+
54
+ constructor(
55
+ private readonly cdr: ChangeDetectorRef,
56
+ private readonly ttsPlayback: TtsAudioPlaybackCoordinator,
57
+ private readonly globals: Globals,
58
+ ) {}
45
59
 
46
60
  /** `false` = messaggio già in storico: niente autoplay / karaoke. Da `message.isJustRecived`. */
47
61
  private get skipSyncAnimation(): boolean {
@@ -53,11 +67,14 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
53
67
  return;
54
68
  }
55
69
  if (this.audioRef?.nativeElement && this.timingReady) {
56
- this.duration = this.audioRef.nativeElement.duration || 1;
70
+ const d = this.audioRef.nativeElement.duration;
71
+ if (Number.isFinite(d) && d > 0) {
72
+ this.duration = d;
73
+ }
57
74
  this.buildFakeTiming();
58
75
  if (this.skipSyncAnimation) {
59
76
  this.markAllWordsPast();
60
- } else {
77
+ } else if (this.playbackStarted) {
61
78
  this.syncStatesFromCurrentTime();
62
79
  }
63
80
  }
@@ -66,7 +83,14 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
66
83
  ngAfterViewInit(): void {
67
84
  const audio = this.audioRef.nativeElement;
68
85
 
86
+ this.playbackOwnerId =
87
+ (this.message?.uid && String(this.message.uid).trim()) ||
88
+ `tts-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
89
+
69
90
  this.onPlaybackEnded = () => {
91
+ this.playbackStarted = false;
92
+ this.cleanupStreaming();
93
+ this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
70
94
  if (this.skipSyncAnimation) {
71
95
  return;
72
96
  }
@@ -78,38 +102,75 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
78
102
  };
79
103
 
80
104
  this.onMetadataLoaded = () => {
81
- if (this.timingReady) {
82
- return;
105
+ // La durata potrebbe arrivare tardi (specie con streaming).
106
+ const d = audio.duration;
107
+ if (Number.isFinite(d) && d > 0) {
108
+ this.duration = d;
109
+ } else if (!this.timingReady) {
110
+ this.duration = this.estimateDurationSecondsFromText();
83
111
  }
112
+
84
113
  this.timingReady = true;
85
- this.duration = audio.duration || 1;
86
114
  this.buildFakeTiming();
87
115
  if (this.skipSyncAnimation) {
88
116
  this.markAllWordsPast();
89
117
  this.cdr.detectChanges();
90
118
  return;
91
119
  }
92
- this.syncStatesFromCurrentTime();
120
+ if (this.playbackStarted) {
121
+ this.syncStatesFromCurrentTime();
122
+ }
93
123
  this.cdr.detectChanges();
94
-
95
- setTimeout(() => {
96
- audio.play().catch(() => {
97
- this.syncStatesFromCurrentTime();
98
- this.cdr.detectChanges();
99
- });
100
- }, 200);
101
124
  };
102
125
 
103
126
  audio.addEventListener('loadedmetadata', this.onMetadataLoaded);
104
127
  audio.addEventListener('ended', this.onPlaybackEnded);
105
128
 
106
- if (audio.readyState >= HAVE_METADATA) {
107
- this.onMetadataLoaded();
129
+ // Prepara subito le parole (durata stimata) e poi aggiorna quando arriva la metadata reale.
130
+ this.duration = this.estimateDurationSecondsFromText();
131
+ this.timingReady = true;
132
+ this.buildFakeTiming();
133
+ if (this.skipSyncAnimation) {
134
+ this.markAllWordsPast();
135
+ this.cdr.detectChanges();
136
+ return;
108
137
  }
138
+ this.cdr.detectChanges();
139
+
140
+ setTimeout(() => {
141
+ if (this.playbackRequested || this.destroyed) {
142
+ return;
143
+ }
144
+ this.playbackRequested = true;
145
+ this.ttsPlayback.requestStart(this.playbackOwnerId, () => {
146
+ if (this.destroyed) {
147
+ this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
148
+ return;
149
+ }
150
+ this.playbackStarted = true;
151
+ this.syncStatesFromCurrentTime();
152
+ this.cdr.detectChanges();
153
+ this.startPlayback(audio);
154
+ });
155
+ }, 200);
109
156
  }
110
157
 
111
158
  ngOnDestroy(): void {
159
+ this.destroyed = true;
160
+ this.playbackStarted = false;
161
+ this.cleanupStreaming();
162
+
112
163
  const audio = this.audioRef?.nativeElement;
164
+ if (audio) {
165
+ try {
166
+ audio.pause();
167
+ audio.currentTime = 0;
168
+ } catch {
169
+ /* ignore */
170
+ }
171
+ }
172
+ this.ttsPlayback.release(this.playbackOwnerId);
173
+
113
174
  if (!audio) {
114
175
  return;
115
176
  }
@@ -121,6 +182,266 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
121
182
  }
122
183
  }
123
184
 
185
+ private startPlayback(audio: HTMLAudioElement): void {
186
+ const src = (this.message as any)?.metadata?.src as string | undefined;
187
+ if (!src) {
188
+ this.playbackStarted = false;
189
+ this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
190
+ this.markAllWordsPast();
191
+ if (this.message) {
192
+ this.message.isJustRecived = false;
193
+ }
194
+ this.cdr.detectChanges();
195
+ return;
196
+ }
197
+
198
+ if (this.message?.type === 'tts') {
199
+ this.startStreamingFromEndpoint(audio, src);
200
+ return;
201
+ }
202
+
203
+ audio.src = src;
204
+ try {
205
+ audio.currentTime = 0;
206
+ } catch {
207
+ /* ignore */
208
+ }
209
+ audio.play().catch(() => this.handlePlaybackError());
210
+ }
211
+
212
+ private startStreamingFromEndpoint(audio: HTMLAudioElement, endpoint: string): void {
213
+ this.cleanupStreaming();
214
+
215
+ const jwt = this.getJwtToken();
216
+ const voiceSettings = this.getVoiceSettingsBody();
217
+ const requestBody = this.buildTtsRequestBody(voiceSettings);
218
+ // <audio src="..."> non può inviare header/body: serve fetch().
219
+ const hasMse = typeof (window as any).MediaSource !== 'undefined';
220
+ if (!hasMse) {
221
+ this.fetchAsBlobAndPlay(audio, endpoint, jwt, requestBody);
222
+ return;
223
+ }
224
+
225
+ const MediaSourceCtor = (window as any).MediaSource as typeof MediaSource;
226
+ const mediaSource = new MediaSourceCtor();
227
+ const objectUrl = URL.createObjectURL(mediaSource);
228
+ this.mediaSourceObjectUrl = objectUrl;
229
+ audio.src = objectUrl;
230
+
231
+ const abort = new AbortController();
232
+ this.streamAbort = abort;
233
+
234
+ const onSourceOpen = async () => {
235
+ mediaSource.removeEventListener('sourceopen', onSourceOpen);
236
+ try {
237
+ const headers: Record<string, string> = {
238
+ 'Content-Type': 'application/json',
239
+ 'Authorization': `${jwt}`
240
+ };
241
+
242
+ const response = await fetch(endpoint, {
243
+ method: 'POST',
244
+ headers,
245
+ body: JSON.stringify(requestBody),
246
+ signal: abort.signal,
247
+ });
248
+ if (!response.ok || !response.body) {
249
+ throw new Error(`TTS stream request failed (${response.status})`);
250
+ }
251
+
252
+ const headerType = (response.headers.get('content-type') || '').split(';')[0].trim();
253
+ const mime = (headerType && MediaSourceCtor.isTypeSupported(headerType))
254
+ ? headerType
255
+ : 'audio/mpeg';
256
+
257
+ if (!MediaSourceCtor.isTypeSupported(mime)) {
258
+ this.cleanupStreaming();
259
+ // Fallback: fetch completo e play via blob (no streaming).
260
+ this.fetchAsBlobAndPlay(audio, endpoint, jwt, requestBody);
261
+ return;
262
+ }
263
+
264
+ const sourceBuffer = mediaSource.addSourceBuffer(mime);
265
+ sourceBuffer.mode = 'sequence';
266
+
267
+ const reader = response.body.getReader();
268
+ const queue: Uint8Array[] = [];
269
+ let doneReading = false;
270
+ let started = false;
271
+
272
+ const tryEndOfStream = () => {
273
+ if (doneReading && queue.length === 0 && !sourceBuffer.updating) {
274
+ try {
275
+ mediaSource.endOfStream();
276
+ } catch {
277
+ /* ignore */
278
+ }
279
+ }
280
+ };
281
+
282
+ const pump = () => {
283
+ if (abort.signal.aborted) {
284
+ return;
285
+ }
286
+ if (sourceBuffer.updating) {
287
+ return;
288
+ }
289
+ const chunk = queue.shift();
290
+ if (!chunk) {
291
+ tryEndOfStream();
292
+ return;
293
+ }
294
+ try {
295
+ const ab = chunk.buffer.slice(
296
+ chunk.byteOffset,
297
+ chunk.byteOffset + chunk.byteLength,
298
+ ) as ArrayBuffer;
299
+ sourceBuffer.appendBuffer(ab);
300
+ } catch {
301
+ this.cleanupStreaming();
302
+ this.fetchAsBlobAndPlay(audio, endpoint, jwt, requestBody);
303
+ }
304
+ };
305
+
306
+ sourceBuffer.addEventListener('updateend', () => {
307
+ if (!started && this.playbackStarted && !this.destroyed) {
308
+ started = true;
309
+ audio.play().catch(() => this.handlePlaybackError());
310
+ }
311
+ pump();
312
+ });
313
+
314
+ // Primo pump (se arrivano subito chunk)
315
+ pump();
316
+
317
+ while (!abort.signal.aborted) {
318
+ const { value, done } = await reader.read();
319
+ if (done) {
320
+ doneReading = true;
321
+ break;
322
+ }
323
+ if (value && value.byteLength > 0) {
324
+ queue.push(value);
325
+ pump();
326
+ }
327
+ }
328
+
329
+ doneReading = true;
330
+ tryEndOfStream();
331
+ } catch {
332
+ if (!abort.signal.aborted) {
333
+ this.handlePlaybackError();
334
+ }
335
+ }
336
+ };
337
+
338
+ mediaSource.addEventListener('sourceopen', onSourceOpen);
339
+ }
340
+
341
+ private handlePlaybackError(): void {
342
+ this.playbackStarted = false;
343
+ this.cleanupStreaming();
344
+ this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
345
+ this.markAllWordsPast();
346
+ if (this.message) {
347
+ this.message.isJustRecived = false;
348
+ }
349
+ this.cdr.detectChanges();
350
+ }
351
+
352
+ private cleanupStreaming(): void {
353
+ try {
354
+ this.streamAbort?.abort();
355
+ } catch {
356
+ /* ignore */
357
+ }
358
+ this.streamAbort = undefined;
359
+
360
+ if (this.mediaSourceObjectUrl) {
361
+ try {
362
+ URL.revokeObjectURL(this.mediaSourceObjectUrl);
363
+ } catch {
364
+ /* ignore */
365
+ }
366
+ this.mediaSourceObjectUrl = undefined;
367
+ }
368
+ }
369
+
370
+ private getJwtToken(): string | null {
371
+ const token = (this.globals?.tiledeskToken || this.globals?.jwt || '').trim();
372
+ return token.length > 0 ? token : null;
373
+ }
374
+
375
+ private getVoiceSettingsBody(): unknown {
376
+ const raw = (this.message as any)?.metadata?.voiceSettings;
377
+ if (raw === null || raw === undefined) {
378
+ return {};
379
+ }
380
+ if (typeof raw === 'string') {
381
+ const s = raw.trim();
382
+ if (!s) return {};
383
+ try {
384
+ return JSON.parse(s);
385
+ } catch {
386
+ // se non è JSON valido, invialo come stringa (il backend può gestirlo)
387
+ return { voiceSettings: raw };
388
+ }
389
+ }
390
+ return raw;
391
+ }
392
+
393
+ private async fetchAsBlobAndPlay(
394
+ audio: HTMLAudioElement,
395
+ endpoint: string,
396
+ jwt: string | null,
397
+ requestBody: unknown,
398
+ ): Promise<void> {
399
+ try {
400
+ const headers: Record<string, string> = {
401
+ 'Content-Type': 'application/json',
402
+ 'Authorization': `${jwt}`
403
+ };
404
+
405
+ console.log('headers', headers);
406
+ console.log('requestBody', requestBody);
407
+
408
+ const response = await fetch(endpoint, {
409
+ method: 'POST',
410
+ headers,
411
+ body: JSON.stringify(requestBody ?? {}),
412
+ signal: this.streamAbort?.signal,
413
+ });
414
+
415
+ if (!response.ok) {
416
+ throw new Error(`TTS request failed (${response.status})`);
417
+ }
418
+
419
+ const blob = await response.blob();
420
+ if (this.destroyed) {
421
+ return;
422
+ }
423
+
424
+ const objectUrl = URL.createObjectURL(blob);
425
+ this.mediaSourceObjectUrl = objectUrl;
426
+ audio.src = objectUrl;
427
+ audio.play().catch(() => this.handlePlaybackError());
428
+ } catch {
429
+ this.handlePlaybackError();
430
+ }
431
+ }
432
+
433
+ private buildTtsRequestBody(voiceSettings: unknown): unknown {
434
+ const text = this.message?.text ?? '';
435
+ if (
436
+ voiceSettings &&
437
+ typeof voiceSettings === 'object' &&
438
+ !Array.isArray(voiceSettings)
439
+ ) {
440
+ return { ...(voiceSettings as Record<string, unknown>), text, streaming: true };
441
+ }
442
+ return { voiceSettings, text, streaming: true };
443
+ }
444
+
124
445
  private markAllWordsPast(): void {
125
446
  this.words.forEach((w) => {
126
447
  w.state = 'past';
@@ -128,6 +449,18 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
128
449
  this.activeIndex = -1;
129
450
  }
130
451
 
452
+ private estimateDurationSecondsFromText(): number {
453
+ const rawWords = (this.message?.text || '')
454
+ .trim()
455
+ .split(/\s+/)
456
+ .filter((w) => w.length > 0);
457
+ if (rawWords.length === 0) {
458
+ return 1;
459
+ }
460
+ // ~140 WPM → ~0.43s/word
461
+ return Math.max(1, rawWords.length * 0.43);
462
+ }
463
+
131
464
  buildFakeTiming(): void {
132
465
  const rawWords = (this.message?.text || '')
133
466
  .trim()
@@ -176,6 +509,9 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
176
509
  }
177
510
 
178
511
  onTimeUpdate(): void {
512
+ if (!this.playbackStarted) {
513
+ return;
514
+ }
179
515
  this.syncStatesFromCurrentTime();
180
516
  }
181
517
 
@@ -0,0 +1,71 @@
1
+ import { Injectable } from '@angular/core';
2
+
3
+ /**
4
+ * Garantisce un solo messaggio TTS in riproduzione alla volta.
5
+ * Se arrivano più messaggi TTS, vengono riprodotti in coda (FIFO) senza interrompere quello corrente.
6
+ */
7
+ @Injectable({ providedIn: 'root' })
8
+ export class TtsAudioPlaybackCoordinator {
9
+ private currentOwnerId: string | null = null;
10
+ private readonly queue: Array<{ ownerId: string; start: () => void }> = [];
11
+
12
+ /**
13
+ * Richiede l'avvio della riproduzione TTS per `ownerId`.
14
+ * Se non c'è nessun TTS attivo, parte subito; altrimenti viene messo in coda.
15
+ */
16
+ requestStart(ownerId: string, start: () => void): void {
17
+ const id = (ownerId || '').trim();
18
+ if (!id) {
19
+ return;
20
+ }
21
+ if (this.currentOwnerId === id) {
22
+ return;
23
+ }
24
+ if (this.queue.some((j) => j.ownerId === id)) {
25
+ return;
26
+ }
27
+ if (this.currentOwnerId) {
28
+ this.queue.push({ ownerId: id, start });
29
+ return;
30
+ }
31
+ this.currentOwnerId = id;
32
+ try {
33
+ start();
34
+ } catch {
35
+ this.releaseIfCurrent(id);
36
+ }
37
+ }
38
+
39
+ /** Chiamare a fine riproduzione naturale (`ended`) se questo messaggio era ancora “attivo”. */
40
+ releaseIfCurrent(ownerId: string): void {
41
+ const id = (ownerId || '').trim();
42
+ if (!id) {
43
+ return;
44
+ }
45
+ if (this.currentOwnerId !== id) {
46
+ // Se era in coda, rimuovilo.
47
+ const idx = this.queue.findIndex((j) => j.ownerId === id);
48
+ if (idx !== -1) {
49
+ this.queue.splice(idx, 1);
50
+ }
51
+ return;
52
+ }
53
+
54
+ this.currentOwnerId = null;
55
+ const next = this.queue.shift();
56
+ if (!next) {
57
+ return;
58
+ }
59
+ this.currentOwnerId = next.ownerId;
60
+ try {
61
+ next.start();
62
+ } catch {
63
+ this.releaseIfCurrent(next.ownerId);
64
+ }
65
+ }
66
+
67
+ /** Distruzione componente o stop esplicito. */
68
+ release(ownerId: string): void {
69
+ this.releaseIfCurrent(ownerId);
70
+ }
71
+ }
@@ -100,9 +100,20 @@ export class VoiceService {
100
100
  this.startVolumeLoop();
101
101
  }
102
102
 
103
- async stopSession(): Promise<void> {
104
- if (this.mediaRecorder?.state === 'recording') {
105
- this.mediaRecorder.stop();
103
+ /**
104
+ * @param options.discardInProgressSegment — non inviare STT/upload per il segmento WebM corrente (es. interruzione da messaggio in arrivo).
105
+ */
106
+ async stopSession(options?: { discardInProgressSegment?: boolean }): Promise<void> {
107
+ const discard = options?.discardInProgressSegment === true;
108
+
109
+ if (this.mediaRecorder) {
110
+ if (discard) {
111
+ this.mediaRecorder.onstop = null;
112
+ this.mediaRecorder.ondataavailable = null;
113
+ }
114
+ if (this.mediaRecorder.state === 'recording') {
115
+ this.mediaRecorder.stop();
116
+ }
106
117
  }
107
118
 
108
119
  this.mediaRecorder = undefined;
@@ -134,6 +145,23 @@ export class VoiceService {
134
145
  this.onRecordingComplete = undefined;
135
146
  }
136
147
 
148
+ /**
149
+ * Scarta il segmento WebM in corso (nessun upload/STT) senza chiudere VAD, mic o sessione.
150
+ * Lo stream resta in ascolto per il prossimo `onSpeechStart`.
151
+ */
152
+ discardCurrentRecordingSegment(): void {
153
+ if (this.mediaRecorder) {
154
+ this.mediaRecorder.onstop = null;
155
+ this.mediaRecorder.ondataavailable = null;
156
+ if (this.mediaRecorder.state === 'recording') {
157
+ this.mediaRecorder.stop();
158
+ }
159
+ }
160
+ this.mediaRecorder = undefined;
161
+ this.audioChunks = [];
162
+ this.logger.log('[VoiceService] discarded in-progress segment; VAD session unchanged');
163
+ }
164
+
137
165
  /**
138
166
  * 🎧 AUDIO ANALYSER INIT
139
167
  */
@@ -161,7 +189,9 @@ export class VoiceService {
161
189
  return;
162
190
  }
163
191
 
164
- this.analyser.getByteFrequencyData(this.dataArray);
192
+ this.analyser.getByteFrequencyData(
193
+ this.dataArray as Parameters<AnalyserNode['getByteFrequencyData']>[0],
194
+ );
165
195
 
166
196
  let sum = 0;
167
197
  for (let i = 0; i < this.dataArray.length; i++) {
@@ -38,6 +38,8 @@
38
38
  --chat-footer-logo-height: 30px;
39
39
  --chat-footer-close-button-height: 30px;
40
40
  --chat-footer-border-radius: 16px;
41
+ --chat-footer-stream-button-height: 96px;
42
+ --chat-footer-stream-button-padding: 10px 0;
41
43
  --chat-footer-background-color: #f6f7fb;
42
44
  --chat-footer-color: #1a1a1a;
43
45
  --chat-footer-border-color-error: #aa0404;
package/src/launch.js CHANGED
@@ -218,67 +218,76 @@ function loadIframe(tiledeskScriptBaseLocation) {
218
218
  iDiv.appendChild(ifrm);
219
219
 
220
220
  // Funzione helper per caricare iframe con fallback per compatibilità CSP (Wix, etc.)
221
- // Usa Blob URL come metodo principale (più compatibile con CSP) con fallback a srcdoc e document.write
222
- function loadIframeContent(iframe, htmlContent, baseLocation) {
223
- var isLocalhost = baseLocation.includes('localhost');
221
+ // Priorità: document.write / srcdoc prima della Blob URL. Le Blob URL spesso danno origine opaca
222
+ // (blob:null): l'iframe non può leggere window.parent.tiledeskSettings → projectid mancante.
223
+ function loadIframeContent(iframe, htmlContent) {
224
224
  var blobUrl = null;
225
-
226
- // Metodo 1: Blob URL (più compatibile con CSP di Wix e altre piattaforme)
227
- // Usa Blob URL come metodo principale perché è meno spesso bloccato da CSP rispetto a srcdoc
225
+
226
+ // 1) document.write: iframe stessa origine della pagina host tiledeskSettings sul parent accessibile
227
+ try {
228
+ var cw = iframe.contentWindow;
229
+ if (cw && cw.document) {
230
+ cw.document.open();
231
+ cw.document.write(htmlContent);
232
+ cw.document.close();
233
+ return;
234
+ }
235
+ } catch (e) {
236
+ console.warn('[Tiledesk] iframe document.write failed, trying srcdoc/blob:', e);
237
+ }
238
+
239
+ // 2) srcdoc: stessa origine del parent (HTML5); utile se document.write è bloccato
240
+ if ('srcdoc' in iframe) {
241
+ try {
242
+ iframe.srcdoc = htmlContent;
243
+ return;
244
+ } catch (e) {
245
+ console.warn('[Tiledesk] iframe srcdoc failed, trying blob:', e);
246
+ }
247
+ }
248
+
249
+ // 3) Blob URL (spesso permesso da CSP dove srcdoc/write no; può rompere lettura parent.tiledeskSettings)
228
250
  if (typeof Blob !== 'undefined' && typeof URL !== 'undefined' && URL.createObjectURL) {
229
251
  try {
230
252
  var blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8' });
231
253
  blobUrl = URL.createObjectURL(blob);
232
254
  iframe.src = blobUrl;
233
-
234
- // Cleanup del blob URL dopo il caricamento per liberare memoria
255
+
235
256
  var originalOnload = iframe.onload;
236
257
  iframe.onload = function() {
237
- // Revoca il blob URL dopo un delay per assicurarsi che tutto sia caricato
238
258
  setTimeout(function() {
239
259
  if (blobUrl) {
240
260
  try {
241
261
  URL.revokeObjectURL(blobUrl);
242
262
  blobUrl = null;
243
- } catch(e) {
244
- console.warn('Error revoking blob URL:', e);
263
+ } catch (err) {
264
+ console.warn('Error revoking blob URL:', err);
245
265
  }
246
266
  }
247
267
  }, 1000);
248
268
  if (originalOnload) originalOnload.call(this);
249
269
  };
250
- return; // Blob URL impostato con successo
251
- } catch(e) {
252
- console.warn('Blob URL not available, trying srcdoc:', e);
270
+ return;
271
+ } catch (e) {
272
+ console.warn('Blob URL not available:', e);
253
273
  }
254
274
  }
255
-
256
- // Metodo 2: srcdoc (fallback se Blob URL non disponibile)
257
- // Skip per localhost (usa document.write per compatibilità sviluppo)
258
- if (!isLocalhost && 'srcdoc' in iframe) {
259
- try {
260
- iframe.srcdoc = htmlContent;
261
- return; // srcdoc impostato
262
- } catch(e) {
263
- console.warn('srcdoc not allowed, trying document.write:', e);
264
- }
265
- }
266
-
267
- // Metodo 3: document.write (fallback finale, funziona su localhost e browser vecchi)
268
- if (isLocalhost || (iframe.contentWindow && iframe.contentWindow.document)) {
275
+
276
+ // 4) Ultimo tentativo document.write (iframe magari non pronto al primo passo)
277
+ if (iframe.contentWindow && iframe.contentWindow.document) {
269
278
  try {
270
279
  iframe.contentWindow.document.open();
271
280
  iframe.contentWindow.document.write(htmlContent);
272
281
  iframe.contentWindow.document.close();
273
- return; // document.write completato
274
- } catch(e) {
275
- console.error('All iframe loading methods failed:', e);
282
+ return;
283
+ } catch (e) {
284
+ console.error('[Tiledesk] All iframe loading methods failed:', e);
276
285
  }
277
286
  }
278
287
  }
279
288
 
280
289
  // Carica il contenuto dell'iframe con fallback automatico
281
- loadIframeContent(ifrm, srcTileDesk, tiledeskScriptBaseLocation);
290
+ loadIframeContent(ifrm, srcTileDesk);
282
291
 
283
292
 
284
293
  }
@@ -219,67 +219,76 @@ function loadIframe(tiledeskScriptBaseLocation) {
219
219
  iDiv.appendChild(ifrm);
220
220
 
221
221
  // Funzione helper per caricare iframe con fallback per compatibilità CSP (Wix, etc.)
222
- // Usa Blob URL come metodo principale (più compatibile con CSP) con fallback a srcdoc e document.write
223
- function loadIframeContent(iframe, htmlContent, baseLocation) {
224
- var isLocalhost = baseLocation.includes('localhost');
222
+ // Priorità: document.write / srcdoc prima della Blob URL. Le Blob URL spesso danno origine opaca
223
+ // (blob:null): l'iframe non può leggere window.parent.tiledeskSettings → projectid mancante.
224
+ function loadIframeContent(iframe, htmlContent) {
225
225
  var blobUrl = null;
226
-
227
- // Metodo 1: Blob URL (più compatibile con CSP di Wix e altre piattaforme)
228
- // Usa Blob URL come metodo principale perché è meno spesso bloccato da CSP rispetto a srcdoc
226
+
227
+ // 1) document.write: iframe stessa origine della pagina host tiledeskSettings sul parent accessibile
228
+ try {
229
+ var cw = iframe.contentWindow;
230
+ if (cw && cw.document) {
231
+ cw.document.open();
232
+ cw.document.write(htmlContent);
233
+ cw.document.close();
234
+ return;
235
+ }
236
+ } catch (e) {
237
+ console.warn('[Tiledesk] iframe document.write failed, trying srcdoc/blob:', e);
238
+ }
239
+
240
+ // 2) srcdoc: stessa origine del parent (HTML5); utile se document.write è bloccato
241
+ if ('srcdoc' in iframe) {
242
+ try {
243
+ iframe.srcdoc = htmlContent;
244
+ return;
245
+ } catch (e) {
246
+ console.warn('[Tiledesk] iframe srcdoc failed, trying blob:', e);
247
+ }
248
+ }
249
+
250
+ // 3) Blob URL (spesso permesso da CSP dove srcdoc/write no; può rompere lettura parent.tiledeskSettings)
229
251
  if (typeof Blob !== 'undefined' && typeof URL !== 'undefined' && URL.createObjectURL) {
230
252
  try {
231
253
  var blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8' });
232
254
  blobUrl = URL.createObjectURL(blob);
233
255
  iframe.src = blobUrl;
234
-
235
- // Cleanup del blob URL dopo il caricamento per liberare memoria
256
+
236
257
  var originalOnload = iframe.onload;
237
258
  iframe.onload = function() {
238
- // Revoca il blob URL dopo un delay per assicurarsi che tutto sia caricato
239
259
  setTimeout(function() {
240
260
  if (blobUrl) {
241
261
  try {
242
262
  URL.revokeObjectURL(blobUrl);
243
263
  blobUrl = null;
244
- } catch(e) {
245
- console.warn('Error revoking blob URL:', e);
264
+ } catch (err) {
265
+ console.warn('Error revoking blob URL:', err);
246
266
  }
247
267
  }
248
268
  }, 1000);
249
269
  if (originalOnload) originalOnload.call(this);
250
270
  };
251
- return; // Blob URL impostato con successo
252
- } catch(e) {
253
- console.warn('Blob URL not available, trying srcdoc:', e);
254
- }
255
- }
256
-
257
- // Metodo 2: srcdoc (fallback se Blob URL non disponibile)
258
- // Skip per localhost (usa document.write per compatibilità sviluppo)
259
- if (!isLocalhost && 'srcdoc' in iframe) {
260
- try {
261
- iframe.srcdoc = htmlContent;
262
- return; // srcdoc impostato
263
- } catch(e) {
264
- console.warn('srcdoc not allowed, trying document.write:', e);
271
+ return;
272
+ } catch (e) {
273
+ console.warn('Blob URL not available:', e);
265
274
  }
266
275
  }
267
-
268
- // Metodo 3: document.write (fallback finale, funziona su localhost e browser vecchi)
269
- if (isLocalhost || (iframe.contentWindow && iframe.contentWindow.document)) {
276
+
277
+ // 4) Ultimo tentativo document.write (iframe magari non pronto al primo passo)
278
+ if (iframe.contentWindow && iframe.contentWindow.document) {
270
279
  try {
271
280
  iframe.contentWindow.document.open();
272
281
  iframe.contentWindow.document.write(htmlContent);
273
282
  iframe.contentWindow.document.close();
274
- return; // document.write completato
275
- } catch(e) {
276
- console.error('All iframe loading methods failed:', e);
283
+ return;
284
+ } catch (e) {
285
+ console.error('[Tiledesk] All iframe loading methods failed:', e);
277
286
  }
278
287
  }
279
288
  }
280
289
 
281
290
  // Carica il contenuto dell'iframe con fallback automatico
282
- loadIframeContent(ifrm, srcTileDesk, tiledeskScriptBaseLocation);
291
+ loadIframeContent(ifrm, srcTileDesk);
283
292
 
284
293
 
285
294
  }
@@ -1,41 +0,0 @@
1
- # npm version patch
2
- version=`node -e 'console.log(require("./package.json").version)'`
3
- echo "version $version"
4
-
5
- npm i
6
-
7
- cp src/environments/real_data/environment.prod.ts src/environments/environment.prod.ts
8
-
9
- # --build-optimizer=false if localstorage is disabled (webview) appears https://github.com/firebase/angularfire/issues/970
10
- ng build --configuration="prod" --aot=true
11
- ##--base-href='./v5/' --output-hashing none
12
-
13
- ### SET HASHING : START ###
14
- cp ./src/launch_template.js ./dist/browser/launch.js
15
- node ./src/build_launch.js
16
- ### SET HASHING : END ###
17
-
18
- #### FIREBASE #####
19
- # cd dist
20
- # # aws s3 sync . s3://tiledesk-widget/v5/latest/
21
- # aws s3 sync . s3://tiledesk-widget/v5/$version/ --cache-control max-age=300
22
- # aws s3 sync . s3://tiledesk-widget/v5/ --cache-control max-age=300
23
- # cd ..
24
-
25
- # #### MQTT #####
26
- cd dist/browser
27
- # aws s3 sync . s3://tiledesk-widget/v5/latest/
28
- aws s3 sync . s3://tiledesk-widget/v6/$version/ --cache-control max-age=86400 --exclude='launch.js' #8days
29
- aws s3 sync . s3://tiledesk-widget/v6/$version/ --cache-control "no-store,no-cache,private" --exclude='*' --include='launch.js'
30
- aws s3 sync . s3://tiledesk-widget/v6/ --cache-control max-age=86400 --exclude='launch.js' #8days
31
- aws s3 sync . s3://tiledesk-widget/v6/ --cache-control "no-store,no-cache,private" --exclude='*' --include='launch.js'
32
- cd ../..
33
-
34
- aws cloudfront create-invalidation --distribution-id E3EJDWEHY08CZZ --paths "/*"
35
-
36
- git restore src/environments/environment.prod.ts
37
-
38
- echo new version deployed $version on s3://tiledesk-widget/v6
39
- echo available on https://s3.eu-west-1.amazonaws.com/tiledesk-widget/v6/index.html
40
- echo https://widget.tiledesk.com/v6/index.html
41
- echo https://widget.tiledesk.com/v6/$version/index.html