@chat21/chat21-web-widget 5.1.32-rc9 → 5.1.33-rc11

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 (246) hide show
  1. package/.angular-mcp-cache/package.json +1 -0
  2. package/.cursor/angular18-accessibility-auditor-skill.md +442 -0
  3. package/.cursor/mcp.json +15 -0
  4. package/.github/workflows/playwright.yml +27 -0
  5. package/.playwright-mcp/console-2026-05-08T15-31-09-000Z.log +17 -0
  6. package/.playwright-mcp/console-2026-05-08T15-32-19-412Z.log +89 -0
  7. package/.playwright-mcp/console-2026-05-08T16-18-48-424Z.log +133 -0
  8. package/.playwright-mcp/console-2026-05-11T12-54-06-869Z.log +13 -0
  9. package/.playwright-mcp/console-2026-05-11T12-54-56-229Z.log +147 -0
  10. package/.playwright-mcp/console-2026-05-11T12-55-47-174Z.log +183 -0
  11. package/.playwright-mcp/console-2026-05-11T15-34-03-590Z.log +210 -0
  12. package/.playwright-mcp/console-2026-05-12T15-07-31-880Z.log +118 -0
  13. package/.playwright-mcp/page-2026-05-08T15-32-19-900Z.yml +851 -0
  14. package/.playwright-mcp/page-2026-05-08T15-32-47-264Z.yml +857 -0
  15. package/.playwright-mcp/page-2026-05-08T15-33-17-089Z.yml +1110 -0
  16. package/.playwright-mcp/page-2026-05-08T15-33-23-486Z.yml +1069 -0
  17. package/.playwright-mcp/page-2026-05-08T15-33-45-390Z.yml +1076 -0
  18. package/.playwright-mcp/page-2026-05-08T15-33-52-666Z.yml +1072 -0
  19. package/.playwright-mcp/page-2026-05-08T15-34-01-338Z.yml +1085 -0
  20. package/.playwright-mcp/page-2026-05-08T15-34-07-227Z.yml +1072 -0
  21. package/.playwright-mcp/page-2026-05-08T15-34-13-875Z.yml +1072 -0
  22. package/.playwright-mcp/page-2026-05-08T15-34-21-885Z.yml +1109 -0
  23. package/.playwright-mcp/page-2026-05-08T15-34-32-755Z.yml +1109 -0
  24. package/.playwright-mcp/page-2026-05-08T15-35-09-607Z.yml +1119 -0
  25. package/.playwright-mcp/page-2026-05-08T15-35-14-242Z.yml +1109 -0
  26. package/.playwright-mcp/page-2026-05-08T16-18-48-671Z.yml +44 -0
  27. package/.playwright-mcp/page-2026-05-08T16-18-52-753Z.png +0 -0
  28. package/.playwright-mcp/page-2026-05-08T16-19-13-919Z.yml +68 -0
  29. package/.playwright-mcp/page-2026-05-08T16-19-17-977Z.png +0 -0
  30. package/.playwright-mcp/page-2026-05-08T16-19-25-733Z.yml +120 -0
  31. package/.playwright-mcp/page-2026-05-08T16-19-29-252Z.png +0 -0
  32. package/.playwright-mcp/page-2026-05-08T16-19-39-269Z.yml +80 -0
  33. package/.playwright-mcp/page-2026-05-08T16-19-43-915Z.png +0 -0
  34. package/.playwright-mcp/page-2026-05-08T16-20-04-407Z.yml +81 -0
  35. package/.playwright-mcp/page-2026-05-08T16-20-08-984Z.png +0 -0
  36. package/.playwright-mcp/page-2026-05-08T16-20-32-397Z.png +0 -0
  37. package/.playwright-mcp/page-2026-05-08T16-20-58-658Z.png +0 -0
  38. package/.playwright-mcp/page-2026-05-08T16-21-12-320Z.yml +86 -0
  39. package/.playwright-mcp/page-2026-05-08T16-21-39-154Z.yml +91 -0
  40. package/.playwright-mcp/page-2026-05-08T16-21-45-420Z.png +0 -0
  41. package/.playwright-mcp/page-2026-05-08T16-22-21-062Z.yml +0 -0
  42. package/.playwright-mcp/page-2026-05-08T16-22-58-232Z.yml +91 -0
  43. package/.playwright-mcp/page-2026-05-08T16-23-36-520Z.yml +0 -0
  44. package/.playwright-mcp/page-2026-05-08T16-23-46-805Z.yml +100 -0
  45. package/.playwright-mcp/page-2026-05-08T16-23-55-169Z.png +0 -0
  46. package/.playwright-mcp/page-2026-05-08T16-24-26-574Z.yml +91 -0
  47. package/.playwright-mcp/page-2026-05-08T16-25-34-414Z.png +0 -0
  48. package/.playwright-mcp/page-2026-05-08T16-25-59-831Z.png +0 -0
  49. package/.playwright-mcp/page-2026-05-08T16-26-21-809Z.yml +91 -0
  50. package/.playwright-mcp/page-2026-05-08T16-26-47-443Z.yml +105 -0
  51. package/.playwright-mcp/page-2026-05-08T16-26-56-136Z.png +0 -0
  52. package/.playwright-mcp/page-2026-05-08T16-27-59-610Z.yml +48 -0
  53. package/.playwright-mcp/page-2026-05-11T12-54-07-180Z.yml +44 -0
  54. package/.playwright-mcp/page-2026-05-11T12-54-56-946Z.yml +4 -0
  55. package/.playwright-mcp/page-2026-05-11T12-55-47-503Z.yml +24 -0
  56. package/.playwright-mcp/page-2026-05-11T12-56-00-766Z.yml +28 -0
  57. package/.playwright-mcp/page-2026-05-11T12-56-06-438Z.yml +90 -0
  58. package/.playwright-mcp/page-2026-05-11T12-57-56-838Z.yml +106 -0
  59. package/.playwright-mcp/page-2026-05-11T12-58-00-124Z.yml +106 -0
  60. package/.playwright-mcp/page-2026-05-11T12-59-08-836Z.yml +61 -0
  61. package/.playwright-mcp/page-2026-05-11T12-59-12-088Z.yml +61 -0
  62. package/.playwright-mcp/page-2026-05-11T12-59-26-215Z.yml +69 -0
  63. package/.playwright-mcp/page-2026-05-11T12-59-29-519Z.yml +69 -0
  64. package/.playwright-mcp/page-2026-05-11T12-59-37-309Z.yml +0 -0
  65. package/.playwright-mcp/page-2026-05-11T12-59-39-968Z.yml +79 -0
  66. package/.playwright-mcp/page-2026-05-11T12-59-45-983Z.yml +78 -0
  67. package/.playwright-mcp/page-2026-05-11T12-59-49-951Z.yml +78 -0
  68. package/.playwright-mcp/page-2026-05-11T15-34-04-515Z.yml +0 -0
  69. package/.playwright-mcp/page-2026-05-12T15-07-32-171Z.yml +44 -0
  70. package/.playwright-mcp/page-2026-05-12T15-08-09-820Z.yml +119 -0
  71. package/CHANGELOG.md +61 -4
  72. package/angular.json +20 -3
  73. package/deploy_amazon_beta.sh +7 -17
  74. package/deploy_amazon_prod.sh +41 -0
  75. package/docs/TILEDESK_WIDGET_ACCESSIBILITY_STATEMENT_COMPLETE.md +379 -0
  76. package/env.sample +3 -2
  77. package/mocks/voice-websocket-mock/server.cjs +245 -0
  78. package/package.json +7 -3
  79. package/playwright.config.ts +41 -0
  80. package/src/app/app.component.html +2 -2
  81. package/src/app/app.component.scss +25 -14
  82. package/src/app/app.component.spec.ts +21 -6
  83. package/src/app/app.module.ts +4 -0
  84. package/src/app/component/conversation-detail/conversation/conversation.component.html +19 -11
  85. package/src/app/component/conversation-detail/conversation/conversation.component.scss +28 -0
  86. package/src/app/component/conversation-detail/conversation/conversation.component.spec.ts +644 -75
  87. package/src/app/component/conversation-detail/conversation/conversation.component.ts +63 -17
  88. package/src/app/component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component.html +25 -13
  89. package/src/app/component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component.spec.ts +123 -5
  90. package/src/app/component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component.ts +1 -0
  91. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.html +17 -7
  92. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +15 -3
  93. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.spec.ts +242 -149
  94. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.ts +7 -6
  95. package/src/app/component/conversation-detail/conversation-emojii/conversation-emojii.component.spec.ts +53 -3
  96. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component copy.html +172 -0
  97. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +112 -61
  98. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +133 -16
  99. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.spec.ts +452 -78
  100. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +198 -84
  101. package/src/app/component/conversation-detail/conversation-header/conversation-header.component.html +113 -53
  102. package/src/app/component/conversation-detail/conversation-header/conversation-header.component.scss +12 -4
  103. package/src/app/component/conversation-detail/conversation-header/conversation-header.component.spec.ts +274 -29
  104. package/src/app/component/conversation-detail/conversation-internal-frame/conversation-internal-frame.component.html +23 -9
  105. package/src/app/component/conversation-detail/conversation-internal-frame/conversation-internal-frame.component.spec.ts +80 -8
  106. package/src/app/component/conversation-detail/conversation-preview/conversation-preview.component.html +29 -23
  107. package/src/app/component/conversation-detail/conversation-preview/conversation-preview.component.spec.ts +185 -16
  108. package/src/app/component/conversation-detail/conversation-preview/conversation-preview.component.ts +34 -14
  109. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.html +46 -18
  110. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.scss +60 -2
  111. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.ts +135 -5
  112. package/src/app/component/error-alert/error-alert.component.spec.ts +65 -5
  113. package/src/app/component/eyeeye-catcher-card/eyeeye-catcher-card.component.html +16 -7
  114. package/src/app/component/eyeeye-catcher-card/eyeeye-catcher-card.component.scss +21 -0
  115. package/src/app/component/eyeeye-catcher-card/eyeeye-catcher-card.component.spec.ts +89 -7
  116. package/src/app/component/form/form-builder/form-builder.component.html +1 -1
  117. package/src/app/component/form/form-builder/form-builder.component.spec.ts +163 -21
  118. package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.html +8 -4
  119. package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.scss +10 -5
  120. package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.spec.ts +90 -16
  121. package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.ts +26 -0
  122. package/src/app/component/form/inputs/form-label/form-label.component.spec.ts +45 -11
  123. package/src/app/component/form/inputs/form-radio-button/form-radio-button.component.spec.ts +24 -6
  124. package/src/app/component/form/inputs/form-select/form-select.component.spec.ts +14 -5
  125. package/src/app/component/form/inputs/form-text/form-text.component.html +14 -12
  126. package/src/app/component/form/inputs/form-text/form-text.component.scss +11 -1
  127. package/src/app/component/form/inputs/form-text/form-text.component.spec.ts +113 -17
  128. package/src/app/component/form/inputs/form-text/form-text.component.ts +35 -3
  129. package/src/app/component/form/inputs/form-textarea/form-textarea.component.html +13 -11
  130. package/src/app/component/form/inputs/form-textarea/form-textarea.component.scss +6 -5
  131. package/src/app/component/form/inputs/form-textarea/form-textarea.component.spec.ts +149 -13
  132. package/src/app/component/form/inputs/form-textarea/form-textarea.component.ts +26 -0
  133. package/src/app/component/form/prechat-form/prechat-form.component.html +14 -11
  134. package/src/app/component/form/prechat-form/prechat-form.component.spec.ts +102 -10
  135. package/src/app/component/form/prechat-form/prechat-form.component.ts +8 -1
  136. package/src/app/component/form/prechat-form-test-mock.ts +35 -0
  137. package/src/app/component/home/home.component.html +38 -31
  138. package/src/app/component/home/home.component.scss +4 -2
  139. package/src/app/component/home/home.component.spec.ts +226 -11
  140. package/src/app/component/home-conversations/home-conversations.component.html +30 -26
  141. package/src/app/component/home-conversations/home-conversations.component.scss +3 -0
  142. package/src/app/component/home-conversations/home-conversations.component.spec.ts +212 -36
  143. package/src/app/component/last-message/last-message.component.html +15 -9
  144. package/src/app/component/last-message/last-message.component.scss +16 -2
  145. package/src/app/component/last-message/last-message.component.spec.ts +204 -23
  146. package/src/app/component/launcher-button/launcher-button.component.html +8 -13
  147. package/src/app/component/launcher-button/launcher-button.component.spec.ts +104 -8
  148. package/src/app/component/list-all-conversations/list-all-conversations.component.html +12 -17
  149. package/src/app/component/list-all-conversations/list-all-conversations.component.scss +2 -0
  150. package/src/app/component/list-conversations/list-conversations.component.html +22 -22
  151. package/src/app/component/menu-options/menu-options.component.html +30 -20
  152. package/src/app/component/menu-options/menu-options.component.spec.ts +125 -9
  153. package/src/app/component/message/audio/audio.component.html +13 -15
  154. package/src/app/component/message/audio/audio.component.spec.ts +140 -5
  155. package/src/app/component/message/audio/audio.component.ts +1 -0
  156. package/src/app/component/message/audio-sync/audio-sync.component.scss +1 -0
  157. package/src/app/component/message/audio-sync/audio-sync.component.spec.ts +81 -1
  158. package/src/app/component/message/audio-sync/audio-sync.component.ts +133 -86
  159. package/src/app/component/message/avatar/avatar.component.html +2 -2
  160. package/src/app/component/message/avatar/avatar.component.spec.ts +99 -7
  161. package/src/app/component/message/bubble-message/bubble-message.component.html +39 -52
  162. package/src/app/component/message/bubble-message/bubble-message.component.scss +59 -1
  163. package/src/app/component/message/bubble-message/bubble-message.component.spec.ts +154 -57
  164. package/src/app/component/message/bubble-message/bubble-message.component.ts +152 -110
  165. package/src/app/component/message/buttons/action-button/action-button.component.html +3 -4
  166. package/src/app/component/message/buttons/action-button/action-button.component.spec.ts +49 -5
  167. package/src/app/component/message/buttons/link-button/link-button.component.scss +5 -8
  168. package/src/app/component/message/buttons/link-button/link-button.component.spec.ts +50 -5
  169. package/src/app/component/message/buttons/text-button/text-button.component.spec.ts +44 -5
  170. package/src/app/component/message/carousel/carousel.component.html +29 -16
  171. package/src/app/component/message/carousel/carousel.component.scss +20 -8
  172. package/src/app/component/message/carousel/carousel.component.spec.ts +80 -3
  173. package/src/app/component/message/carousel/carousel.component.ts +16 -0
  174. package/src/app/component/message/frame/frame.component.html +9 -4
  175. package/src/app/component/message/frame/frame.component.spec.ts +34 -15
  176. package/src/app/component/message/frame/frame.component.ts +7 -2
  177. package/src/app/component/message/html/html.component.html +1 -1
  178. package/src/app/component/message/html/html.component.scss +1 -1
  179. package/src/app/component/message/html/html.component.spec.ts +24 -7
  180. package/src/app/component/message/image/image.component.html +12 -10
  181. package/src/app/component/message/image/image.component.scss +16 -0
  182. package/src/app/component/message/image/image.component.spec.ts +101 -15
  183. package/src/app/component/message/image/image.component.ts +90 -51
  184. package/src/app/component/message/info-message/info-message.component.spec.ts +26 -14
  185. package/src/app/component/message/json-sources/json-sources.component.html +38 -0
  186. package/src/app/component/message/json-sources/json-sources.component.scss +201 -0
  187. package/src/app/component/message/json-sources/json-sources.component.ts +89 -0
  188. package/src/app/component/message/like-unlike/like-unlike.component.html +7 -9
  189. package/src/app/component/message/like-unlike/like-unlike.component.spec.ts +31 -3
  190. package/src/app/component/message/return-receipt/return-receipt.component.spec.ts +38 -17
  191. package/src/app/component/message/text/text.component.html +3 -3
  192. package/src/app/component/message/text/text.component.scss +80 -86
  193. package/src/app/component/message/text/text.component.spec.ts +106 -13
  194. package/src/app/component/message-attachment/message-attachment.component.spec.ts +134 -13
  195. package/src/app/component/selection-department/selection-department.component.html +21 -23
  196. package/src/app/component/selection-department/selection-department.component.spec.ts +159 -14
  197. package/src/app/component/selection-department/selection-department.component.ts +8 -1
  198. package/src/app/component/send-button/send-button.component.html +5 -13
  199. package/src/app/component/send-button/send-button.component.spec.ts +2 -2
  200. package/src/app/component/star-rating-widget/star-rating-widget.component.html +51 -81
  201. package/src/app/directives/tooltip.directive.spec.ts +8 -4
  202. package/src/app/modals/confirm-close/confirm-close.component.html +20 -8
  203. package/src/app/modals/confirm-close/confirm-close.component.scss +3 -0
  204. package/src/app/modals/confirm-close/confirm-close.component.spec.ts +13 -4
  205. package/src/app/modals/confirm-close/confirm-close.component.ts +8 -1
  206. package/src/app/pipe/html-entites-encode.pipe.spec.ts +35 -2
  207. package/src/app/pipe/marked.pipe.spec.ts +38 -2
  208. package/src/app/pipe/marked.pipe.ts +51 -41
  209. package/src/app/providers/app-config.service.ts +4 -2
  210. package/src/app/providers/brand.service.spec.ts +23 -2
  211. package/src/app/providers/brand.service.ts +1 -1
  212. package/src/app/providers/global-settings.service.spec.ts +1009 -14
  213. package/src/app/providers/global-settings.service.ts +59 -2
  214. package/src/app/providers/json-sources-parser.service.ts +175 -0
  215. package/src/app/providers/translator.service.ts +24 -6
  216. package/src/app/providers/tts-audio-playback-coordinator.service.spec.ts +117 -0
  217. package/src/app/providers/tts-audio-playback-coordinator.service.ts +39 -16
  218. package/src/app/providers/url-preview.service.ts +82 -0
  219. package/src/app/providers/voice/audio.types.ts +6 -0
  220. package/src/app/providers/voice/voice-streaming.service.spec.ts +23 -0
  221. package/src/app/providers/voice/voice-streaming.service.ts +702 -0
  222. package/src/app/providers/voice/voice-streaming.types.ts +112 -0
  223. package/src/app/providers/voice/voice.service.spec.ts +170 -3
  224. package/src/app/providers/voice/voice.service.ts +691 -17
  225. package/src/app/sass/_variables.scss +1 -1
  226. package/src/app/sass/animations.scss +19 -1
  227. package/src/app/utils/globals.ts +14 -0
  228. package/src/app/utils/json-sources-utils.ts +27 -0
  229. package/src/app/utils/url-utils.ts +98 -0
  230. package/src/app/utils/utils-resources.ts +1 -1
  231. package/src/assets/i18n/en.json +106 -100
  232. package/src/assets/i18n/es.json +107 -101
  233. package/src/assets/i18n/fr.json +107 -101
  234. package/src/assets/i18n/it.json +107 -99
  235. package/src/assets/sounds/keyboard.mp3 +0 -0
  236. package/src/assets/twp/index-dev.html +18 -0
  237. package/src/assets/twp/tiledesk_widget_files/widget-css-override-example.css +14 -0
  238. package/src/chat21-core/providers/chat-manager.spec.ts +72 -0
  239. package/src/chat21-core/providers/scripts/script.service.spec.ts +12 -2
  240. package/src/chat21-core/utils/constants.ts +4 -0
  241. package/src/chat21-core/utils/utils-message.ts +23 -1
  242. package/src/widget-config-template.json +3 -1
  243. package/src/widget-config.json +28 -27
  244. package/tests/widget-form-rich.spec.ts +67 -0
  245. package/tests/widget-index-dev-settings.spec.ts +52 -0
  246. package/tests/widget-twp-iframe.spec.ts +39 -0
