@cognigy/chat-components-vue 0.1.0 → 0.3.0
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/dist/chat-components-vue.css +1 -1
- package/dist/chat-components-vue.js +12386 -5741
- package/dist/components/Message.vue.d.ts +4 -0
- package/dist/components/common/Typography.vue.d.ts +1 -1
- package/dist/components/messages/AdaptiveCardRenderer.vue.d.ts +35 -0
- package/dist/components/messages/ListItem.vue.d.ts +1 -1
- package/dist/composables/useLiveRegion.d.ts +30 -0
- package/dist/index.d.ts +3 -2
- package/dist/types/index.d.ts +105 -1
- package/dist/utils/helpers.d.ts +3 -2
- package/dist/utils/matcher.d.ts +3 -3
- package/dist/utils/theme.d.ts +12 -1
- package/package.json +8 -3
- package/src/components/Message.vue +98 -55
- package/src/components/common/ActionButton.vue +16 -7
- package/src/components/common/ChatBubble.vue +8 -6
- package/src/components/common/ChatEvent.vue +5 -2
- package/src/components/common/TypingIndicator.vue +4 -1
- package/src/components/common/Typography.vue +56 -67
- package/src/components/messages/AdaptiveCard.vue +322 -225
- package/src/components/messages/AdaptiveCardRenderer.vue +260 -0
- package/src/components/messages/AudioMessage.vue +4 -1
- package/src/components/messages/DatePicker.vue +5 -27
- package/src/components/messages/FileMessage.vue +12 -3
- package/src/components/messages/Gallery.vue +96 -10
- package/src/components/messages/GalleryItem.vue +17 -5
- package/src/components/messages/ImageMessage.vue +20 -5
- package/src/components/messages/List.vue +56 -42
- package/src/components/messages/ListItem.vue +105 -68
- package/src/components/messages/TextMessage.vue +1 -1
- package/src/components/messages/TextWithButtons.vue +35 -11
- package/src/components/messages/VideoMessage.vue +35 -26
- package/src/composables/useCollation.ts +28 -45
- package/src/composables/useLiveRegion.ts +101 -0
- package/src/index.ts +4 -1
- package/src/types/index.ts +127 -2
- package/src/utils/helpers.ts +46 -24
- package/src/utils/matcher.ts +20 -6
- package/src/utils/sanitize.ts +1 -2
- package/src/utils/theme.ts +42 -1
|
@@ -64,23 +64,23 @@
|
|
|
64
64
|
</video>
|
|
65
65
|
</div>
|
|
66
66
|
|
|
67
|
-
<!-- Download transcript button -->
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
67
|
+
<!-- Download transcript button (split-border: flush card extension below player) -->
|
|
68
|
+
<button
|
|
69
|
+
v-if="videoData.altText"
|
|
70
|
+
:class="[$style.downloadButton, 'webchat-buttons-template-button-video']"
|
|
71
|
+
@click="downloadTranscript"
|
|
72
|
+
>
|
|
73
|
+
<DownloadIcon :class="$style.downloadIcon" />
|
|
74
|
+
Download Transcript
|
|
75
|
+
</button>
|
|
76
|
+
<a
|
|
77
|
+
v-if="videoData.altText"
|
|
78
|
+
ref="downloadLinkRef"
|
|
79
|
+
:href="transcriptDataUrl"
|
|
80
|
+
download="video-transcript.txt"
|
|
81
|
+
style="display: none"
|
|
82
|
+
aria-hidden="true"
|
|
83
|
+
/>
|
|
84
84
|
</div>
|
|
85
85
|
</template>
|
|
86
86
|
|
|
@@ -230,6 +230,10 @@ onMounted(() => {
|
|
|
230
230
|
border-radius: var(--cc-bubble-border-radius, 15px);
|
|
231
231
|
max-width: 295px;
|
|
232
232
|
position: relative;
|
|
233
|
+
background-color: var(--cc-white, #ffffff);
|
|
234
|
+
box-shadow: var(--cc-message-shadow, rgba(151, 124, 156, 0.1) 0px 5px 9px 0px,
|
|
235
|
+
rgba(203, 195, 212, 0.1) 0px 5px 16px 0px,
|
|
236
|
+
rgba(216, 212, 221, 0.1) 0px 8px 20px 0px);
|
|
233
237
|
}
|
|
234
238
|
|
|
235
239
|
.player {
|
|
@@ -250,8 +254,13 @@ onMounted(() => {
|
|
|
250
254
|
overflow: hidden;
|
|
251
255
|
}
|
|
252
256
|
|
|
253
|
-
|
|
254
|
-
|
|
257
|
+
/* Split-border strategy: playerWrapper gets border with no bottom,
|
|
258
|
+
downloadButton gets border with no top — seamless card appearance */
|
|
259
|
+
.wrapperWithButton .playerWrapper {
|
|
260
|
+
border: 1px solid var(--cc-border-media-card, var(--cc-black-80, rgba(0, 0, 0, 0.8)));
|
|
261
|
+
border-bottom: none;
|
|
262
|
+
border-radius: var(--cc-bubble-border-radius, 15px) var(--cc-bubble-border-radius, 15px) 0 0;
|
|
263
|
+
overflow: hidden;
|
|
255
264
|
}
|
|
256
265
|
|
|
257
266
|
.wrapperWithButton .player {
|
|
@@ -259,11 +268,9 @@ onMounted(() => {
|
|
|
259
268
|
border-bottom-right-radius: 0px;
|
|
260
269
|
}
|
|
261
270
|
|
|
262
|
-
.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
border-bottom-left-radius: var(--cc-bubble-border-radius, 15px);
|
|
266
|
-
border-bottom-right-radius: var(--cc-bubble-border-radius, 15px);
|
|
271
|
+
.wrapperWithButton .lightOverlay {
|
|
272
|
+
border-bottom-left-radius: 0px;
|
|
273
|
+
border-bottom-right-radius: 0px;
|
|
267
274
|
}
|
|
268
275
|
|
|
269
276
|
.downloadButton {
|
|
@@ -272,11 +279,13 @@ onMounted(() => {
|
|
|
272
279
|
justify-content: center;
|
|
273
280
|
gap: 10px;
|
|
274
281
|
width: 100%;
|
|
282
|
+
box-sizing: border-box;
|
|
275
283
|
padding: 12px;
|
|
276
284
|
background-color: var(--cc-primary-color, #1976d2);
|
|
277
285
|
color: var(--cc-primary-contrast-color, #ffffff);
|
|
278
|
-
border:
|
|
279
|
-
border-
|
|
286
|
+
border: 1px solid var(--cc-border-media-card, var(--cc-black-80, rgba(0, 0, 0, 0.8)));
|
|
287
|
+
border-top: none;
|
|
288
|
+
border-radius: 0 0 var(--cc-bubble-border-radius, 15px) var(--cc-bubble-border-radius, 15px);
|
|
280
289
|
cursor: pointer;
|
|
281
290
|
font-size: 14px;
|
|
282
291
|
font-weight: 600;
|
|
@@ -44,35 +44,22 @@ export interface UseCollationReturn {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
* Check if two messages can be collated together
|
|
47
|
+
* Check if two messages can be collated together.
|
|
48
|
+
* Both must be bot messages with text, no rich content, attachments, or plugins.
|
|
48
49
|
*/
|
|
49
50
|
function canCollate(current: IMessage, previous: IMessage): boolean {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return false
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Don't collate if either has attachments
|
|
66
|
-
if (current.data?.attachments || previous.data?.attachments) {
|
|
67
|
-
return false
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Don't collate plugin messages
|
|
71
|
-
if (current.data?._plugin || previous.data?._plugin) {
|
|
72
|
-
return false
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return true
|
|
51
|
+
return (
|
|
52
|
+
current.source === 'bot' &&
|
|
53
|
+
previous.source === 'bot' &&
|
|
54
|
+
Boolean(current.text) &&
|
|
55
|
+
Boolean(previous.text) &&
|
|
56
|
+
!current.data?._cognigy?._webchat &&
|
|
57
|
+
!previous.data?._cognigy?._webchat &&
|
|
58
|
+
!current.data?.attachments &&
|
|
59
|
+
!previous.data?.attachments &&
|
|
60
|
+
!current.data?._plugin &&
|
|
61
|
+
!previous.data?._plugin
|
|
62
|
+
)
|
|
76
63
|
}
|
|
77
64
|
|
|
78
65
|
/**
|
|
@@ -109,37 +96,33 @@ export function useCollation(
|
|
|
109
96
|
|
|
110
97
|
const result: CollatedMessage[] = []
|
|
111
98
|
|
|
112
|
-
for (
|
|
113
|
-
const
|
|
99
|
+
for (const current of msgs) {
|
|
100
|
+
const lastIndex = result.length - 1
|
|
101
|
+
const lastCollated = result[lastIndex]
|
|
114
102
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
? lastCollated.collatedFrom[lastCollated.collatedFrom.length - 1]
|
|
103
|
+
if (lastCollated) {
|
|
104
|
+
const existingCollatedFrom = lastCollated.collatedFrom
|
|
105
|
+
const lastOriginal = existingCollatedFrom
|
|
106
|
+
? existingCollatedFrom[existingCollatedFrom.length - 1]
|
|
120
107
|
: lastCollated
|
|
121
108
|
|
|
122
109
|
if (canCollate(current, lastOriginal)) {
|
|
123
|
-
// Collate:
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const collatedFrom = lastCollated.collatedFrom
|
|
128
|
-
? [...lastCollated.collatedFrom, current]
|
|
110
|
+
// Collate: create new object with combined text (avoid mutating existing)
|
|
111
|
+
const collatedFrom = existingCollatedFrom
|
|
112
|
+
? [...existingCollatedFrom, current]
|
|
129
113
|
: [lastOriginal, current]
|
|
130
114
|
|
|
131
|
-
|
|
132
|
-
result[result.length - 1] = {
|
|
115
|
+
result[lastIndex] = {
|
|
133
116
|
...lastCollated,
|
|
134
|
-
text:
|
|
117
|
+
text: (lastCollated.text ?? '') + '\n' + (current.text ?? ''),
|
|
135
118
|
collatedFrom,
|
|
136
119
|
}
|
|
137
120
|
continue
|
|
138
121
|
}
|
|
139
122
|
}
|
|
140
123
|
|
|
141
|
-
// Can't collate, add as new message
|
|
142
|
-
result.push(current
|
|
124
|
+
// Can't collate, add as new message (shallow copy to avoid mutating original)
|
|
125
|
+
result.push({ ...current })
|
|
143
126
|
}
|
|
144
127
|
|
|
145
128
|
return result
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useLiveRegion composable
|
|
3
|
+
*
|
|
4
|
+
* Manages an ARIA live region for screen reader announcements.
|
|
5
|
+
* Creates/reuses a single visually-hidden <div role="status" aria-live="polite"> element
|
|
6
|
+
* in the DOM, and announces content when messages arrive.
|
|
7
|
+
*
|
|
8
|
+
* This matches the React reference implementation's approach to accessibility
|
|
9
|
+
* announcements for message components.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { onMounted, onUnmounted } from 'vue'
|
|
13
|
+
|
|
14
|
+
const LIVE_REGION_ID = 'webchat-live-region'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get or create the shared live region element in the DOM
|
|
18
|
+
*/
|
|
19
|
+
function getOrCreateLiveRegion(): HTMLDivElement {
|
|
20
|
+
let region = document.getElementById(LIVE_REGION_ID) as HTMLDivElement | null
|
|
21
|
+
|
|
22
|
+
if (!region) {
|
|
23
|
+
region = document.createElement('div')
|
|
24
|
+
region.id = LIVE_REGION_ID
|
|
25
|
+
region.setAttribute('role', 'status')
|
|
26
|
+
region.setAttribute('aria-live', 'polite')
|
|
27
|
+
region.setAttribute('aria-atomic', 'true')
|
|
28
|
+
|
|
29
|
+
// Visually hidden but accessible to screen readers
|
|
30
|
+
Object.assign(region.style, {
|
|
31
|
+
position: 'absolute',
|
|
32
|
+
width: '1px',
|
|
33
|
+
height: '1px',
|
|
34
|
+
padding: '0',
|
|
35
|
+
margin: '-1px',
|
|
36
|
+
overflow: 'hidden',
|
|
37
|
+
clip: 'rect(0, 0, 0, 0)',
|
|
38
|
+
whiteSpace: 'nowrap',
|
|
39
|
+
borderWidth: '0',
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
document.body.appendChild(region)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return region
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Track reference count so we only clean up when no components are using the region
|
|
50
|
+
*/
|
|
51
|
+
let refCount = 0
|
|
52
|
+
|
|
53
|
+
interface UseLiveRegionOptions {
|
|
54
|
+
/** Text to announce to screen readers */
|
|
55
|
+
announcement: string
|
|
56
|
+
/** Whether the announcement should be made (defaults to true) */
|
|
57
|
+
enabled?: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Composable that announces content to screen readers via an ARIA live region.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```typescript
|
|
65
|
+
* // In a message component:
|
|
66
|
+
* useLiveRegion({
|
|
67
|
+
* announcement: `New message: ${messageText}`,
|
|
68
|
+
* enabled: !props.ignoreLiveRegion,
|
|
69
|
+
* })
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export function useLiveRegion(options: UseLiveRegionOptions): void {
|
|
73
|
+
onMounted(() => {
|
|
74
|
+
refCount++
|
|
75
|
+
|
|
76
|
+
if (options.enabled === false) return
|
|
77
|
+
if (!options.announcement) return
|
|
78
|
+
|
|
79
|
+
const region = getOrCreateLiveRegion()
|
|
80
|
+
|
|
81
|
+
// Clear then set to trigger screen reader re-announcement
|
|
82
|
+
region.textContent = ''
|
|
83
|
+
// Use requestAnimationFrame to ensure the empty text is processed first
|
|
84
|
+
requestAnimationFrame(() => {
|
|
85
|
+
region.textContent = options.announcement
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
onUnmounted(() => {
|
|
90
|
+
refCount--
|
|
91
|
+
|
|
92
|
+
// Clean up the live region when no components are using it
|
|
93
|
+
if (refCount <= 0) {
|
|
94
|
+
refCount = 0
|
|
95
|
+
const region = document.getElementById(LIVE_REGION_ID)
|
|
96
|
+
if (region) {
|
|
97
|
+
region.textContent = ''
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ export { useSanitize } from './composables/useSanitize'
|
|
|
35
35
|
export { useImageContext, provideImageContext, ImageContextKey } from './composables/useImageContext'
|
|
36
36
|
export { useChannelPayload, type UseChannelPayloadReturn } from './composables/useChannelPayload'
|
|
37
37
|
export { useCollation, type UseCollationReturn, type CollatedMessage } from './composables/useCollation'
|
|
38
|
+
export { useLiveRegion } from './composables/useLiveRegion'
|
|
38
39
|
|
|
39
40
|
// SVG Icons
|
|
40
41
|
export { DownloadIcon, CloseIcon, VideoPlayIcon, AudioPlayIcon, AudioPauseIcon, ArrowBackIcon, LinkIcon } from './assets/svg'
|
|
@@ -42,7 +43,7 @@ export { DownloadIcon, CloseIcon, VideoPlayIcon, AudioPlayIcon, AudioPauseIcon,
|
|
|
42
43
|
// Utilities
|
|
43
44
|
export { match, getChannelPayload } from './utils/matcher'
|
|
44
45
|
export { sanitizeHTMLWithConfig, sanitizeContent } from './utils/sanitize'
|
|
45
|
-
export { configColorsToCssVariables } from './utils/theme'
|
|
46
|
+
export { configColorsToCssVariables, themeFontToCssVariable } from './utils/theme'
|
|
46
47
|
export { getWebchatButtonLabel, interpolateString, getRandomId, moveFocusToMessageFocusTarget, replaceUrlsWithHTMLanchorElem, getBackgroundImage, getFileName, getFileExtension, getSizeLabel, isImageAttachment, VALID_IMAGE_MIME_TYPES } from './utils/helpers'
|
|
47
48
|
|
|
48
49
|
// Types
|
|
@@ -55,7 +56,9 @@ export type {
|
|
|
55
56
|
MessagePlugin,
|
|
56
57
|
MessagePluginOptions,
|
|
57
58
|
MessageContext,
|
|
59
|
+
MessageParams,
|
|
58
60
|
IMessage,
|
|
61
|
+
IStreamingMessage,
|
|
59
62
|
IWebchatButton,
|
|
60
63
|
IWebchatQuickReply,
|
|
61
64
|
IWebchatTemplateAttachment,
|
package/src/types/index.ts
CHANGED
|
@@ -6,6 +6,30 @@
|
|
|
6
6
|
import type { IMessage } from '@cognigy/socket-client'
|
|
7
7
|
import type { Component } from 'vue'
|
|
8
8
|
|
|
9
|
+
// =============================================================================
|
|
10
|
+
// Message Parameter Types
|
|
11
|
+
// =============================================================================
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parameters about the current message state in the conversation.
|
|
15
|
+
* Used to control interactive element behavior (e.g., disabling quick replies after user reply).
|
|
16
|
+
*/
|
|
17
|
+
export interface MessageParams {
|
|
18
|
+
/** Whether the user has already replied to this message (disables quick reply buttons) */
|
|
19
|
+
hasReply?: boolean
|
|
20
|
+
/** Whether the conversation has ended (disables all interactive elements) */
|
|
21
|
+
isConversationEnded?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extended IMessage with streaming animation state.
|
|
26
|
+
* Used with progressive message rendering to conditionally show/hide elements
|
|
27
|
+
* based on whether the message is still being streamed.
|
|
28
|
+
*/
|
|
29
|
+
export interface IStreamingMessage extends IMessage {
|
|
30
|
+
animationState?: 'start' | 'animating' | 'done'
|
|
31
|
+
}
|
|
32
|
+
|
|
9
33
|
// =============================================================================
|
|
10
34
|
// Extended Socket Client Types
|
|
11
35
|
// =============================================================================
|
|
@@ -22,7 +46,7 @@ export interface IMessageWithId extends IMessage {
|
|
|
22
46
|
* Type guard to check if a message has an id property
|
|
23
47
|
*/
|
|
24
48
|
export function hasMessageId(message: IMessage): message is IMessageWithId & { id: string } {
|
|
25
|
-
return 'id' in message && typeof
|
|
49
|
+
return 'id' in message && typeof message.id === 'string'
|
|
26
50
|
}
|
|
27
51
|
|
|
28
52
|
/**
|
|
@@ -95,6 +119,13 @@ export interface ChatSettings {
|
|
|
95
119
|
disableUrlButtonSanitization?: boolean
|
|
96
120
|
dynamicImageAspectRatio?: boolean
|
|
97
121
|
showEngagementInChat?: boolean
|
|
122
|
+
/**
|
|
123
|
+
* Gallery layout variant:
|
|
124
|
+
* - 'default': Standard gallery with 206px wide slides
|
|
125
|
+
* - 'compact': Narrower slides with smaller navigation buttons
|
|
126
|
+
* - 'copilot': Adaptive sizing based on card count (1-card, 2-card, 3+ layouts)
|
|
127
|
+
*/
|
|
128
|
+
galleryVariant?: 'default' | 'compact' | 'copilot'
|
|
98
129
|
}
|
|
99
130
|
colors?: {
|
|
100
131
|
// Primary action colors
|
|
@@ -118,8 +149,32 @@ export interface ChatSettings {
|
|
|
118
149
|
borderUserMessage?: string
|
|
119
150
|
borderAgentMessage?: string
|
|
120
151
|
|
|
152
|
+
// Media card borders (Image, Video, Audio, Gallery, List, TypingIndicator)
|
|
153
|
+
borderMediaCard?: string
|
|
154
|
+
|
|
155
|
+
// List divider color (separator between list items)
|
|
156
|
+
listDividerColor?: string
|
|
157
|
+
|
|
121
158
|
// Link color
|
|
122
159
|
textLinkColor?: string
|
|
160
|
+
|
|
161
|
+
// Adaptive Card colors
|
|
162
|
+
adaptiveCardTextColor?: string
|
|
163
|
+
adaptiveCardInputColor?: string
|
|
164
|
+
adaptiveCardInputBackground?: string
|
|
165
|
+
adaptiveCardInputBorder?: string
|
|
166
|
+
|
|
167
|
+
// File preview colors (non-image file attachment pills)
|
|
168
|
+
filePreviewBackground?: string
|
|
169
|
+
filePreviewTextColor?: string
|
|
170
|
+
filePreviewSecondaryTextColor?: string
|
|
171
|
+
|
|
172
|
+
// Chat event colors (event pill notifications)
|
|
173
|
+
chatEventBackground?: string
|
|
174
|
+
chatEventTextColor?: string
|
|
175
|
+
|
|
176
|
+
// Message shadow (applied to all message bubbles/cards)
|
|
177
|
+
messageShadow?: string
|
|
123
178
|
}
|
|
124
179
|
behavior?: {
|
|
125
180
|
renderMarkdown?: boolean
|
|
@@ -127,6 +182,21 @@ export interface ChatSettings {
|
|
|
127
182
|
messageDelay?: number
|
|
128
183
|
collateStreamedOutputs?: boolean
|
|
129
184
|
focusInputAfterPostback?: boolean
|
|
185
|
+
/**
|
|
186
|
+
* Controls adaptive card interactivity.
|
|
187
|
+
* - `true`: All cards are readonly (presentation only), regardless of submitted data
|
|
188
|
+
* - `false` or `undefined`: Smart default - readonly only if card has submitted data
|
|
189
|
+
*
|
|
190
|
+
* Use `true` for chat history/transcript displays where no interaction is needed.
|
|
191
|
+
* Use `false`/omit for interactive chat interfaces with smart auto-detection.
|
|
192
|
+
*/
|
|
193
|
+
adaptiveCardsReadonly?: boolean
|
|
194
|
+
/**
|
|
195
|
+
* When true, buttons/quick replies are hidden while a message is still being streamed.
|
|
196
|
+
* Works with IStreamingMessage.animationState to delay showing interactive elements
|
|
197
|
+
* until the text animation is complete.
|
|
198
|
+
*/
|
|
199
|
+
progressiveMessageRendering?: boolean
|
|
130
200
|
}
|
|
131
201
|
widgetSettings?: {
|
|
132
202
|
enableDefaultPreview?: boolean
|
|
@@ -232,6 +302,14 @@ export interface MessageProps {
|
|
|
232
302
|
disableHeader?: boolean
|
|
233
303
|
plugins?: MessagePlugin[]
|
|
234
304
|
onEmitAnalytics?: (event: string, payload?: unknown) => void
|
|
305
|
+
/** Conversation state parameters (hasReply, isConversationEnded) */
|
|
306
|
+
messageParams?: MessageParams
|
|
307
|
+
/** Callback to open an xApp overlay (for openXApp button type) */
|
|
308
|
+
openXAppOverlay?: (url: string | undefined) => void
|
|
309
|
+
/** Callback when gallery slide changes — receives (activeIndex, totalSlides) */
|
|
310
|
+
onSlideChange?: (index: number, total: number) => void
|
|
311
|
+
/** Callback when an image is clicked — receives the image URL */
|
|
312
|
+
onImageClick?: (url: string) => void
|
|
235
313
|
}
|
|
236
314
|
|
|
237
315
|
/**
|
|
@@ -303,6 +381,14 @@ export interface MessageContext {
|
|
|
303
381
|
theme?: ChatTheme
|
|
304
382
|
action?: MessageSender
|
|
305
383
|
onEmitAnalytics?: (event: string, payload?: unknown) => void
|
|
384
|
+
/** Conversation state parameters (hasReply, isConversationEnded) */
|
|
385
|
+
messageParams?: MessageParams
|
|
386
|
+
/** Callback to open an xApp overlay (for openXApp button type) */
|
|
387
|
+
openXAppOverlay?: (url: string | undefined) => void
|
|
388
|
+
/** Callback when gallery slide changes — receives (activeIndex, totalSlides) */
|
|
389
|
+
onSlideChange?: (index: number, total: number) => void
|
|
390
|
+
/** Callback when an image is clicked — receives the image URL */
|
|
391
|
+
onImageClick?: (url: string) => void
|
|
306
392
|
}
|
|
307
393
|
|
|
308
394
|
/**
|
|
@@ -369,5 +455,44 @@ export interface IWebchatChannelPayload {
|
|
|
369
455
|
quick_replies?: QuickReply[]
|
|
370
456
|
attachment?: TemplateAttachment | AudioAttachment | ImageAttachment | VideoAttachment
|
|
371
457
|
}
|
|
372
|
-
adaptiveCard?: unknown
|
|
458
|
+
adaptiveCard?: Record<string, unknown>
|
|
459
|
+
/** Submitted adaptive card data */
|
|
460
|
+
adaptiveCardData?: Record<string, unknown>
|
|
461
|
+
data?: Record<string, unknown>
|
|
462
|
+
formData?: Record<string, unknown>
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Cognigy message data structure
|
|
467
|
+
* Shape of message.data._cognigy
|
|
468
|
+
*/
|
|
469
|
+
export interface ICognigyData {
|
|
470
|
+
_webchat?: IWebchatChannelPayload
|
|
471
|
+
_defaultPreview?: IWebchatChannelPayload
|
|
472
|
+
_facebook?: IWebchatChannelPayload
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Plugin payload structure for custom message types
|
|
477
|
+
*/
|
|
478
|
+
export interface IPluginPayload {
|
|
479
|
+
type?: string
|
|
480
|
+
payload?: Record<string, unknown>
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Extended message data with Cognigy-specific fields
|
|
485
|
+
* Use this when accessing message.data with Cognigy payload structures
|
|
486
|
+
*/
|
|
487
|
+
export interface IMessageDataExtended {
|
|
488
|
+
_cognigy?: ICognigyData
|
|
489
|
+
_plugin?: IPluginPayload
|
|
490
|
+
/** Adaptive card submission request data */
|
|
491
|
+
request?: {
|
|
492
|
+
value?: Record<string, unknown>
|
|
493
|
+
}
|
|
494
|
+
/** Direct adaptive card data fields (fallback locations) */
|
|
495
|
+
adaptiveCardData?: Record<string, unknown>
|
|
496
|
+
data?: Record<string, unknown>
|
|
497
|
+
formData?: Record<string, unknown>
|
|
373
498
|
}
|
package/src/utils/helpers.ts
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
import type {IWebchatButton, IWebchatQuickReply} from '../types'
|
|
6
6
|
|
|
7
|
+
// Pre-compiled regex patterns (compiled once at module load)
|
|
8
|
+
const TEMPLATE_PLACEHOLDER_REGEX = /{(\w+)}/g
|
|
9
|
+
const URL_MATCHER_REGEX =
|
|
10
|
+
/(^|\s)(\b(https?):\/\/([-A-Z0-9+&@$#/%?=~_|!:,.;\p{L}]*[-A-Z0-9+&$@#/%=~_|\p{L}]))/giu
|
|
11
|
+
const CONTROL_CHARS_REGEX = /[\r\n\f]/g
|
|
12
|
+
const URL_SCHEME_REGEX = /^[a-zA-Z][a-zA-Z0-9+.-]*:/
|
|
13
|
+
|
|
7
14
|
/**
|
|
8
15
|
* Gets the label for a button
|
|
9
16
|
* Returns "Call" for phone_number buttons without title
|
|
@@ -29,7 +36,7 @@ export function interpolateString(
|
|
|
29
36
|
template: string,
|
|
30
37
|
replacements: Record<string, string>
|
|
31
38
|
): string {
|
|
32
|
-
return template.replace(
|
|
39
|
+
return template.replace(TEMPLATE_PLACEHOLDER_REGEX, (_, key) => {
|
|
33
40
|
return key in replacements ? replacements[key] : ''
|
|
34
41
|
})
|
|
35
42
|
}
|
|
@@ -55,19 +62,31 @@ export function moveFocusToMessageFocusTarget(dataMessageId: string): void {
|
|
|
55
62
|
}, 0)
|
|
56
63
|
}
|
|
57
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Escapes HTML special characters to prevent injection attacks
|
|
67
|
+
*/
|
|
68
|
+
function escapeHtmlAttribute(str: string): string {
|
|
69
|
+
return str
|
|
70
|
+
.replace(/&/g, '&')
|
|
71
|
+
.replace(/"/g, '"')
|
|
72
|
+
.replace(/'/g, ''')
|
|
73
|
+
.replace(/</g, '<')
|
|
74
|
+
.replace(/>/g, '>')
|
|
75
|
+
}
|
|
76
|
+
|
|
58
77
|
/**
|
|
59
78
|
* Helper function that replaces URLs in a string with HTML anchor elements
|
|
60
79
|
* - Works with URLs starting with http/https, www., or just domain/subdomain
|
|
61
80
|
* - Will only match URLs at the beginning or following whitespace
|
|
62
81
|
* - Will not work with emails
|
|
82
|
+
* - URLs are escaped to prevent injection attacks
|
|
63
83
|
*/
|
|
64
84
|
export function replaceUrlsWithHTMLanchorElem(text: string): string {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return `<a href="${url}" target="_blank">${url}</a>`
|
|
85
|
+
return text.replace(URL_MATCHER_REGEX, (_, prefix, url) => {
|
|
86
|
+
// Escape URL for safe insertion into HTML attributes and content
|
|
87
|
+
const escapedUrl = escapeHtmlAttribute(url)
|
|
88
|
+
// Preserve leading whitespace, but keep it outside the anchor
|
|
89
|
+
return `${prefix}<a href="${escapedUrl}" target="_blank">${escapedUrl}</a>`
|
|
71
90
|
})
|
|
72
91
|
}
|
|
73
92
|
|
|
@@ -79,13 +98,13 @@ export function getBackgroundImage(url: string): string | undefined {
|
|
|
79
98
|
if (!url) return undefined
|
|
80
99
|
|
|
81
100
|
// Remove control characters that could break CSS parsing
|
|
82
|
-
let sanitized = url.replace(
|
|
101
|
+
let sanitized = url.replace(CONTROL_CHARS_REGEX, '')
|
|
83
102
|
|
|
84
103
|
// If the string looks like an absolute URL (has a scheme), validate allowed protocols (http/https).
|
|
85
|
-
if (
|
|
104
|
+
if (URL_SCHEME_REGEX.test(sanitized)) {
|
|
86
105
|
try {
|
|
87
106
|
const parsed = new URL(sanitized)
|
|
88
|
-
if (
|
|
107
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
89
108
|
return undefined
|
|
90
109
|
}
|
|
91
110
|
// Normalize absolute URLs
|
|
@@ -117,13 +136,12 @@ const ONE_KB = 1000
|
|
|
117
136
|
* Example: "document.pdf" → "document."
|
|
118
137
|
*/
|
|
119
138
|
export function getFileName(fileNameWithExtension: string): string {
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
122
|
-
return
|
|
123
|
-
} else {
|
|
124
|
-
// return full name here if it didn't have a file ending
|
|
125
|
-
return fileNameWithExtension
|
|
139
|
+
const lastDotIndex = fileNameWithExtension.lastIndexOf('.')
|
|
140
|
+
if (lastDotIndex > 0) {
|
|
141
|
+
return fileNameWithExtension.slice(0, lastDotIndex + 1)
|
|
126
142
|
}
|
|
143
|
+
// Return full name if no extension
|
|
144
|
+
return fileNameWithExtension
|
|
127
145
|
}
|
|
128
146
|
|
|
129
147
|
/**
|
|
@@ -131,12 +149,11 @@ export function getFileName(fileNameWithExtension: string): string {
|
|
|
131
149
|
* Example: "document.pdf" → "pdf"
|
|
132
150
|
*/
|
|
133
151
|
export function getFileExtension(fileNameWithExtension: string): string | null {
|
|
134
|
-
const
|
|
135
|
-
if (
|
|
136
|
-
return
|
|
137
|
-
} else {
|
|
138
|
-
return null
|
|
152
|
+
const lastDotIndex = fileNameWithExtension.lastIndexOf('.')
|
|
153
|
+
if (lastDotIndex > 0 && lastDotIndex < fileNameWithExtension.length - 1) {
|
|
154
|
+
return fileNameWithExtension.slice(lastDotIndex + 1)
|
|
139
155
|
}
|
|
156
|
+
return null
|
|
140
157
|
}
|
|
141
158
|
|
|
142
159
|
/**
|
|
@@ -152,13 +169,18 @@ export function getSizeLabel(size: number): string {
|
|
|
152
169
|
}
|
|
153
170
|
|
|
154
171
|
/**
|
|
155
|
-
* Valid image MIME types for file attachments
|
|
172
|
+
* Valid image MIME types for file attachments (Set for O(1) lookup)
|
|
156
173
|
*/
|
|
157
|
-
export const VALID_IMAGE_MIME_TYPES = [
|
|
174
|
+
export const VALID_IMAGE_MIME_TYPES = new Set([
|
|
175
|
+
'image/jpeg',
|
|
176
|
+
'image/png',
|
|
177
|
+
'image/gif',
|
|
178
|
+
'image/webp',
|
|
179
|
+
])
|
|
158
180
|
|
|
159
181
|
/**
|
|
160
182
|
* Checks if attachment is a valid image type
|
|
161
183
|
*/
|
|
162
184
|
export function isImageAttachment(mimeType: string): boolean {
|
|
163
|
-
return VALID_IMAGE_MIME_TYPES.
|
|
185
|
+
return VALID_IMAGE_MIME_TYPES.has(mimeType)
|
|
164
186
|
}
|