@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 +3 -0
- package/angular.json +3 -1
- package/package.json +1 -1
- package/src/app/app.module.ts +2 -0
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +14 -1
- package/src/app/component/conversation-detail/conversation-content/conversation-content.component.ts +0 -1
- package/src/app/component/message/bubble-message/bubble-message.component.html +10 -5
- package/src/app/component/message/bubble-message/bubble-message.component.scss +5 -0
- package/src/app/component/message/bubble-message/bubble-message.component.ts +78 -113
- package/src/app/component/message/json-sources/json-sources.component.html +38 -0
- package/src/app/component/message/json-sources/json-sources.component.scss +201 -0
- package/src/app/component/message/json-sources/json-sources.component.ts +89 -0
- package/src/app/providers/json-sources-parser.service.ts +175 -0
- package/src/app/providers/url-preview.service.ts +82 -0
- package/src/app/utils/json-sources-utils.ts +27 -0
- package/src/app/utils/url-utils.ts +98 -0
- package/src/assets/twp/chatbot-panel.html +3 -1
- package/src/chat21-core/utils/constants.ts +4 -0
- package/src/chat21-core/utils/utils-message.ts +38 -6
- package/.github/workflows/build.yml +0 -22
package/CHANGELOG.md
CHANGED
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
package/src/app/app.module.ts
CHANGED
|
@@ -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 ================= //
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
|
|
23
|
-
<div id="bubble-message"
|
|
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
|
|
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,148 +1,113 @@
|
|
|
1
|
-
import { Component, EventEmitter,
|
|
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 {
|
|
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
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
+
this.fullnameColor = this.fontColor
|
|
70
|
+
? convertColorToRGBA(this.fontColor, 65)
|
|
71
|
+
: this.fullnameColor;
|
|
121
72
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
73
|
+
if (this.message?.sender_fullname?.trim()) {
|
|
74
|
+
this.fullnameColor = getColorBck(this.message.sender_fullname);
|
|
75
|
+
}
|
|
125
76
|
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
this.
|
|
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
|
-
|
|
142
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 }}
|