@@ -1,23 +1,158 @@
1
1
  import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+ import { LoggerInstance } from 'src/chat21-core/providers/logger/loggerInstance';
3
+ import { CustomLogger } from 'src/chat21-core/providers/logger/customLogger';
4
+ import { NGXLogger } from 'ngx-logger';
2
5
 
3
6
  import { AudioComponent } from './audio.component';
4
7
 
5
- describe('AudioTrackComponent', () => {
8
+ describe('AudioComponent', () => {
6
9
  let component: AudioComponent;
7
10
  let fixture: ComponentFixture<AudioComponent>;
11
+ const ngxlogger = jasmine.createSpyObj('NGXLogger', ['log', 'trace', 'debug', 'warn', 'error', 'info']);
12
+ const customLogger = new CustomLogger(ngxlogger);
13
+ const arrayBuf = new ArrayBuffer(64);
14
+
15
+ const fakeBuffer = {
16
+ duration: 90,
17
+ getChannelData: () => new Float32Array(4000),
18
+ } as unknown as AudioBuffer;
8
19
 
9
20
  beforeEach(async () => {
21
+ LoggerInstance.setInstance(customLogger);
22
+ spyOn(window, 'fetch').and.returnValue(
23
+ Promise.resolve({
24
+ arrayBuffer: () => Promise.resolve(arrayBuf),
25
+ } as Response),
26
+ );
27
+ spyOn(AudioContext.prototype, 'decodeAudioData').and.returnValue(Promise.resolve(fakeBuffer));
28
+
10
29
  await TestBed.configureTestingModule({
11
- declarations: [ AudioComponent ]
30
+ declarations: [AudioComponent],
12
31
  })
13
- .compileComponents();
32
+ .overrideComponent(AudioComponent, {
33
+ set: {
34
+ template: `
35
+ <div class="audio-container">
36
+ <div class="audio-track"></div>
37
+ <div class="audio-player-custom">
38
+ <audio #audioElement></audio>
39
+ <canvas #canvasElement width="120" height="32"></canvas>
40
+ </div>
41
+ </div>`,
42
+ },
43
+ })
44
+ .compileComponents();
14
45
 
15
46
  fixture = TestBed.createComponent(AudioComponent);
16
47
  component = fixture.componentInstance;
17
- fixture.detectChanges();
48
+ component.stylesMap = new Map<string, string>([
49
+ ['bubbleSentBackground', 'rgba(10, 20, 30, 1)'],
50
+ ['bubbleSentTextColor', '#112233'],
51
+ ]);
52
+ component.color = '#000000';
18
53
  });
19
54
 
20
- it('should create', () => {
55
+ it('should create', async () => {
56
+ const blob = new Blob([new Uint8Array(arrayBuf.byteLength)], { type: 'audio/wav' });
57
+ component.audioBlob = blob;
58
+ fixture.detectChanges();
59
+ await fixture.whenStable();
60
+ fixture.detectChanges();
21
61
  expect(component).toBeTruthy();
22
62
  });
63
+
64
+ it('formatTime should pad seconds under 10', () => {
65
+ expect(component.formatTime(0)).toBe('0:00');
66
+ expect(component.formatTime(9)).toBe('0:09');
67
+ expect(component.formatTime(70)).toBe('1:10');
68
+ });
69
+
70
+ it('extractFirstColor should parse first rgba from gradient string', () => {
71
+ expect(component.extractFirstColor('linear-gradient(rgba(1, 2, 3, 0.5), red)')).toBe('rgba(1, 2, 3, 0.5)');
72
+ expect(component.extractFirstColor('no-color')).toBeNull();
73
+ });
74
+
75
+ it('drawWaveform should return early when canvas context missing', () => {
76
+ const canvas = document.createElement('canvas');
77
+ spyOn(canvas, 'getContext').and.returnValue(null);
78
+ (component as any).waveformCanvas = { nativeElement: canvas };
79
+ (component as any).audioBuffer = fakeBuffer;
80
+ (component as any).audioDuration = 10;
81
+ (component as any).audioElement = {
82
+ nativeElement: { currentTime: 0, paused: true },
83
+ };
84
+ expect(() => component.drawWaveform(fakeBuffer)).not.toThrow();
85
+ });
86
+
87
+ it('drawWaveform should render bars when context exists', () => {
88
+ const fillRect = jasmine.createSpy('fillRect');
89
+ const clearRect = jasmine.createSpy('clearRect');
90
+ const canvas = document.createElement('canvas');
91
+ canvas.width = 200;
92
+ canvas.height = 40;
93
+ spyOn(canvas, 'getContext').and.returnValue({ fillRect, clearRect } as any);
94
+ (component as any).waveformCanvas = { nativeElement: canvas };
95
+ (component as any).audioElement = {
96
+ nativeElement: { currentTime: 0, paused: true },
97
+ };
98
+ (component as any).audioDuration = 10;
99
+ component.drawWaveform(fakeBuffer);
100
+ expect(clearRect).toHaveBeenCalled();
101
+ expect(fillRect).toHaveBeenCalled();
102
+ });
103
+
104
+ it('ngAfterViewInit with blob should wire object URL and CSS vars', async () => {
105
+ const blob = new Blob([new Uint8Array(128)], { type: 'audio/wav' });
106
+ component.audioBlob = blob;
107
+ spyOn(URL, 'createObjectURL').and.returnValue('blob:mock-audio');
108
+ fixture.detectChanges();
109
+ await fixture.whenStable();
110
+ fixture.detectChanges();
111
+ expect(component.rawAudioUrl).toBe('blob:mock-audio');
112
+ expect(URL.createObjectURL).toHaveBeenCalledWith(blob);
113
+ });
114
+
115
+ it('ngAfterViewInit with metadata.src should fetch and decode', async () => {
116
+ component.audioBlob = null;
117
+ component.metadata = { src: 'blob:from-meta' };
118
+ fixture.detectChanges();
119
+ await fixture.whenStable();
120
+ fixture.detectChanges();
121
+ expect(window.fetch).toHaveBeenCalled();
122
+ expect(component.audioDuration).toBe(90);
123
+ });
124
+
125
+ it('playPauseAudio should toggle play state when buffer ready', () => {
126
+ spyOn(window, 'requestAnimationFrame').and.stub();
127
+ (component as any).audioBuffer = fakeBuffer;
128
+ (component as any).audioDuration = 10;
129
+ const play = jasmine.createSpy('play').and.returnValue(Promise.resolve());
130
+ const pause = jasmine.createSpy('pause');
131
+ const canvas = document.createElement('canvas');
132
+ canvas.width = 120;
133
+ canvas.height = 32;
134
+ spyOn(canvas, 'getContext').and.returnValue({
135
+ fillRect: jasmine.createSpy(),
136
+ clearRect: jasmine.createSpy(),
137
+ } as any);
138
+ (component as any).waveformCanvas = { nativeElement: canvas };
139
+ (component as any).audioElement = {
140
+ nativeElement: { paused: true, currentTime: 0, play, pause, ontimeupdate: null as any, onended: null as any },
141
+ };
142
+ (component as any).audioContext = { resume: jasmine.createSpy().and.returnValue(Promise.resolve()) };
143
+
144
+ component.playPauseAudio();
145
+ expect(play).toHaveBeenCalled();
146
+ expect(component.isPlaying).toBe(true);
147
+
148
+ (component as any).audioElement.nativeElement.paused = false;
149
+ component.playPauseAudio();
150
+ expect(pause).toHaveBeenCalled();
151
+ });
152
+
153
+ it('getAudioDuration should set audioDuration from decoded buffer', async () => {
154
+ component.metadata = { src: 'blob:x' };
155
+ await component.getAudioDuration();
156
+ expect(component.audioDuration).toBe(90);
157
+ });
23
158
  });
@@ -18,6 +18,7 @@ export class AudioComponent implements AfterViewInit {
18
18
  @Input() metadata: any | null = null;
19
19
  @Input() color: string;
20
20
  @Input() stylesMap: Map<string, string>;
21
+ @Input() translationMap: Map<string, string>;
21
22
 
22
23
  audioUrl: SafeUrl | null = null;
23
24
  rawAudioUrl: string | null = null;
@@ -17,6 +17,7 @@
17
17
  .lyrics {
18
18
  font-size: inherit;
19
19
  margin: 0;
20
+ // line-height: 1.4em;
20
21
  font-style: normal;
21
22
  letter-spacing: normal;
22
23
  font-stretch: normal;
@@ -1,14 +1,39 @@
1
1
  import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+ import { CommonModule } from '@angular/common';
2
3
 
3
4
  import { AudioSyncComponent } from './audio-sync.component';
5
+ import { TtsAudioPlaybackCoordinator } from 'src/app/providers/tts-audio-playback-coordinator.service';
6
+ import { VoiceService } from 'src/app/providers/voice/voice.service';
7
+ import { Globals } from 'src/app/utils/globals';
4
8
 
5
9
  describe('AudioSyncComponent', () => {
6
10
  let component: AudioSyncComponent;
7
11
  let fixture: ComponentFixture<AudioSyncComponent>;
12
+ let voiceService: { proxyTtsStreamUrl: string | null; proxyTtsUrl: string | null };
8
13
 
9
14
  beforeEach(async () => {
15
+ voiceService = {
16
+ proxyTtsStreamUrl: 'https://speech.example.com/api/tts/stream',
17
+ proxyTtsUrl: 'https://speech.example.com/api/tts',
18
+ };
19
+
10
20
  await TestBed.configureTestingModule({
11
- imports: [AudioSyncComponent]
21
+ declarations: [AudioSyncComponent],
22
+ imports: [CommonModule],
23
+ providers: [
24
+ {
25
+ provide: TtsAudioPlaybackCoordinator,
26
+ useValue: {
27
+ requestStart: (_ownerId: string, start: () => void) => start(),
28
+ releaseIfCurrent: jasmine.createSpy('releaseIfCurrent'),
29
+ release: jasmine.createSpy('release'),
30
+ stopAllPlayback$: { subscribe: () => ({ unsubscribe: () => undefined }) },
31
+ preemptPlayback$: { subscribe: () => ({ unsubscribe: () => undefined }) },
32
+ },
33
+ },
34
+ { provide: Globals, useValue: { tiledeskToken: 'JWT test-token', jwt: '' } },
35
+ { provide: VoiceService, useValue: voiceService },
36
+ ],
12
37
  })
13
38
  .compileComponents();
14
39
 
@@ -20,4 +45,59 @@ describe('AudioSyncComponent', () => {
20
45
  it('should create', () => {
21
46
  expect(component).toBeTruthy();
22
47
  });
48
+
49
+ it('starts TTS playback from the proxy streaming endpoint first', () => {
50
+ component.message = {
51
+ uid: 'm1',
52
+ type: 'tts',
53
+ text: 'hello',
54
+ metadata: {},
55
+ isJustRecived: true,
56
+ } as any;
57
+ const audio = document.createElement('audio');
58
+ const startStreaming = spyOn(component as any, 'startStreamingFromEndpoint').and.stub();
59
+
60
+ (component as any).startPlayback(audio);
61
+
62
+ expect(startStreaming).toHaveBeenCalledWith(
63
+ audio,
64
+ 'https://speech.example.com/api/tts/stream',
65
+ 'https://speech.example.com/api/tts',
66
+ undefined,
67
+ );
68
+ });
69
+
70
+ it('requests browser-compatible MP3 for proxy REST TTS by default', () => {
71
+ component.message = {
72
+ uid: 'm1',
73
+ type: 'tts',
74
+ text: 'hello',
75
+ metadata: {},
76
+ } as any;
77
+
78
+ const body = (component as any).buildTtsRequestBody({});
79
+
80
+ expect(body).toEqual({
81
+ text: 'hello',
82
+ streaming: true,
83
+ outputFormat: 'mp3_44100_128',
84
+ });
85
+ });
86
+
87
+ it('does not override an explicit TTS outputFormat from message voice settings', () => {
88
+ component.message = {
89
+ uid: 'm1',
90
+ type: 'tts',
91
+ text: 'hello',
92
+ metadata: {},
93
+ } as any;
94
+
95
+ const body = (component as any).buildTtsRequestBody({ outputFormat: 'pcm_16000' });
96
+
97
+ expect(body).toEqual({
98
+ text: 'hello',
99
+ streaming: true,
100
+ outputFormat: 'pcm_16000',
101
+ });
102
+ });
23
103
  });
@@ -12,11 +12,12 @@ import {
12
12
  import { Subscription } from 'rxjs';
13
13
  import { MessageModel } from 'src/chat21-core/models/message';
14
14
  import { TtsAudioPlaybackCoordinator } from 'src/app/providers/tts-audio-playback-coordinator.service';
15
- import { Globals } from 'src/app/utils/globals';
16
15
  import { VoiceService } from 'src/app/providers/voice/voice.service';
16
+ import { Globals } from 'src/app/utils/globals';
17
17
 
18
18
  /** HAVE_METADATA: metadati già disponibili (tipico audio servito da cache). */
19
19
  const HAVE_METADATA = 1;
20
+ const BROWSER_TTS_OUTPUT_FORMAT = 'mp3_44100_128';
20
21
 
21
22
  @Component({
22
23
  selector: 'chat-audio-sync',
@@ -50,11 +51,10 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
50
51
  private destroyed = false;
51
52
  private playbackRequested = false;
52
53
  private playbackStarted = false;
53
- private micInterrupted = false;
54
54
  private streamAbort?: AbortController;
55
55
  private mediaSourceObjectUrl?: string;
56
- private cancelAllSub?: Subscription;
57
- private micSpeechSub?: Subscription;
56
+ private stopAllSub?: Subscription;
57
+ private preemptSub?: Subscription;
58
58
 
59
59
  constructor(
60
60
  private readonly cdr: ChangeDetectorRef,
@@ -93,28 +93,6 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
93
93
  (this.message?.uid && String(this.message.uid).trim()) ||
94
94
  `tts-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
95
95
 
96
- // Se l’utente parla al microfono mentre sta ascoltando, interrompi TUTTO (corrente + coda).
97
- this.micSpeechSub = this.voiceService.speechStart$.subscribe(() => {
98
- if (this.destroyed) {
99
- return;
100
- }
101
- // interrompi solo se questo messaggio era in riproduzione o in attesa
102
- if (this.playbackStarted || this.playbackRequested) {
103
- this.micInterrupted = true;
104
- this.ttsPlayback.cancelAll();
105
- this.interruptPlaybackAndRevealText();
106
- }
107
- });
108
-
109
- // Stop globale (es. mic) notificato dal coordinatore: ogni istanza deve fermarsi e mostrare testo intero.
110
- this.cancelAllSub = this.ttsPlayback.cancelAll$.subscribe(() => {
111
- if (this.destroyed) {
112
- return;
113
- }
114
- this.micInterrupted = true;
115
- this.interruptPlaybackAndRevealText();
116
- });
117
-
118
96
  this.onPlaybackEnded = () => {
119
97
  this.playbackStarted = false;
120
98
  this.cleanupStreaming();
@@ -166,19 +144,12 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
166
144
  this.cdr.detectChanges();
167
145
 
168
146
  setTimeout(() => {
169
- if (this.playbackRequested || this.destroyed || this.micInterrupted) {
170
- if (this.micInterrupted) {
171
- this.markAllWordsPast();
172
- if (this.message) {
173
- this.message.isJustRecived = false;
174
- }
175
- this.cdr.detectChanges();
176
- }
147
+ if (this.playbackRequested || this.destroyed) {
177
148
  return;
178
149
  }
179
150
  this.playbackRequested = true;
180
151
  this.ttsPlayback.requestStart(this.playbackOwnerId, () => {
181
- if (this.destroyed || this.micInterrupted) {
152
+ if (this.destroyed) {
182
153
  this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
183
154
  return;
184
155
  }
@@ -188,14 +159,59 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
188
159
  this.startPlayback(audio);
189
160
  });
190
161
  }, 200);
162
+
163
+ // Stop signal: user pressed X while this TTS was playing or queued.
164
+ this.stopAllSub = this.ttsPlayback.stopAllPlayback$.subscribe(() => {
165
+ if (!this.playbackRequested && !this.playbackStarted) {
166
+ return;
167
+ }
168
+ this.destroyed = true;
169
+ this.playbackStarted = false;
170
+ this.cleanupStreaming();
171
+ try {
172
+ audio.pause();
173
+ audio.currentTime = 0;
174
+ } catch {
175
+ /* ignore */
176
+ }
177
+ this.markAllWordsPast();
178
+ if (this.message) {
179
+ this.message.isJustRecived = false;
180
+ }
181
+ this.cdr.detectChanges();
182
+ });
183
+
184
+ // Preempt signal: a newer message requested start while this one was playing.
185
+ // Only react when the emitted id matches this component's own ownerId.
186
+ this.preemptSub = this.ttsPlayback.preemptPlayback$.subscribe((stoppedId) => {
187
+ if (stoppedId !== this.playbackOwnerId) {
188
+ return;
189
+ }
190
+ this.playbackStarted = false;
191
+ this.cleanupStreaming();
192
+ try {
193
+ audio.pause();
194
+ audio.currentTime = 0;
195
+ } catch {
196
+ /* ignore */
197
+ }
198
+ this.markAllWordsPast();
199
+ if (this.message) {
200
+ this.message.isJustRecived = false;
201
+ }
202
+ this.cdr.detectChanges();
203
+ // No releaseIfCurrent call — the coordinator already cleared currentOwnerId before emitting.
204
+ });
191
205
  }
192
206
 
193
207
  ngOnDestroy(): void {
194
208
  this.destroyed = true;
195
209
  this.playbackStarted = false;
196
210
  this.cleanupStreaming();
197
- this.cancelAllSub?.unsubscribe();
198
- this.micSpeechSub?.unsubscribe();
211
+ this.stopAllSub?.unsubscribe();
212
+ this.stopAllSub = undefined;
213
+ this.preemptSub?.unsubscribe();
214
+ this.preemptSub = undefined;
199
215
 
200
216
  const audio = this.audioRef?.nativeElement;
201
217
  if (audio) {
@@ -219,34 +235,29 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
219
235
  }
220
236
  }
221
237
 
222
- private interruptPlaybackAndRevealText(): void {
223
- this.playbackStarted = false;
224
- this.cleanupStreaming();
238
+ private startPlayback(audio: HTMLAudioElement): void {
239
+ const messageSrc = (this.message as any)?.metadata?.src as string | undefined;
225
240
 
226
- const audio = this.audioRef?.nativeElement;
227
- if (audio) {
228
- try {
229
- audio.pause();
230
- audio.currentTime = 0;
231
- } catch {
232
- /* ignore */
241
+ if (this.message?.type === 'tts') {
242
+ const streamEndpoint = this.voiceService.proxyTtsStreamUrl;
243
+ const fullFileEndpoint = this.voiceService.proxyTtsUrl;
244
+ if (streamEndpoint) {
245
+ this.startStreamingFromEndpoint(audio, streamEndpoint, fullFileEndpoint, messageSrc);
246
+ return;
233
247
  }
248
+ if (fullFileEndpoint) {
249
+ this.fetchFullFileFromEndpoint(audio, fullFileEndpoint);
250
+ return;
251
+ }
252
+ if (messageSrc) {
253
+ this.playDirectUrl(audio, messageSrc);
254
+ return;
255
+ }
256
+ this.handlePlaybackError();
257
+ return;
234
258
  }
235
259
 
236
- // Rimuove se era in coda (o rilascia se era corrente).
237
- this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
238
-
239
- // Mostra tutto il testo (niente "future" invisibili).
240
- this.markAllWordsPast();
241
- if (this.message) {
242
- this.message.isJustRecived = false;
243
- }
244
- this.cdr.detectChanges();
245
- }
246
-
247
- private startPlayback(audio: HTMLAudioElement): void {
248
- const src = (this.message as any)?.metadata?.src as string | undefined;
249
- if (!src) {
260
+ if (!messageSrc) {
250
261
  this.playbackStarted = false;
251
262
  this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
252
263
  this.markAllWordsPast();
@@ -257,11 +268,10 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
257
268
  return;
258
269
  }
259
270
 
260
- if (this.message?.type === 'tts') {
261
- this.startStreamingFromEndpoint(audio, src);
262
- return;
263
- }
271
+ this.playDirectUrl(audio, messageSrc);
272
+ }
264
273
 
274
+ private playDirectUrl(audio: HTMLAudioElement, src: string): void {
265
275
  audio.src = src;
266
276
  try {
267
277
  audio.currentTime = 0;
@@ -271,16 +281,40 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
271
281
  audio.play().catch(() => this.handlePlaybackError());
272
282
  }
273
283
 
274
- private startStreamingFromEndpoint(audio: HTMLAudioElement, endpoint: string): void {
284
+ private startStreamingFromEndpoint(
285
+ audio: HTMLAudioElement,
286
+ endpoint: string,
287
+ fullFileEndpoint?: string | null,
288
+ directFallbackSrc?: string,
289
+ ): void {
275
290
  this.cleanupStreaming();
276
291
 
277
292
  const jwt = this.getJwtToken();
278
293
  const voiceSettings = this.getVoiceSettingsBody();
279
294
  const requestBody = this.buildTtsRequestBody(voiceSettings);
295
+ let fallbackUsed = false;
296
+ const fallback = () => {
297
+ if (fallbackUsed) {
298
+ this.handlePlaybackError();
299
+ return;
300
+ }
301
+ fallbackUsed = true;
302
+ this.cleanupStreaming();
303
+ if (fullFileEndpoint) {
304
+ this.fetchFullFileFromEndpoint(audio, fullFileEndpoint);
305
+ return;
306
+ }
307
+ if (directFallbackSrc) {
308
+ this.playDirectUrl(audio, directFallbackSrc);
309
+ return;
310
+ }
311
+ this.handlePlaybackError();
312
+ };
313
+
280
314
  // <audio src="..."> non può inviare header/body: serve fetch().
281
315
  const hasMse = typeof (window as any).MediaSource !== 'undefined';
282
316
  if (!hasMse) {
283
- this.fetchAsBlobAndPlay(audio, endpoint, jwt, requestBody);
317
+ fallback();
284
318
  return;
285
319
  }
286
320
 
@@ -298,10 +332,8 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
298
332
  try {
299
333
  const headers: Record<string, string> = {
300
334
  'Content-Type': 'application/json',
335
+ 'Authorization': `${jwt}`
301
336
  };
302
- if (jwt) {
303
- headers['Authorization'] = jwt;
304
- }
305
337
 
306
338
  const response = await fetch(endpoint, {
307
339
  method: 'POST',
@@ -314,14 +346,15 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
314
346
  }
315
347
 
316
348
  const headerType = (response.headers.get('content-type') || '').split(';')[0].trim();
317
- const mime = (headerType && MediaSourceCtor.isTypeSupported(headerType))
318
- ? headerType
319
- : 'audio/mpeg';
349
+ if (headerType && !MediaSourceCtor.isTypeSupported(headerType)) {
350
+ // Fallback: fetch completo e play via blob (no streaming).
351
+ fallback();
352
+ return;
353
+ }
320
354
 
355
+ const mime = headerType || 'audio/mpeg';
321
356
  if (!MediaSourceCtor.isTypeSupported(mime)) {
322
- this.cleanupStreaming();
323
- // Fallback: fetch completo e play via blob (no streaming).
324
- this.fetchAsBlobAndPlay(audio, endpoint, jwt, requestBody);
357
+ fallback();
325
358
  return;
326
359
  }
327
360
 
@@ -362,15 +395,14 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
362
395
  ) as ArrayBuffer;
363
396
  sourceBuffer.appendBuffer(ab);
364
397
  } catch {
365
- this.cleanupStreaming();
366
- this.fetchAsBlobAndPlay(audio, endpoint, jwt, requestBody);
398
+ fallback();
367
399
  }
368
400
  };
369
401
 
370
402
  sourceBuffer.addEventListener('updateend', () => {
371
403
  if (!started && this.playbackStarted && !this.destroyed) {
372
404
  started = true;
373
- audio.play().catch(() => this.handlePlaybackError());
405
+ audio.play().catch(() => fallback());
374
406
  }
375
407
  pump();
376
408
  });
@@ -394,7 +426,7 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
394
426
  tryEndOfStream();
395
427
  } catch {
396
428
  if (!abort.signal.aborted) {
397
- this.handlePlaybackError();
429
+ fallback();
398
430
  }
399
431
  }
400
432
  };
@@ -463,10 +495,8 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
463
495
  try {
464
496
  const headers: Record<string, string> = {
465
497
  'Content-Type': 'application/json',
498
+ 'Authorization': `${jwt}`
466
499
  };
467
- if (jwt) {
468
- headers['Authorization'] = jwt;
469
- }
470
500
 
471
501
  const response = await fetch(endpoint, {
472
502
  method: 'POST',
@@ -493,16 +523,33 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
493
523
  }
494
524
  }
495
525
 
496
- private buildTtsRequestBody(voiceSettings: unknown): unknown {
526
+ private fetchFullFileFromEndpoint(audio: HTMLAudioElement, endpoint: string): void {
527
+ const jwt = this.getJwtToken();
528
+ const voiceSettings = this.getVoiceSettingsBody();
529
+ const requestBody = this.buildTtsRequestBody(voiceSettings, false);
530
+ void this.fetchAsBlobAndPlay(audio, endpoint, jwt, requestBody);
531
+ }
532
+
533
+ private buildTtsRequestBody(voiceSettings: unknown, streaming = true): unknown {
497
534
  const text = this.message?.text ?? '';
498
535
  if (
499
536
  voiceSettings &&
500
537
  typeof voiceSettings === 'object' &&
501
538
  !Array.isArray(voiceSettings)
502
539
  ) {
503
- return { ...(voiceSettings as Record<string, unknown>), text, streaming: true };
540
+ return {
541
+ outputFormat: BROWSER_TTS_OUTPUT_FORMAT,
542
+ ...(voiceSettings as Record<string, unknown>),
543
+ text,
544
+ streaming,
545
+ };
504
546
  }
505
- return { voiceSettings, text, streaming: true };
547
+ return {
548
+ voiceSettings,
549
+ text,
550
+ streaming,
551
+ outputFormat: BROWSER_TTS_OUTPUT_FORMAT,
552
+ };
506
553
  }
507
554
 
508
555
  private markAllWordsPast(): void {
@@ -1,9 +1,9 @@
1
1
  <div class="c21-icon-avatar">
2
2
  <div class="c21-avatar-image profile_image">
3
3
  <!-- is a BOT -->
4
- <img *ngIf="senderID?.indexOf('bot_') !== -1 || senderFullname.toLowerCase().includes('bot')" [src]="url" (error)="onBotImgError($event)" (load)="onLoadedBot($event)"/>
4
+ <img *ngIf="senderID?.indexOf('bot_') !== -1 || senderFullname.toLowerCase().includes('bot')" [src]="url" [attr.alt]="senderFullname || 'Bot'" role="img" (error)="onBotImgError($event)" (load)="onLoadedBot($event)"/>
5
5
  <!-- is a HUMAN -->
6
- <img *ngIf="senderID?.indexOf('bot_') === -1 && !senderFullname.toLowerCase().includes('bot')" [src]="url" (error)="onHumanImgError($event)" (load)="onLoadedHuman($event)"/>
6
+ <img *ngIf="senderID?.indexOf('bot_') === -1 && !senderFullname.toLowerCase().includes('bot')" [src]="url" [attr.alt]="senderFullname || 'User'" role="img" (error)="onHumanImgError($event)" (load)="onLoadedHuman($event)"/>
7
7
  </div>
8
8
  </div>
9
9