@chat21/chat21-web-widget 5.1.33 → 5.1.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,9 @@
6
6
  ### **Copyrigth**:
7
7
  *Tiledesk SRL*
8
8
 
9
+ # 5.1.34
10
+ - **bug fixed**: if last message is ulr_preview shows previous message buttons
11
+
9
12
  # 5.1.33
10
13
  - **bug fixed**: widget not loaded because blob block loading in lauch.js
11
14
 
package/angular.json CHANGED
@@ -44,7 +44,9 @@
44
44
  "src/environments/real_data/widget-config-docker.json",
45
45
  "src/environments/real_data/widget-config-native-mqtt.json",
46
46
  "src/environments/real_data/widget-config-native-prod.json",
47
- "src/environments/real_data/widget-config-aws-stage.json"
47
+ "src/environments/real_data/widget-config-aws-stage.json",
48
+ "src/environments/real_data/widget-config-aws-aruba.json",
49
+ "src/environments/real_data/widget-config-regione-puglia.json"
48
50
  ],
49
51
  "styles": [
50
52
  "src/app/sass/styles.scss"
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.33",
4
+ "version": "5.1.34",
5
5
  "license": "MIT",
6
6
  "homepage": "https://www.tiledesk.com",
7
7
  "repository": {
@@ -138,6 +138,7 @@ import { CarouselComponent } from './component/message/carousel/carousel.compone
138
138
  import { BrandService } from './providers/brand.service';
139
139
  import { ErrorAlertComponent } from './component/error-alert/error-alert.component';
140
140
  import { ConfirmCloseComponent } from './modals/confirm-close/confirm-close.component';
141
+ import { JsonSourcesComponent } from './component/message/json-sources/json-sources.component';
141
142
 
142
143
 
143
144
 
@@ -309,6 +310,7 @@ export function uploadFactory(http: HttpClient, appConfig: AppConfigService, app
309
310
  LikeUnlikeComponent,
310
311
  TooltipDirective,
311
312
  CarouselComponent,
313
+ JsonSourcesComponent,
312
314
  ErrorAlertComponent,
313
315
  ConfirmCloseComponent
314
316
  ],
@@ -164,6 +164,12 @@
164
164
  min-width: 14px;
165
165
  border: 0.1px solid #0000000f;
166
166
  }
167
+ .msg_sent.json-resources{
168
+ border: 0 !important;
169
+ width: 100%;
170
+ max-width: 652px;
171
+ flex: 1 1 auto;
172
+ }
167
173
  .message_innerhtml {
168
174
  padding: 8px;
169
175
  }
@@ -237,6 +243,13 @@
237
243
  width: auto;
238
244
 
239
245
  }
246
+ .msg_receive.json-resources{
247
+ min-height: unset;
248
+ padding: 0;
249
+ width: 100%;
250
+ max-width: 652px;
251
+ flex: 1 1 auto;
252
+ }
240
253
 
241
254
 
242
255
  .message_innerhtml {
@@ -332,4 +345,4 @@
332
345
  }
333
346
  }
334
347
 
335
- // ============= END CSS c21-body ================= //
348
+ // ============= END CSS c21-body ================= //
@@ -92,7 +92,6 @@ export class ConversationContentComponent implements OnInit {
92
92
 
93
93
  }
94
94
 
95
-
96
95
  /**
97
96
  *
98
97
  * @param message
@@ -20,7 +20,7 @@
20
20
 
21
21
 
22
22
 
23
- <div id="bubble-message" class="messages primary-color">
23
+ <div id="bubble-message" *ngIf="hasRenderableContent()" class="messages primary-color">
24
24
  <div>
25
25
 
26
26
  <div *ngIf="messageType(MESSAGE_TYPE_OTHERS, message) && !isSameSender"
@@ -58,12 +58,17 @@
58
58
  (onElementRendered)="onElementRenderedFN($event)">
59
59
  </chat-audio> -->
60
60
 
61
- <chat-audio *ngIf="isAudio(message)"
61
+ <chat-audio *ngIf="isAudio(message)"
62
62
  [metadata]="message.metadata"
63
63
  [color]="fontColor"
64
64
  [stylesMap]="stylesMap">
65
65
  </chat-audio>
66
66
 
67
+ <chat-json-sources *ngIf="jsonSources !== null && jsonSources.length > 0"
68
+ [items]="jsonSources"
69
+ (onElementRendered)="onElementRenderedFN($event)">
70
+ </chat-json-sources>
71
+
67
72
 
68
73
  <!-- <chat-frame *ngIf="message.metadata && message.metadata.type && message.metadata.type.includes('video')"
69
74
  [metadata]="message.metadata"
@@ -75,17 +80,17 @@
75
80
  <!-- <div *ngIf="message.type == 'text'"> -->
76
81
 
77
82
  <!-- tooltip="{{message.timestamp | dateAgo}} ({{message.timestamp | date:'shortDate'}} {{message.timestamp | date:'HH:mm:ss'}})" placement="bottom" -->
78
- <div *ngIf="message?.text && !isAudio(message)" >
83
+ <div *ngIf="(message?.text && !isAudio(message)) && !isJsonSources(message)">
79
84
 
80
85
  <!-- [htmlEnabled]="(message?.type==='html')? true : false" -->
81
- <chat-text *ngIf="message?.type !=='html'"
86
+ <chat-text *ngIf="jsonSources === null && message?.type !=='html'"
82
87
  [text]="message?.text"
83
88
  [color]="fontColor"
84
89
  (onBeforeMessageRender)="onBeforeMessageRenderFN($event)"
85
90
  (onAfterMessageRender)="onAfterMessageRenderFN($event)">
86
91
  </chat-text>
87
92
 
88
- <chat-html *ngIf="message?.type==='html'"
93
+ <chat-html *ngIf="jsonSources === null && message?.type==='html'"
89
94
  [htmlText]="message?.text"
90
95
  [fontSize]="stylesMap.get('buttonFontSize')"
91
96
  [themeColor]="stylesMap.get('themeColor')"
@@ -1,5 +1,10 @@
1
1
  /* ====== SET MESSAGES ====== */
2
2
 
3
+ :host(.hidden-bubble) {
4
+ display: none !important;
5
+ }
6
+
7
+
3
8
  .messages {
4
9
  border-radius: var(--border-radius-bubble-message);
5
10
  padding: 0;
@@ -1,148 +1,113 @@
1
- import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
1
+ import { Component, EventEmitter, HostBinding, Input, Output } from '@angular/core';
2
2
  import { DomSanitizer } from '@angular/platform-browser';
3
3
  import { MessageModel } from 'src/chat21-core/models/message';
4
- import { LoggerService } from 'src/chat21-core/providers/abstract/logger.service';
5
- import { LoggerInstance } from 'src/chat21-core/providers/logger/loggerInstance';
6
- import { MAX_WIDTH_IMAGES, MESSAGE_TYPE_MINE, MESSAGE_TYPE_OTHERS, MIN_WIDTH_IMAGES } from 'src/chat21-core/utils/constants';
4
+ import { MESSAGE_TYPE_MINE, MESSAGE_TYPE_OTHERS, TYPE_MSG_URL_PREVIEW } from 'src/chat21-core/utils/constants';
7
5
  import { convertColorToRGBA } from 'src/chat21-core/utils/utils';
8
- import { isAudio, isFile, isFrame, isImage, messageType } from 'src/chat21-core/utils/utils-message';
6
+ import { calcImageSize, isAudio, isFile, isFrame, isImage, isJsonSources, messageType } from 'src/chat21-core/utils/utils-message';
9
7
  import { getColorBck } from 'src/chat21-core/utils/utils-user';
8
+ import { JsonSourcesParserService } from 'src/app/providers/json-sources-parser.service';
9
+ import { JsonSourceItem } from '../json-sources/json-sources.component';
10
10
 
11
11
  @Component({
12
12
  selector: 'chat-bubble-message',
13
13
  templateUrl: './bubble-message.component.html',
14
14
  styleUrls: ['./bubble-message.component.scss']
15
15
  })
16
- export class BubbleMessageComponent implements OnInit {
16
+ export class BubbleMessageComponent {
17
17
 
18
18
  @Input() message: MessageModel;
19
19
  @Input() isSameSender: boolean;
20
20
  @Input() fontColor: string;
21
21
  @Input() stylesMap: Map<string, string>;
22
+
22
23
  @Output() onBeforeMessageRender = new EventEmitter();
23
24
  @Output() onAfterMessageRender = new EventEmitter();
24
- @Output() onElementRendered = new EventEmitter<{element: string, status: boolean}>();
25
- isImage = isImage;
26
- isFile = isFile;
27
- isFrame = isFrame;
28
- isAudio = isAudio;
29
- convertColorToRGBA = convertColorToRGBA
30
-
31
- // ========== begin:: check message type functions ======= //
32
- messageType = messageType;
33
-
34
- MESSAGE_TYPE_MINE = MESSAGE_TYPE_MINE;
35
- MESSAGE_TYPE_OTHERS = MESSAGE_TYPE_OTHERS;
36
- // ========== end:: check message type functions ======= //
37
- sizeImage : { width: number, height: number}
38
- fullnameColor: string;
39
- private logger: LoggerService = LoggerInstance.getInstance()
40
- constructor(public sanitizer: DomSanitizer) { }
41
-
42
- ngOnInit() {
43
- // console.log("---- > MSG:", this.message);
25
+ @Output() onElementRendered = new EventEmitter<{ element: string; status: boolean }>();
26
+
27
+ @HostBinding('class.no-background') get hostNoBackground() { return this.jsonSources !== null && this.jsonSources.length > 0; }
28
+ @HostBinding('class.json-resources') get hostIsJsonResources() { return this.jsonSources !== null && this.jsonSources.length > 0; }
29
+ @HostBinding('class.hidden-bubble') get hostHiddenBubble() { return !this.hasRenderableContent(); }
30
+
31
+ hasRenderableContent(): boolean {
32
+ const msg = this.message;
33
+ if (!msg) return false;
34
+ if (isImage(msg) || isFile(msg) || isFrame(msg) || isAudio(msg)) return true;
35
+ if (this.jsonSources && this.jsonSources.length > 0) return true;
36
+ // For url_preview messages, `text` may carry the raw JSON payload (not display text):
37
+ // if sources parsing yielded nothing, the bubble must stay hidden.
38
+ if (this.isUrlPreviewMessage) return false;
39
+ return !!(msg.text && String(msg.text).trim().length > 0);
44
40
  }
45
41
 
46
- ngOnChanges() {
47
- if (this.message && this.message.metadata && typeof this.message.metadata === 'object' ) {
48
- this.sizeImage = this.getMetadataSize(this.message.metadata)
49
- }
42
+ readonly isImage = isImage;
43
+ readonly isFile = isFile;
44
+ readonly isFrame = isFrame;
45
+ readonly isAudio = isAudio;
46
+ readonly isJsonSources = isJsonSources;
47
+ readonly messageType = messageType;
48
+ readonly convertColorToRGBA = convertColorToRGBA;
49
+ readonly MESSAGE_TYPE_MINE = MESSAGE_TYPE_MINE;
50
+ readonly MESSAGE_TYPE_OTHERS = MESSAGE_TYPE_OTHERS;
51
+
52
+ sizeImage: { width: number; height: number };
53
+ fullnameColor: string;
54
+ jsonSources: JsonSourceItem[] | null = null;
55
+ isUrlPreviewMessage = false;
50
56
 
51
- if(this.fontColor){
52
- this.fullnameColor = convertColorToRGBA(this.fontColor, 65)
53
- }
54
- if(this.message && this.message.sender_fullname && this.message.sender_fullname.trim() !== ''){
55
- this.fullnameColor = getColorBck(this.message.sender_fullname)
56
- }
57
+ private urlPreviewReqId = 0;
57
58
 
58
- }
59
+ constructor(
60
+ public sanitizer: DomSanitizer,
61
+ private jsonSourcesParser: JsonSourcesParserService
62
+ ) {}
59
63
 
60
- /**
61
- *
62
- * @param message
63
- */
64
- // getMetadataSize(metadata): any {
65
- // if(metadata.width === undefined){
66
- // metadata.width= MAX_WIDTH_IMAGES
67
- // }
68
- // if(metadata.height === undefined){
69
- // metadata.height = MAX_WIDTH_IMAGES
70
- // }
71
- // // const MAX_WIDTH_IMAGES = 300;
72
- // const sizeImage = {
73
- // width: metadata.width,
74
- // height: metadata.height
75
- // };
76
- // // that.g.wdLog(['message::: ', metadata);
77
- // if (metadata.width && metadata.width > MAX_WIDTH_IMAGES) {
78
- // const rapporto = (metadata['width'] / metadata['height']);
79
- // sizeImage.width = MAX_WIDTH_IMAGES;
80
- // sizeImage.height = MAX_WIDTH_IMAGES / rapporto;
81
- // }
82
- // return sizeImage; // h.toString();
83
- // }
84
-
85
- /**
86
- *
87
- * @param message
88
- */
89
- getMetadataSize(metadata): {width, height} {
90
- // if (metadata.width === undefined) {
91
- // metadata.width = MAX_WIDTH_IMAGES
92
- // }
93
- // if (metadata.height === undefined) {
94
- // metadata.height = MAX_WIDTH_IMAGES
95
- // }
96
-
97
- const sizeImage = {
98
- width: metadata.width,
99
- height: metadata.height
100
- };
101
-
102
-
103
- if (metadata.width && metadata.width < MAX_WIDTH_IMAGES) {
104
- if (metadata.width <= 55) {
105
- const ratio = (metadata['width'] / metadata['height']);
106
- sizeImage.width = MIN_WIDTH_IMAGES;
107
- sizeImage.height = MIN_WIDTH_IMAGES / ratio;
108
- } else if (metadata.width > 55) {
109
- sizeImage.width = metadata.width;
110
- sizeImage.height = metadata.height
111
- }
112
- } else if (metadata.width && metadata.width > MAX_WIDTH_IMAGES) {
113
- const ratio = (metadata['width'] / metadata['height']);
114
- sizeImage.width = MAX_WIDTH_IMAGES;
115
- sizeImage.height = MAX_WIDTH_IMAGES / ratio;
64
+ ngOnChanges(): void {
65
+ if (this.message?.metadata && typeof this.message.metadata === 'object') {
66
+ this.sizeImage = calcImageSize(this.message.metadata);
116
67
  }
117
- return sizeImage
118
- }
119
68
 
120
- // ========= begin:: event emitter function ============//
69
+ this.fullnameColor = this.fontColor
70
+ ? convertColorToRGBA(this.fontColor, 65)
71
+ : this.fullnameColor;
121
72
 
122
- // returnOpenAttachment(event: String) {
123
- // this.onOpenAttachment.emit(event)
124
- // }
73
+ if (this.message?.sender_fullname?.trim()) {
74
+ this.fullnameColor = getColorBck(this.message.sender_fullname);
75
+ }
125
76
 
126
- // /** */
127
- // returnClickOnAttachmentButton(event: any) {
128
- // this.onClickAttachmentButton.emit(event)
129
- // }
77
+ // Reset on every message change: we must not "leak" sources across different messages.
78
+ this.jsonSources = null;
130
79
 
131
- onBeforeMessageRenderFN(event){
132
- const messageOBJ = { message: this.message, sanitizer: this.sanitizer, messageEl: event.messageEl, component: event.component}
133
- this.onBeforeMessageRender.emit(messageOBJ)
80
+ // url_preview payload can live on message root OR inside metadata/attributes depending on the integration.
81
+ const urlPreviewLike =
82
+ this.message?.type === TYPE_MSG_URL_PREVIEW
83
+ || this.message?.metadata?.type === TYPE_MSG_URL_PREVIEW
84
+ || this.message?.attributes?.type === TYPE_MSG_URL_PREVIEW;
85
+ this.isUrlPreviewMessage = !!urlPreviewLike;
86
+ if (urlPreviewLike) this.loadJsonSourcesFromUrlPreviewMessage();
134
87
  }
135
88
 
136
- onAfterMessageRenderFN(event){
137
- const messageOBJ = { message: this.message, sanitizer: this.sanitizer, messageEl: event.messageEl, component: event.component}
138
- this.onAfterMessageRender.emit(messageOBJ)
89
+ private async loadJsonSourcesFromUrlPreviewMessage(): Promise<void> {
90
+ // Protect the UI from out-of-order async responses when the input `message` changes quickly.
91
+ const reqId = ++this.urlPreviewReqId;
92
+ // 1) Parse-only, so the UI can render immediately (no url-preview calls).
93
+ const baseSources = this.jsonSourcesParser.parseBaseFromMessage(this.message);
94
+ this.jsonSources = baseSources;
95
+
96
+ // 2) Enrich in background via url-preview, then merge missing fields.
97
+ const enriched = await this.jsonSourcesParser.enrichSources(baseSources);
98
+ if (reqId !== this.urlPreviewReqId) return;
99
+ this.jsonSources = enriched;
139
100
  }
140
101
 
141
- onElementRenderedFN(event){
142
- this.onElementRendered.emit({element: event.element, status: event.status})
102
+ onBeforeMessageRenderFN(event: any): void {
103
+ this.onBeforeMessageRender.emit({ message: this.message, sanitizer: this.sanitizer, messageEl: event.messageEl, component: event.component });
143
104
  }
144
105
 
145
- // ========= END:: event emitter function ============//
146
-
106
+ onAfterMessageRenderFN(event: any): void {
107
+ this.onAfterMessageRender.emit({ message: this.message, sanitizer: this.sanitizer, messageEl: event.messageEl, component: event.component });
108
+ }
147
109
 
110
+ onElementRenderedFN(event: any): void {
111
+ this.onElementRendered.emit({ element: event.element, status: event.status });
112
+ }
148
113
  }
@@ -0,0 +1,38 @@
1
+ <div class="sources-panel" *ngIf="items && items.length > 0">
2
+
3
+ <div class="sources-header">
4
+ <div class="sources-favicons">
5
+ <img class="header-favicon"
6
+ *ngFor="let item of headerFavicons; trackBy: trackByLink"
7
+ [src]="getFavicon(item)"
8
+ alt=""
9
+ loading="lazy" />
10
+ </div>
11
+ <span class="sources-count">{{ items.length }} {{ items.length === 1 ? 'sito' : 'siti' }}</span>
12
+ </div>
13
+
14
+ <a class="source-row"
15
+ *ngFor="let item of visibleItems; trackBy: trackByLink"
16
+ [href]="item.link"
17
+ target="_blank"
18
+ rel="noopener noreferrer">
19
+
20
+ <div class="source-row__left">
21
+ <div class="source-row__title">{{ item.title }}</div>
22
+ <div class="source-row__desc" *ngIf="item.description">{{ item.description }}</div>
23
+ <div class="source-row__meta">
24
+ <img class="source-row__favicon" *ngIf="getFavicon(item)" [src]="getFavicon(item)" alt="" loading="lazy" />
25
+ <span class="source-row__host">{{ getHostname(item) }}</span>
26
+ </div>
27
+ </div>
28
+
29
+ <div class="source-row__thumb" *ngIf="item.image">
30
+ <img [src]="item.image" alt="" loading="lazy" (error)="$event.target.closest('.source-row__thumb').remove()" />
31
+ </div>
32
+ </a>
33
+
34
+ <button class="show-all" *ngIf="canExpand" type="button" (click)="toggleShowAll()">
35
+ {{ showAll ? 'Mostra meno' : 'Mostra tutto' }}
36
+ </button>
37
+ </div>
38
+
@@ -0,0 +1,201 @@
1
+ :host {
2
+ --panel-bck: #ffffff; // #f7f8fa;
3
+ --row-sep: rgba(66, 133, 244, 0.18);
4
+ --text: rgba(0, 0, 0, 0.90);
5
+ --muted: rgba(0, 0, 0, 0.62);
6
+ --accent: #1a73e8;
7
+ --pill-bck: #eaebf1;
8
+
9
+ font-family: "Google Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
10
+ -webkit-font-smoothing: antialiased;
11
+ -moz-osx-font-smoothing: grayscale;
12
+
13
+ --title-line-height: 1.4;
14
+ --title-font-size: 14px;
15
+
16
+ --desc-line-height: 1.3;
17
+ --desc-font-size: 12px;
18
+
19
+ display: block;
20
+ width: 100%;
21
+ max-width: 652px;
22
+ }
23
+
24
+ .sources-header {
25
+ display: none;
26
+ align-items: center;
27
+ gap: 8px;
28
+ padding: 2px 4px 8px 4px;
29
+ border-bottom: 1px solid var(--row-sep);
30
+ }
31
+
32
+ .sources-favicons {
33
+ display: flex;
34
+ align-items: center;
35
+ }
36
+
37
+ .header-favicon {
38
+ width: 18px;
39
+ height: 18px;
40
+ border-radius: 50%;
41
+ border: 2px solid #fff;
42
+ background: rgba(255, 255, 255, 0.7);
43
+ object-fit: cover;
44
+
45
+ & + & {
46
+ margin-left: -2px;
47
+ }
48
+ }
49
+
50
+ .sources-count {
51
+ font-size: 13px;
52
+ font-weight: 500;
53
+ color: var(--muted);
54
+ }
55
+
56
+ .sources-panel {
57
+ display: flex;
58
+ flex-direction: column;
59
+ padding: 18px 12px 10px 12px;
60
+ background: var(--panel-bck);
61
+ border-radius: 18px;
62
+ font-size: 14px;
63
+ width: 100%;
64
+ max-width: 652px;
65
+ box-sizing: border-box;
66
+ }
67
+
68
+ .source-row {
69
+ display: flex;
70
+ align-items: stretch;
71
+ justify-content: space-between;
72
+ gap: 14px;
73
+ text-decoration: none;
74
+ color: inherit;
75
+ padding: 10px 10px;
76
+ border-radius: 12px;
77
+ transition: background 140ms ease;
78
+ font-family: 'Google Sans', Arial, sans-serif;
79
+ width: 100%;
80
+ box-sizing: border-box;
81
+
82
+ border-bottom: 1px solid var(--row-sep);
83
+ border-bottom-left-radius: 0;
84
+ border-bottom-right-radius: 0;
85
+
86
+ &:hover {
87
+ background: rgba(255, 255, 255, 0.55);
88
+ .source-row__title {
89
+ text-decoration: underline;
90
+ }
91
+ }
92
+
93
+ // & + & {
94
+ // border-top: 1px solid var(--row-sep);
95
+ // border-top-left-radius: 0;
96
+ // border-top-right-radius: 0;
97
+ // }
98
+
99
+ .source-row__left {
100
+ min-width: 0;
101
+ display: flex;
102
+ flex-direction: column;
103
+ gap: 6px;
104
+ flex: 1 1 auto;
105
+ }
106
+
107
+ .source-row__title {
108
+ font-weight: 500;
109
+ color: var(--text);
110
+ font-size: var(--title-font-size);
111
+ line-height: var(--title-line-height);
112
+ margin: 0px;
113
+ min-width: 0;
114
+ overflow: hidden;
115
+ text-overflow: ellipsis;
116
+ display: -webkit-box;
117
+ line-clamp: 2;
118
+ -webkit-line-clamp: 2;
119
+ -webkit-box-orient: vertical;
120
+ white-space: normal;
121
+ }
122
+
123
+ .source-row__desc {
124
+ font-size: var(--desc-font-size);
125
+ line-height: var(--desc-line-height);
126
+ overflow: hidden;
127
+ text-overflow: ellipsis;
128
+ display: -webkit-box;
129
+ line-clamp: 2;
130
+ -webkit-line-clamp: 2;
131
+ -webkit-box-orient: vertical;
132
+ color: #56595e;
133
+ margin: 0;
134
+ }
135
+
136
+ .source-row__meta {
137
+ display: flex;
138
+ align-items: center;
139
+ gap: 8px;
140
+ margin-top: auto;
141
+ padding-top: 2px;
142
+ }
143
+
144
+ .source-row__favicon {
145
+ width: 16px;
146
+ height: 16px;
147
+ border-radius: 50%;
148
+ flex: 0 0 auto;
149
+ border: 2px solid #fff;
150
+ background: rgba(255, 255, 255, 0.7);
151
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
152
+ }
153
+
154
+ .source-row__host {
155
+ color: rgba(0, 0, 0, 0.55);
156
+ font-size: 12px;
157
+ overflow: hidden;
158
+ text-overflow: ellipsis;
159
+ white-space: nowrap;
160
+ min-width: 0;
161
+ }
162
+
163
+ .source-row__thumb {
164
+ width: 56px;
165
+ height: 56px;
166
+ border-radius: 10px;
167
+ overflow: hidden;
168
+ flex: 0 0 auto;
169
+ border: 1px solid rgba(0, 0, 0, 0.06);
170
+ background: rgba(255, 255, 255, 0.65);
171
+ align-self: flex-start;
172
+ display: flex;
173
+ justify-content: center;
174
+ align-items: center;
175
+ }
176
+
177
+ .source-row__thumb img {
178
+ width: 100%;
179
+ height: 100%;
180
+ object-fit: cover;
181
+ display: block;
182
+ }
183
+ }
184
+
185
+ .show-all {
186
+ margin-top: 10px;
187
+ border: 0;
188
+ background: var(--pill-bck);
189
+ color: rgba(0, 0, 0, 0.78);
190
+ font-weight: 500;
191
+ padding: 10px 12px;
192
+ border-radius: 999px;
193
+ cursor: pointer;
194
+ transition: filter 140ms ease, transform 140ms ease;
195
+ }
196
+
197
+ .show-all:hover {
198
+ filter: brightness(0.98);
199
+ transform: translateY(-1px);
200
+ }
201
+
@@ -0,0 +1,89 @@
1
+ import { Component, EventEmitter, Input, Output } from '@angular/core';
2
+ import { getTopLevelDomainFromUrl } from 'src/app/utils/url-utils';
3
+
4
+ export type JsonSourceItem = {
5
+ title?: string;
6
+ link?: string;
7
+ description?: string;
8
+ favicon?: string;
9
+ favicon_hd?: string;
10
+ image?: string;
11
+ };
12
+
13
+ @Component({
14
+ selector: 'chat-json-sources',
15
+ templateUrl: './json-sources.component.html',
16
+ styleUrls: ['./json-sources.component.scss']
17
+ })
18
+ export class JsonSourcesComponent {
19
+ @Input() items: JsonSourceItem[] = [];
20
+ @Input() themeColor?: string;
21
+ @Input() limit = 3;
22
+
23
+ @Output() onElementRendered = new EventEmitter<{ element: string; status: boolean }>();
24
+
25
+ showAll = false;
26
+
27
+ trackByLink = (_: number, item: JsonSourceItem) => item?.link || item?.title || _;
28
+
29
+ ngAfterViewInit() {
30
+ this.onElementRendered.emit({ element: 'json_sources', status: true });
31
+ }
32
+
33
+ getFavicon(item: JsonSourceItem): string | null {
34
+ const domain = getTopLevelDomainFromUrl(item?.link || '');
35
+ if (!domain) return null;
36
+ const explicit = (item?.favicon_hd || item?.favicon || '').trim();
37
+ if (explicit) {
38
+ try {
39
+ const faviconDomain = getTopLevelDomainFromUrl(explicit);
40
+ return faviconDomain ? explicit.replace(new URL(explicit).hostname, faviconDomain) : explicit;
41
+ } catch {
42
+ return explicit;
43
+ }
44
+ }
45
+ return `https://favicon.im/${domain}`;
46
+ //return `https://${domain}/favicon.ico`;
47
+ }
48
+
49
+ getHostname(item: JsonSourceItem): string {
50
+ const hostname = this.safeHostname(item?.link || '');
51
+ return hostname || '';
52
+ }
53
+
54
+ private safeHostname(url: string): string {
55
+ try {
56
+ return new URL(url).hostname.replace(/^www\./, '');
57
+ } catch {
58
+ return '';
59
+ }
60
+ }
61
+
62
+ get visibleItems(): JsonSourceItem[] {
63
+ if (!this.items) return [];
64
+ const normalizedLimit = Math.max(1, this.limit || 1);
65
+ return this.showAll ? this.items : this.items.slice(0, normalizedLimit);
66
+ }
67
+
68
+ get headerFavicons(): JsonSourceItem[] {
69
+ const seen = new Set<string>();
70
+ return (this.items || []).reduce<JsonSourceItem[]>((acc, item) => {
71
+ const url = this.getFavicon(item);
72
+ if (url && !seen.has(url)) {
73
+ seen.add(url);
74
+ acc.push(item);
75
+ }
76
+ return acc;
77
+ }, []).slice(0, 3);
78
+ }
79
+
80
+ get canExpand(): boolean {
81
+ const normalizedLimit = Math.max(1, this.limit || 1);
82
+ return (this.items?.length || 0) > normalizedLimit;
83
+ }
84
+
85
+ toggleShowAll() {
86
+ this.showAll = !this.showAll;
87
+ }
88
+ }
89
+
@@ -0,0 +1,175 @@
1
+ import { Injectable } from '@angular/core';
2
+ import { JSON_SOURCE_FIELD_TITLE, JSON_SOURCE_FIELD_URL } from 'src/chat21-core/utils/constants';
3
+ import { UrlPreviewService } from 'src/app/providers/url-preview.service';
4
+ import { extractUrlsFromText } from 'src/app/utils/url-utils';
5
+ import { JsonSourceItem } from 'src/app/component/message/json-sources/json-sources.component';
6
+ import { mergeJsonSourcesMissingFields } from 'src/app/utils/json-sources-utils';
7
+
8
+ export type UrlPreviewMessage = {
9
+ type?: string; // "url_preview"
10
+ text?: string;
11
+ };
12
+
13
+ /**
14
+ * Parse and enrich "url_preview" messages into `JsonSourceItem[]`.
15
+ *
16
+ * Rules:
17
+ * - The payload is always read from `msg.text`, regardless of `activeMode`.
18
+ * - `msg.text` may be either:
19
+ * - a JSON array of source objects (`{source_name, source_file_name, ...}`), or
20
+ * - a plain string from which URLs are extracted (split by whitespace/punctuation).
21
+ * - After building the initial array, `url-preview` is called only for items that miss
22
+ * title or description, and missing fields are merged in (never overwriting).
23
+ */
24
+ @Injectable({ providedIn: 'root' })
25
+ export class JsonSourcesParserService {
26
+ constructor(private urlPreviewService: UrlPreviewService) {}
27
+
28
+ /**
29
+ * Parse-only: returns sources immediately (no url-preview calls).
30
+ * Use this to render the list instantly, then call `enrichSources()` in background.
31
+ */
32
+ parseBaseFromMessage(messageLike?: any): JsonSourceItem[] | null {
33
+ const payload = this.getUrlPreviewPayload(messageLike);
34
+ return this.parseBaseJsonSources(payload);
35
+ }
36
+
37
+ /**
38
+ * Parse + enrich: kept for backward compatibility with older callers.
39
+ * If you need instant rendering, prefer `parseBaseFromMessage()` + `enrichSources()`.
40
+ */
41
+ /**
42
+ * Best-practice entrypoint for UI components:
43
+ * accepts a full `MessageModel`/message-like object, and supports url_preview payload
44
+ * living either on the root message OR inside `metadata` OR inside `attributes`.
45
+ */
46
+ async parseFromMessage(messageLike?: any): Promise<JsonSourceItem[] | null> {
47
+ const base = this.parseBaseFromMessage(messageLike);
48
+ return this.enrichSources(base);
49
+ }
50
+
51
+ async enrichSources(baseSources?: JsonSourceItem[] | null): Promise<JsonSourceItem[] | null> {
52
+ const sources = (baseSources || []).filter((s) => !!s?.link);
53
+ if (sources.length === 0) return baseSources || null;
54
+
55
+ // Only call url-preview for items missing the most relevant fields.
56
+ const incompleteUrls = sources
57
+ .filter(s => !!s.link && (!s.title || !s.description))
58
+ .map(s => s.link!)
59
+ .slice(0, 10);
60
+
61
+ if (incompleteUrls.length === 0) return sources;
62
+
63
+ const previews = await this.urlPreviewService.previewUrls(incompleteUrls);
64
+ const previewItems: JsonSourceItem[] = (previews || []).map(p => ({
65
+ link: p.url,
66
+ title: p.title || p.siteName || p.url,
67
+ description: p.description,
68
+ image: p.image,
69
+ favicon: p.favicon,
70
+ favicon_hd: p.favicon_hd
71
+ }));
72
+
73
+ if (previewItems.length === 0) return sources;
74
+ return mergeJsonSourcesMissingFields(sources, previewItems);
75
+ }
76
+
77
+ async parseJsonSources(msg?: UrlPreviewMessage | null): Promise<JsonSourceItem[] | null> {
78
+ const base = this.parseBaseJsonSources(msg);
79
+ return this.enrichSources(base);
80
+ }
81
+
82
+ private getUrlPreviewPayload(messageLike?: any): UrlPreviewMessage | null {
83
+ if (!messageLike) return null;
84
+ const candidates: any[] = [
85
+ messageLike,
86
+ (messageLike?.metadata && typeof messageLike.metadata === 'object') ? messageLike.metadata : null,
87
+ (messageLike?.attributes && typeof messageLike.attributes === 'object') ? messageLike.attributes : null
88
+ ].filter(Boolean);
89
+ return (candidates.find((c) => c?.type === 'url_preview') || null) as UrlPreviewMessage | null;
90
+ }
91
+
92
+ private parseBaseJsonSources(msg?: UrlPreviewMessage | null): JsonSourceItem[] | null {
93
+ if (!msg || msg.type !== 'url_preview') return null;
94
+
95
+ // Regardless of `activeMode`, the payload is always read from `msg.text`.
96
+ // It can be either a JSON array of source objects, or a plain string with URLs.
97
+ return this.isJsonArrayOfObjects(msg.text)
98
+ ? this.mapTextToSources(msg.text)
99
+ : this.mapListToSources(msg.text);
100
+ }
101
+
102
+ private mapListToSources(listValue?: string): JsonSourceItem[] | null {
103
+ const urls = extractUrlsFromText((listValue || '').toString(), 10);
104
+ return urls.length ? urls.map(u => ({ link: u, title: u })) : null;
105
+ }
106
+
107
+ private isJsonArrayOfObjects(text?: string): boolean {
108
+ if (!text) return false;
109
+ try {
110
+ const parsed = this.parseJsonLenient(text);
111
+ return Array.isArray(parsed) && parsed.some(it => it && typeof it === 'object' && !Array.isArray(it));
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ private mapTextToSources(text?: string): JsonSourceItem[] | null {
118
+ if (!text) return null;
119
+ try {
120
+ const parsed = this.parseJsonLenient(text);
121
+ return this.mapSourcesArray(parsed);
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ private mapSourcesArray(input: any): JsonSourceItem[] | null {
128
+ const arr = Array.isArray(input) ? input : null;
129
+ if (!arr || arr.length === 0) return null;
130
+ const mapped = arr
131
+ .filter((s: any) => s && typeof s === 'object' && typeof s[JSON_SOURCE_FIELD_URL] === 'string')
132
+ .map((s: any): JsonSourceItem | null => {
133
+ const rawUrl = (s[JSON_SOURCE_FIELD_URL] || '').toString().trim();
134
+ const normalized = extractUrlsFromText(rawUrl, 1)[0];
135
+ if (!normalized) return null;
136
+ return {
137
+ link: normalized,
138
+ title: (s[JSON_SOURCE_FIELD_TITLE] || rawUrl).toString(),
139
+ description: typeof s.source_description === 'string' ? s.source_description : undefined,
140
+ image: typeof s.source_image === 'string' ? s.source_image : undefined
141
+ };
142
+ })
143
+ .filter((x: JsonSourceItem | null): x is JsonSourceItem => !!x && !!x.link);
144
+ return mapped.length ? mapped : null;
145
+ }
146
+
147
+ private parseJsonLenient(input: string): any {
148
+ const trimmed = (input || '').trim();
149
+ try {
150
+ const parsed = JSON.parse(trimmed);
151
+ if (typeof parsed === 'string') {
152
+ const inner = parsed.trim();
153
+ if ((inner.startsWith('[') && inner.endsWith(']')) || (inner.startsWith('{') && inner.endsWith('}'))) {
154
+ return this.parseJsonLenient(inner);
155
+ }
156
+ }
157
+ return parsed;
158
+ } catch {
159
+ const cleaned = trimmed
160
+ .replace(/^```(?:json)?\s*/i, '')
161
+ .replace(/```$/i, '')
162
+ .trim()
163
+ .replace(/,\s*([}\]])/g, '$1');
164
+ const parsed = JSON.parse(cleaned);
165
+ if (typeof parsed === 'string') {
166
+ const inner = parsed.trim();
167
+ if ((inner.startsWith('[') && inner.endsWith(']')) || (inner.startsWith('{') && inner.endsWith('}'))) {
168
+ return this.parseJsonLenient(inner);
169
+ }
170
+ }
171
+ return parsed;
172
+ }
173
+ }
174
+ }
175
+
@@ -0,0 +1,82 @@
1
+ import { HttpClient, HttpHeaders } from '@angular/common/http';
2
+ import { Injectable } from '@angular/core';
3
+ import { firstValueFrom } from 'rxjs';
4
+ import { AppConfigService } from './app-config.service';
5
+ import { Globals } from '../utils/globals';
6
+
7
+ export type UrlPreviewItem = {
8
+ url: string;
9
+ title?: string;
10
+ description?: string;
11
+ image?: string;
12
+ siteName?: string;
13
+ favicon?: string;
14
+ favicon_hd?: string;
15
+ };
16
+
17
+ @Injectable({
18
+ providedIn: 'root'
19
+ })
20
+ export class UrlPreviewService {
21
+ constructor(
22
+ private http: HttpClient,
23
+ private appConfigService: AppConfigService,
24
+ private g: Globals
25
+ ) {}
26
+
27
+ async previewUrls(urls: string[]): Promise<UrlPreviewItem[]> {
28
+ const apiUrl = this.appConfigService.getConfig()?.apiUrl;
29
+ const projectId = this.g.projectid;
30
+
31
+ const cleaned = (urls || []).map((u) => (u || '').trim()).filter(Boolean).slice(0, 10);
32
+ if (!apiUrl || !projectId || cleaned.length === 0) return [];
33
+
34
+ const base = apiUrl.endsWith('/') ? apiUrl : apiUrl + '/';
35
+ const url = `${base}${projectId}/url-preview`;
36
+
37
+ const token = this.g.tiledeskToken;
38
+ const headers = new HttpHeaders({
39
+ Accept: 'application/json',
40
+ 'Content-Type': 'application/json',
41
+ Authorization: token || ''
42
+ });
43
+
44
+ const body = { urls: cleaned };
45
+
46
+ try {
47
+ const res = await firstValueFrom(
48
+ this.http.post<any>(url, body, { headers })
49
+ );
50
+
51
+ // Expected response:
52
+ // [
53
+ // { url: "...", success: true, data: { url,title,description,image,siteName,... } },
54
+ // ...
55
+ // ]
56
+ // But we stay liberal and accept some alternative wrappers.
57
+ const items: any[] = Array.isArray(res)
58
+ ? res
59
+ : (Array.isArray(res?.items) ? res.items : (Array.isArray(res?.data) ? res.data : []));
60
+
61
+ return (items || [])
62
+ .filter((x) => x && typeof x === 'object')
63
+ .filter((x) => x.success !== false) // keep true/undefined, drop explicit failures
64
+ .map((x) => {
65
+ const d = x.data && typeof x.data === 'object' ? x.data : x;
66
+ return {
67
+ url: (d.url || x.url || x.link || '').toString(),
68
+ title: typeof d.title === 'string' ? d.title : (typeof x.title === 'string' ? x.title : undefined),
69
+ description: typeof d.description === 'string' ? d.description : (typeof x.description === 'string' ? x.description : undefined),
70
+ image: typeof d.image === 'string' ? d.image : (typeof x.image === 'string' ? x.image : undefined),
71
+ siteName: typeof d.siteName === 'string' ? d.siteName : undefined,
72
+ favicon: typeof d.favicon === 'string' ? d.favicon : (typeof x.favicon === 'string' ? x.favicon : undefined),
73
+ favicon_hd: typeof d.favicon_hd === 'string' ? d.favicon_hd : (typeof x.favicon_hd === 'string' ? x.favicon_hd : undefined)
74
+ } as UrlPreviewItem;
75
+ })
76
+ .filter((x) => !!x.url);
77
+ } catch {
78
+ return [];
79
+ }
80
+ }
81
+ }
82
+
@@ -0,0 +1,27 @@
1
+ import { JsonSourceItem } from 'src/app/component/message/json-sources/json-sources.component';
2
+
3
+ export function extractUrlsFromJsonSources(sources: JsonSourceItem[] | null | undefined): string[] {
4
+ return (sources || [])
5
+ .map((s) => (s?.link || '').trim())
6
+ .filter(Boolean);
7
+ }
8
+
9
+ export function mergeJsonSourcesMissingFields(
10
+ base: JsonSourceItem[],
11
+ previews: JsonSourceItem[]
12
+ ): JsonSourceItem[] {
13
+ const byUrl = new Map(previews.map((p) => [p.link, p]));
14
+ return base.map((cur) => {
15
+ const p = cur?.link ? byUrl.get(cur.link) : undefined;
16
+ if (!p) return cur;
17
+ return {
18
+ ...cur,
19
+ title: cur.title || p.title,
20
+ description: cur.description || p.description,
21
+ image: cur.image || p.image,
22
+ favicon: cur.favicon || p.favicon,
23
+ favicon_hd: cur.favicon_hd || p.favicon_hd
24
+ };
25
+ });
26
+ }
27
+
@@ -0,0 +1,98 @@
1
+ export function extractUrlsFromText(text?: string, maxUrls = 20): string[] {
2
+ if (!text) return [];
3
+ const input = text.toString();
4
+ // Match candidates:
5
+ // - https?://...
6
+ // - www....
7
+ // - naked domains like google.it/path
8
+ // We later normalize + validate.
9
+ const candidateRegex =
10
+ /\b(?:https?:\/\/[^\s<>"'`)\]]+|www\.[^\s<>"'`)\]]+|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+[^\s<>"'`)\]]*)/gi;
11
+ const matches = input.match(candidateRegex) || [];
12
+
13
+ const seen = new Set<string>();
14
+ const out: string[] = [];
15
+ for (const raw of matches) {
16
+ const normalized = normalizeAndValidateUrlCandidate(raw);
17
+ if (!normalized) continue;
18
+ if (seen.has(normalized)) continue;
19
+ seen.add(normalized);
20
+ out.push(normalized);
21
+ if (out.length >= maxUrls) break;
22
+ }
23
+ return out;
24
+ }
25
+
26
+ function normalizeAndValidateUrlCandidate(candidate: string): string | null {
27
+ if (!candidate) return null;
28
+ let s = candidate.trim();
29
+
30
+ // Drop surrounding punctuation/brackets commonly attached in text
31
+ s = s.replace(/^[("'<\[]+/, '').replace(/[)"'>\]]+$/, '');
32
+ // Drop trailing punctuation
33
+ s = s.replace(/[.,;:!?]+$/g, '').trim();
34
+ if (!s) return null;
35
+
36
+ // Reject emails quickly
37
+ if (s.includes('@')) return null;
38
+
39
+ // Add scheme if missing
40
+ if (/^www\./i.test(s)) {
41
+ s = `https://${s}`;
42
+ } else if (!/^https?:\/\//i.test(s)) {
43
+ s = `https://${s}`;
44
+ }
45
+
46
+ try {
47
+ const url = new URL(s);
48
+ const hostname = url.hostname.toLowerCase();
49
+
50
+ // must look like a real domain: contain a dot and a plausible TLD
51
+ if (!hostname.includes('.')) return null;
52
+ const tld = hostname.split('.').pop() || '';
53
+ if (tld.length < 2) return null;
54
+ if (!/^[a-z0-9-]+$/.test(tld)) return null;
55
+
56
+ // normalize: remove default port and trailing slash when path is '/'
57
+ url.hash = ''; // do not include fragments in "identity"
58
+ if (url.pathname === '/') url.pathname = '';
59
+ return url.toString();
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Best-effort "registrable domain" extractor.
67
+ * Examples:
68
+ * - "docs.example.com" -> "example.com"
69
+ * - "www.example.co.uk" -> "example.co.uk"
70
+ *
71
+ * This is a heuristic (no publicsuffix list) but works well for common cases.
72
+ */
73
+ export function getTopLevelDomainFromHostname(hostname: string): string {
74
+ const host = (hostname || '').trim().toLowerCase().replace(/\.+$/, '');
75
+ const parts = host.split('.').filter(Boolean);
76
+ if (parts.length <= 2) return host;
77
+
78
+ const tld = parts[parts.length - 1];
79
+ const sld = parts[parts.length - 2];
80
+
81
+ // Heuristic for common ccTLD second-level domains (co.uk, com.au, etc.)
82
+ const commonSecondLevel = new Set(['co', 'com', 'org', 'net', 'gov', 'ac', 'edu']);
83
+ const isCcTld = tld.length === 2;
84
+ if (isCcTld && commonSecondLevel.has(sld) && parts.length >= 3) {
85
+ return parts.slice(-3).join('.');
86
+ }
87
+ return parts.slice(-2).join('.');
88
+ }
89
+
90
+ export function getTopLevelDomainFromUrl(url: string): string {
91
+ try {
92
+ const hostname = new URL(url).hostname.replace(/^www\./, '');
93
+ return getTopLevelDomainFromHostname(hostname);
94
+ } catch {
95
+ return '';
96
+ }
97
+ }
98
+
@@ -508,7 +508,9 @@
508
508
  customAttributes: { sound: false },
509
509
  displayOnDesktop: true,
510
510
  displayOnMobile: true,
511
- soundEnabled: false
511
+ soundEnabled: false,
512
+ onPageChangeVisibilityDesktop: 'open',
513
+ onPageChangeVisibilityMobile: 'open',
512
514
  // autostart: false
513
515
 
514
516
  };
@@ -56,6 +56,10 @@ export const TYPE_MSG_FILE = 'file';
56
56
  export const TYPE_MSG_BUTTON = 'button';
57
57
  export const TYPE_MSG_EMAIL = 'email';
58
58
  export const TYPE_MSG_FORM = 'form';
59
+ export const TYPE_MSG_URL_PREVIEW = 'url_preview';
60
+
61
+ export const JSON_SOURCE_FIELD_URL = 'source_name';
62
+ export const JSON_SOURCE_FIELD_TITLE = 'source_file_name';
59
63
 
60
64
  export const MAX_WIDTH_IMAGES = 230;
61
65
  export const MIN_WIDTH_IMAGES = 130;
@@ -6,9 +6,11 @@ import {
6
6
  MESSAGE_TYPE_MINE,
7
7
  MESSAGE_TYPE_OTHERS,
8
8
  MAX_WIDTH_IMAGES,
9
+ MIN_WIDTH_IMAGES,
9
10
  INFO_MESSAGE_TYPE,
10
11
  CHANNEL_TYPE,
11
- MESSAGE_TYPE_PRIVATE
12
+ MESSAGE_TYPE_PRIVATE,
13
+ TYPE_MSG_URL_PREVIEW
12
14
  } from '../../chat21-core/utils/constants';
13
15
  /** */
14
16
  export function isCarousel(message: any) {
@@ -48,6 +50,13 @@ export function isAudio(message: any) {
48
50
  return false;
49
51
  }
50
52
 
53
+ export function isJsonSources(message: any) {
54
+ if (message && message.type && message.type === 'url_preview') {
55
+ return true;
56
+ }
57
+ return false;
58
+ }
59
+
51
60
  /** */
52
61
  export function isInfo(message: any) {
53
62
  if (message && message.attributes && (message.attributes.subtype === 'info' || message.attributes.subtype === 'info/support')) {
@@ -91,10 +100,19 @@ export function isSameSender(messages, senderId, index):boolean{
91
100
  }
92
101
 
93
102
  export function isLastMessage(messages, idMessage):boolean {
94
- if (idMessage === messages[messages.length - 1].uid) {
95
- return true;
96
- }
97
- return false;
103
+ // url_preview messages are auxiliary (citations card): they must not "steal"
104
+ // last-message status from the preceding interactive message, otherwise its
105
+ // text/action buttons would disappear as soon as a url_preview arrives.
106
+ const interactive = (messages || []).filter((m: any) => !isUrlPreviewMessage(m));
107
+ const last = interactive[interactive.length - 1] || messages[messages.length - 1];
108
+ return !!last && idMessage === last.uid;
109
+ }
110
+
111
+ function isUrlPreviewMessage(m: any): boolean {
112
+ if (!m) return false;
113
+ return m.type === TYPE_MSG_URL_PREVIEW
114
+ || m.metadata?.type === TYPE_MSG_URL_PREVIEW
115
+ || m.attributes?.type === TYPE_MSG_URL_PREVIEW;
98
116
  }
99
117
 
100
118
  export function isFirstMessage(messages, senderId, index):boolean{
@@ -314,7 +332,21 @@ export function commandToMessage(msg: MessageModel, conversation: ConversationMo
314
332
  message.type = msg['type']
315
333
  message.isSender = isSender(message.sender, currentUserId)
316
334
  message.attributes = { ...conversation.attributes, ...msg['attributes']}
317
-
318
335
 
319
336
  return message as MessageModel
320
337
  }
338
+
339
+ export function calcImageSize(metadata: any): { width: number; height: number } {
340
+ const size = { width: metadata.width, height: metadata.height };
341
+ if (!metadata.width) return size;
342
+ if (metadata.width <= 55) {
343
+ const ratio = metadata.width / metadata.height;
344
+ size.width = MIN_WIDTH_IMAGES;
345
+ size.height = MIN_WIDTH_IMAGES / ratio;
346
+ } else if (metadata.width > MAX_WIDTH_IMAGES) {
347
+ const ratio = metadata.width / metadata.height;
348
+ size.width = MAX_WIDTH_IMAGES;
349
+ size.height = MAX_WIDTH_IMAGES / ratio;
350
+ }
351
+ return size;
352
+ }
@@ -1,22 +0,0 @@
1
- name: Build
2
- on:
3
- push:
4
- branches:
5
- - master # The default branch
6
- - (branch|release)-.* # The other branches to be analyzed
7
- - (features|release)/.*
8
- pull_request:
9
- types: [opened, synchronize, reopened]
10
- jobs:
11
- sonarcloud:
12
- name: SonarCloud
13
- runs-on: ubuntu-latest
14
- steps:
15
- - uses: actions/checkout@v2
16
- with:
17
- fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
18
- - name: SonarCloud Scan
19
- uses: SonarSource/sonarcloud-github-action@master
20
- env:
21
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
22
- SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}