@cognigy/chat-components-vue 0.1.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/LICENSE +20 -0
- package/README.md +178 -0
- package/dist/assets/svg/ArrowBackIcon.vue.d.ts +9 -0
- package/dist/assets/svg/AudioPauseIcon.vue.d.ts +2 -0
- package/dist/assets/svg/AudioPlayIcon.vue.d.ts +2 -0
- package/dist/assets/svg/CloseIcon.vue.d.ts +2 -0
- package/dist/assets/svg/DownloadIcon.vue.d.ts +2 -0
- package/dist/assets/svg/VideoPlayIcon.vue.d.ts +9 -0
- package/dist/assets/svg/index.d.ts +7 -0
- package/dist/chat-components-vue.css +1 -0
- package/dist/chat-components-vue.js +9858 -0
- package/dist/components/Message.vue.d.ts +11 -0
- package/dist/components/common/ActionButton.vue.d.ts +59 -0
- package/dist/components/common/ActionButtons.vue.d.ts +36 -0
- package/dist/components/common/ChatBubble.vue.d.ts +22 -0
- package/dist/components/common/ChatEvent.vue.d.ts +20 -0
- package/dist/components/common/LinkIcon.vue.d.ts +2 -0
- package/dist/components/common/TypingIndicator.vue.d.ts +21 -0
- package/dist/components/common/Typography.vue.d.ts +38 -0
- package/dist/components/messages/AdaptiveCard.vue.d.ts +2 -0
- package/dist/components/messages/AudioMessage.vue.d.ts +5 -0
- package/dist/components/messages/DatePicker.vue.d.ts +2 -0
- package/dist/components/messages/FileMessage.vue.d.ts +2 -0
- package/dist/components/messages/Gallery.vue.d.ts +2 -0
- package/dist/components/messages/GalleryItem.vue.d.ts +7 -0
- package/dist/components/messages/ImageMessage.vue.d.ts +5 -0
- package/dist/components/messages/List.vue.d.ts +2 -0
- package/dist/components/messages/ListItem.vue.d.ts +16 -0
- package/dist/components/messages/TextMessage.vue.d.ts +15 -0
- package/dist/components/messages/TextWithButtons.vue.d.ts +2 -0
- package/dist/components/messages/VideoMessage.vue.d.ts +5 -0
- package/dist/composables/useChannelPayload.d.ts +47 -0
- package/dist/composables/useCollation.d.ts +47 -0
- package/dist/composables/useImageContext.d.ts +13 -0
- package/dist/composables/useMessageContext.d.ts +18 -0
- package/dist/composables/useSanitize.d.ts +8 -0
- package/dist/index.d.ts +33 -0
- package/dist/types/index.d.ts +275 -0
- package/dist/utils/helpers.d.ts +56 -0
- package/dist/utils/matcher.d.ts +20 -0
- package/dist/utils/sanitize.d.ts +28 -0
- package/dist/utils/theme.d.ts +18 -0
- package/package.json +94 -0
- package/src/assets/svg/ArrowBackIcon.vue +30 -0
- package/src/assets/svg/AudioPauseIcon.vue +20 -0
- package/src/assets/svg/AudioPlayIcon.vue +19 -0
- package/src/assets/svg/CloseIcon.vue +10 -0
- package/src/assets/svg/DownloadIcon.vue +10 -0
- package/src/assets/svg/VideoPlayIcon.vue +25 -0
- package/src/assets/svg/index.ts +7 -0
- package/src/components/Message.vue +152 -0
- package/src/components/common/ActionButton.vue +354 -0
- package/src/components/common/ActionButtons.vue +170 -0
- package/src/components/common/ChatBubble.vue +109 -0
- package/src/components/common/ChatEvent.vue +84 -0
- package/src/components/common/LinkIcon.vue +34 -0
- package/src/components/common/TypingIndicator.vue +202 -0
- package/src/components/common/Typography.vue +196 -0
- package/src/components/messages/AdaptiveCard.vue +292 -0
- package/src/components/messages/AudioMessage.vue +391 -0
- package/src/components/messages/DatePicker.vue +135 -0
- package/src/components/messages/FileMessage.vue +195 -0
- package/src/components/messages/Gallery.vue +296 -0
- package/src/components/messages/GalleryItem.vue +214 -0
- package/src/components/messages/ImageMessage.vue +368 -0
- package/src/components/messages/List.vue +149 -0
- package/src/components/messages/ListItem.vue +344 -0
- package/src/components/messages/TextMessage.vue +203 -0
- package/src/components/messages/TextWithButtons.vue +119 -0
- package/src/components/messages/VideoMessage.vue +343 -0
- package/src/composables/useChannelPayload.ts +101 -0
- package/src/composables/useCollation.ts +163 -0
- package/src/composables/useImageContext.ts +27 -0
- package/src/composables/useMessageContext.ts +41 -0
- package/src/composables/useSanitize.ts +25 -0
- package/src/index.ts +71 -0
- package/src/types/index.ts +373 -0
- package/src/utils/helpers.ts +164 -0
- package/src/utils/matcher.ts +283 -0
- package/src/utils/sanitize.ts +133 -0
- package/src/utils/theme.ts +58 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<ChatBubble
|
|
3
|
+
v-if="hasAdaptiveCard"
|
|
4
|
+
:class="['adaptivecard-wrapper', 'internal', $style.wrapper]"
|
|
5
|
+
data-testid="adaptive-card-message"
|
|
6
|
+
>
|
|
7
|
+
<div :class="$style.card">
|
|
8
|
+
<!-- Card icon/indicator -->
|
|
9
|
+
<div :class="$style.icon">
|
|
10
|
+
<svg
|
|
11
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
12
|
+
viewBox="0 0 24 24"
|
|
13
|
+
:width="24"
|
|
14
|
+
:height="24"
|
|
15
|
+
fill="none"
|
|
16
|
+
stroke="currentColor"
|
|
17
|
+
stroke-width="2"
|
|
18
|
+
stroke-linecap="round"
|
|
19
|
+
stroke-linejoin="round"
|
|
20
|
+
>
|
|
21
|
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
|
22
|
+
<line x1="9" y1="9" x2="15" y2="9"></line>
|
|
23
|
+
<line x1="9" y1="15" x2="15" y2="15"></line>
|
|
24
|
+
</svg>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<!-- Card content -->
|
|
28
|
+
<div :class="$style.content">
|
|
29
|
+
<Typography
|
|
30
|
+
variant="title1-semibold"
|
|
31
|
+
component="div"
|
|
32
|
+
:class="$style.title"
|
|
33
|
+
>
|
|
34
|
+
{{ cardTitle }}
|
|
35
|
+
</Typography>
|
|
36
|
+
|
|
37
|
+
<Typography
|
|
38
|
+
v-if="cardBody"
|
|
39
|
+
variant="body-regular"
|
|
40
|
+
component="div"
|
|
41
|
+
:class="$style.body"
|
|
42
|
+
>
|
|
43
|
+
{{ cardBody }}
|
|
44
|
+
</Typography>
|
|
45
|
+
|
|
46
|
+
<div v-if="hasActions" :class="$style.actions">
|
|
47
|
+
<Typography variant="copy-medium" component="span" :class="$style.actionsLabel">
|
|
48
|
+
{{ actionsLabel }}
|
|
49
|
+
</Typography>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
</ChatBubble>
|
|
54
|
+
</template>
|
|
55
|
+
|
|
56
|
+
<script setup lang="ts">
|
|
57
|
+
import { computed } from 'vue'
|
|
58
|
+
import ChatBubble from '../common/ChatBubble.vue'
|
|
59
|
+
import Typography from '../common/Typography.vue'
|
|
60
|
+
import { useMessageContext } from '../../composables/useMessageContext'
|
|
61
|
+
import { isAdaptiveCardPayload } from '../../types'
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Simplified adaptive card structure for our rendering needs.
|
|
65
|
+
* The full IAdaptiveCard type from 'adaptivecards' package is complex,
|
|
66
|
+
* but we only need a subset for display purposes.
|
|
67
|
+
*/
|
|
68
|
+
interface AdaptiveCardData {
|
|
69
|
+
title?: string
|
|
70
|
+
body?: unknown[]
|
|
71
|
+
actions?: unknown[]
|
|
72
|
+
speak?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Adaptive card sources from different payload locations
|
|
77
|
+
*/
|
|
78
|
+
interface AdaptiveCardSources {
|
|
79
|
+
webchat: AdaptiveCardData | undefined
|
|
80
|
+
defaultPreview: AdaptiveCardData | undefined
|
|
81
|
+
plugin: AdaptiveCardData | undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Extract adaptive card from various message payload locations.
|
|
86
|
+
*
|
|
87
|
+
* Note on types: The socket-client types have limitations:
|
|
88
|
+
* - _defaultPreview is typed as `any`
|
|
89
|
+
* - _plugin.data is `any` for adaptivecards type
|
|
90
|
+
* We use runtime type guards to safely access these properties.
|
|
91
|
+
*/
|
|
92
|
+
function getAdaptiveCardSources(message: ReturnType<typeof useMessageContext>['message']): AdaptiveCardSources {
|
|
93
|
+
const cognigyData = message?.data?._cognigy
|
|
94
|
+
const pluginData = message?.data?._plugin
|
|
95
|
+
|
|
96
|
+
// _webchat can be IWebchatMessage | IAdaptiveCardMessage - use type guard
|
|
97
|
+
const webchatPayload = cognigyData?._webchat
|
|
98
|
+
const webchat = isAdaptiveCardPayload(webchatPayload)
|
|
99
|
+
? (webchatPayload.adaptiveCard as AdaptiveCardData)
|
|
100
|
+
: undefined
|
|
101
|
+
|
|
102
|
+
// _defaultPreview is typed as `any` in socket-client (upstream limitation)
|
|
103
|
+
// We safely check for adaptiveCard property
|
|
104
|
+
const defaultPreviewPayload = cognigyData?._defaultPreview
|
|
105
|
+
const defaultPreview = isAdaptiveCardPayload(defaultPreviewPayload)
|
|
106
|
+
? (defaultPreviewPayload.adaptiveCard as AdaptiveCardData)
|
|
107
|
+
: undefined
|
|
108
|
+
|
|
109
|
+
// Plugin data can come in two formats:
|
|
110
|
+
// 1. Typed format: { type: 'adaptivecards', data: cardData }
|
|
111
|
+
// 2. Legacy format: { payload: cardData }
|
|
112
|
+
let plugin: AdaptiveCardData | undefined
|
|
113
|
+
if (pluginData) {
|
|
114
|
+
if (pluginData.type === 'adaptivecards' && 'data' in pluginData) {
|
|
115
|
+
plugin = pluginData.data as AdaptiveCardData
|
|
116
|
+
} else if ('payload' in pluginData) {
|
|
117
|
+
plugin = (pluginData as { payload?: unknown }).payload as AdaptiveCardData
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { webchat, defaultPreview, plugin }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Message context
|
|
125
|
+
const { message, config } = useMessageContext()
|
|
126
|
+
|
|
127
|
+
// Get all adaptive card sources
|
|
128
|
+
const cardSources = computed(() => getAdaptiveCardSources(message))
|
|
129
|
+
|
|
130
|
+
// Check if this message has an adaptive card
|
|
131
|
+
const hasAdaptiveCard = computed(() => {
|
|
132
|
+
const { webchat, defaultPreview, plugin } = cardSources.value
|
|
133
|
+
return !!(webchat || defaultPreview || plugin)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
// Get card payload based on configuration
|
|
137
|
+
const cardPayload = computed((): AdaptiveCardData | undefined => {
|
|
138
|
+
const { webchat, defaultPreview, plugin } = cardSources.value
|
|
139
|
+
const defaultPreviewEnabled = config?.settings?.widgetSettings?.enableDefaultPreview
|
|
140
|
+
|
|
141
|
+
if (webchat && defaultPreview && !defaultPreviewEnabled) {
|
|
142
|
+
return webchat
|
|
143
|
+
}
|
|
144
|
+
if (defaultPreview && defaultPreviewEnabled) {
|
|
145
|
+
return defaultPreview
|
|
146
|
+
}
|
|
147
|
+
return plugin || webchat
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Adaptive card body element (simplified type for our use case)
|
|
152
|
+
*/
|
|
153
|
+
interface AdaptiveCardElement {
|
|
154
|
+
type: string
|
|
155
|
+
text?: string
|
|
156
|
+
size?: string
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Check if an item is a TextBlock element
|
|
161
|
+
*/
|
|
162
|
+
function isTextBlock(item: unknown): item is AdaptiveCardElement & { type: 'TextBlock'; text: string } {
|
|
163
|
+
return (
|
|
164
|
+
typeof item === 'object' &&
|
|
165
|
+
item !== null &&
|
|
166
|
+
(item as AdaptiveCardElement).type === 'TextBlock' &&
|
|
167
|
+
typeof (item as AdaptiveCardElement).text === 'string'
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Extract card title
|
|
172
|
+
const cardTitle = computed(() => {
|
|
173
|
+
const card = cardPayload.value
|
|
174
|
+
if (!card) return 'Adaptive Card'
|
|
175
|
+
|
|
176
|
+
// Try to get title from card property (custom extension)
|
|
177
|
+
if ('title' in card && typeof card.title === 'string') {
|
|
178
|
+
return card.title
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Try to get from body elements
|
|
182
|
+
const body = card.body
|
|
183
|
+
if (body && Array.isArray(body)) {
|
|
184
|
+
// Look for large text block (likely a title)
|
|
185
|
+
const titleElement = body.find(
|
|
186
|
+
(item): item is AdaptiveCardElement =>
|
|
187
|
+
isTextBlock(item) && item.size === 'large'
|
|
188
|
+
)
|
|
189
|
+
if (titleElement?.text) {
|
|
190
|
+
return titleElement.text
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Fallback to first TextBlock
|
|
194
|
+
const firstText = body.find(isTextBlock)
|
|
195
|
+
if (firstText?.text) {
|
|
196
|
+
return firstText.text
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Fallback to speak text or generic title
|
|
201
|
+
if (card.speak) {
|
|
202
|
+
return card.speak.substring(0, 50)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return 'Adaptive Card'
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
// Extract card body text
|
|
209
|
+
const cardBody = computed(() => {
|
|
210
|
+
const body = cardPayload.value?.body
|
|
211
|
+
if (!body || !Array.isArray(body)) {
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Get text from body elements (skip the title)
|
|
216
|
+
const titleText = cardTitle.value
|
|
217
|
+
const bodyTexts = body
|
|
218
|
+
.filter((item): item is AdaptiveCardElement & { text: string } =>
|
|
219
|
+
isTextBlock(item) && item.text !== titleText
|
|
220
|
+
)
|
|
221
|
+
.map(item => item.text)
|
|
222
|
+
.slice(0, 2) // Limit to first 2 text blocks
|
|
223
|
+
|
|
224
|
+
return bodyTexts.length > 0 ? bodyTexts.join(' ') : null
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// Check if card has actions
|
|
228
|
+
const hasActions = computed(() => {
|
|
229
|
+
return cardPayload.value?.actions && Array.isArray(cardPayload.value.actions) && cardPayload.value.actions.length > 0
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
// Actions label
|
|
233
|
+
const actionsLabel = computed(() => {
|
|
234
|
+
const actions = cardPayload.value?.actions
|
|
235
|
+
if (!actions || !Array.isArray(actions) || actions.length === 0) return ''
|
|
236
|
+
|
|
237
|
+
const count = actions.length
|
|
238
|
+
return count === 1 ? '1 action available' : `${count} actions available`
|
|
239
|
+
})
|
|
240
|
+
</script>
|
|
241
|
+
|
|
242
|
+
<style module>
|
|
243
|
+
.wrapper {
|
|
244
|
+
max-width: 400px;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.card {
|
|
248
|
+
display: flex;
|
|
249
|
+
gap: 12px;
|
|
250
|
+
padding: 16px;
|
|
251
|
+
background-color: var(--cc-white, #ffffff);
|
|
252
|
+
border-radius: var(--cc-bubble-border-radius, 15px);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.icon {
|
|
256
|
+
flex-shrink: 0;
|
|
257
|
+
color: var(--cc-primary-color, #1976d2);
|
|
258
|
+
display: flex;
|
|
259
|
+
align-items: flex-start;
|
|
260
|
+
padding-top: 2px;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.content {
|
|
264
|
+
flex: 1;
|
|
265
|
+
display: flex;
|
|
266
|
+
flex-direction: column;
|
|
267
|
+
gap: 8px;
|
|
268
|
+
min-width: 0;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.title {
|
|
272
|
+
color: var(--cc-black-10, rgba(0, 0, 0, 0.1));
|
|
273
|
+
margin: 0;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.body {
|
|
277
|
+
color: var(--cc-black-20, rgba(0, 0, 0, 0.2));
|
|
278
|
+
margin: 0;
|
|
279
|
+
overflow-wrap: break-word;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.actions {
|
|
283
|
+
margin-top: 4px;
|
|
284
|
+
padding-top: 8px;
|
|
285
|
+
border-top: 1px solid var(--cc-black-80, rgba(0, 0, 0, 0.8));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.actionsLabel {
|
|
289
|
+
color: var(--cc-black-40, rgba(0, 0, 0, 0.4));
|
|
290
|
+
font-style: italic;
|
|
291
|
+
}
|
|
292
|
+
</style>
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div v-if="audioData.url" :class="[$style.wrapper, 'webchat-media-template-audio']" data-testid="audio-message">
|
|
3
|
+
<!-- Hidden audio element -->
|
|
4
|
+
<audio
|
|
5
|
+
ref="audioRef"
|
|
6
|
+
:src="audioData.url"
|
|
7
|
+
@play="handlePlay"
|
|
8
|
+
@pause="handlePause"
|
|
9
|
+
@timeupdate="handleTimeUpdate"
|
|
10
|
+
@loadedmetadata="handleLoadedMetadata"
|
|
11
|
+
@ended="handleEnded"
|
|
12
|
+
style="display: none"
|
|
13
|
+
/>
|
|
14
|
+
|
|
15
|
+
<!-- Custom controls -->
|
|
16
|
+
<div :class="$style.audioWrapper" data-testid="audio-controls">
|
|
17
|
+
<div :class="$style.controls">
|
|
18
|
+
<!-- Time remaining -->
|
|
19
|
+
<div class="duration">
|
|
20
|
+
<time>{{ formattedTime }}</time>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<!-- Progress bar -->
|
|
24
|
+
<div :class="$style.progressBar">
|
|
25
|
+
<input
|
|
26
|
+
type="range"
|
|
27
|
+
min="0"
|
|
28
|
+
max="0.999999"
|
|
29
|
+
step="any"
|
|
30
|
+
:value="progress"
|
|
31
|
+
:aria-valuetext="audioTimeValueText"
|
|
32
|
+
:aria-label="audioPlaybackProgressLabel"
|
|
33
|
+
:style="progressBarStyle"
|
|
34
|
+
@mousedown="handleSeekStart"
|
|
35
|
+
@touchstart="handleSeekStart"
|
|
36
|
+
@input="handleSeekChange"
|
|
37
|
+
@mouseup="handleSeekEnd"
|
|
38
|
+
@touchend="handleSeekEnd"
|
|
39
|
+
/>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<!-- Play/pause button -->
|
|
43
|
+
<div class="buttons">
|
|
44
|
+
<button
|
|
45
|
+
:class="$style.playButton"
|
|
46
|
+
:aria-label="playing ? pauseAudioLabel : playAudioLabel"
|
|
47
|
+
@click="togglePlayPause"
|
|
48
|
+
>
|
|
49
|
+
<AudioPauseIcon v-if="playing" />
|
|
50
|
+
<AudioPlayIcon v-else />
|
|
51
|
+
</button>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<!-- Download transcript button -->
|
|
56
|
+
<button
|
|
57
|
+
v-if="audioData.altText"
|
|
58
|
+
:class="$style.downloadButton"
|
|
59
|
+
:aria-label="downloadTranscriptLabel"
|
|
60
|
+
data-testid="download-transcript-button"
|
|
61
|
+
@click="downloadTranscript"
|
|
62
|
+
>
|
|
63
|
+
<DownloadIcon />
|
|
64
|
+
</button>
|
|
65
|
+
<a
|
|
66
|
+
v-if="audioData.altText"
|
|
67
|
+
ref="downloadLinkRef"
|
|
68
|
+
:href="transcriptDataUrl"
|
|
69
|
+
download="audio-transcript.txt"
|
|
70
|
+
style="display: none"
|
|
71
|
+
aria-hidden="true"
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
</template>
|
|
76
|
+
|
|
77
|
+
<script setup lang="ts">
|
|
78
|
+
import { ref, computed, onMounted, useCssModule } from 'vue'
|
|
79
|
+
import { useMessageContext } from '../../composables/useMessageContext'
|
|
80
|
+
import { getChannelPayload } from '../../utils/matcher'
|
|
81
|
+
import { interpolateString } from '../../utils/helpers'
|
|
82
|
+
import { DownloadIcon, AudioPlayIcon, AudioPauseIcon } from '../../assets/svg'
|
|
83
|
+
import type { IWebchatAudioAttachment } from '../../types'
|
|
84
|
+
|
|
85
|
+
const { message, config } = useMessageContext()
|
|
86
|
+
|
|
87
|
+
const $style = useCssModule()
|
|
88
|
+
|
|
89
|
+
// Refs
|
|
90
|
+
const audioRef = ref<HTMLAudioElement>()
|
|
91
|
+
const downloadLinkRef = ref<HTMLAnchorElement>()
|
|
92
|
+
|
|
93
|
+
// State
|
|
94
|
+
const playing = ref(false)
|
|
95
|
+
const progress = ref(0)
|
|
96
|
+
const duration = ref(0)
|
|
97
|
+
const currentTime = ref(0)
|
|
98
|
+
|
|
99
|
+
// Get audio data from message payload
|
|
100
|
+
const payload = computed(() => getChannelPayload(message, config))
|
|
101
|
+
const audioData = computed(() => {
|
|
102
|
+
const attachment = payload.value?.message?.attachment as IWebchatAudioAttachment
|
|
103
|
+
return {
|
|
104
|
+
url: attachment?.payload?.url || '',
|
|
105
|
+
altText: attachment?.payload?.altText,
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// Format time display (time remaining)
|
|
110
|
+
const formattedTime = computed(() => {
|
|
111
|
+
const padString = (num: number) => {
|
|
112
|
+
return ('0' + num).toString().slice(-2)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const seconds = duration.value * (1 - Math.min(1, progress.value))
|
|
116
|
+
const date = new Date(seconds * 1000)
|
|
117
|
+
const hh = date.getUTCHours()
|
|
118
|
+
const mm = date.getUTCMinutes()
|
|
119
|
+
const ss = padString(date.getUTCSeconds())
|
|
120
|
+
|
|
121
|
+
if (hh) {
|
|
122
|
+
return `${hh}:${padString(mm)}:${ss}`
|
|
123
|
+
}
|
|
124
|
+
return `${mm}:${ss}`
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// Convert formatted time to readable text for screen readers
|
|
128
|
+
const timeToText = (time: string) => {
|
|
129
|
+
let timeStr = time
|
|
130
|
+
if (timeStr.length < 6) {
|
|
131
|
+
timeStr = `00:${timeStr}`
|
|
132
|
+
}
|
|
133
|
+
const [hours, minutes, seconds] = timeStr.split(':').map(Number)
|
|
134
|
+
const hoursText = hours ? `${hours} hours ` : ''
|
|
135
|
+
const minutesText = minutes ? `${minutes} minutes ` : ''
|
|
136
|
+
const secondsText = `${seconds} seconds`
|
|
137
|
+
return `${hoursText}${minutesText}${secondsText}`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Progress bar style (linear gradient)
|
|
141
|
+
const progressBarStyle = computed(() => ({
|
|
142
|
+
background: `linear-gradient(to right, var(--cc-primary-color-focus) ${
|
|
143
|
+
progress.value * 100
|
|
144
|
+
}%, var(--cc-black-80) ${progress.value * 100}%)`,
|
|
145
|
+
}))
|
|
146
|
+
|
|
147
|
+
// Transcript data URL for download
|
|
148
|
+
const transcriptDataUrl = computed(() => {
|
|
149
|
+
const text = audioData.value.altText || ''
|
|
150
|
+
return `data:text/plain;charset=utf-8,${encodeURIComponent(text)}`
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// Translations
|
|
154
|
+
const audioPlaybackProgressLabel = computed(() =>
|
|
155
|
+
config?.settings?.customTranslations?.ariaLabels?.audioPlaybackProgress || 'Audio playback progress'
|
|
156
|
+
)
|
|
157
|
+
const playAudioLabel = computed(() =>
|
|
158
|
+
config?.settings?.customTranslations?.ariaLabels?.playAudio || 'Play audio'
|
|
159
|
+
)
|
|
160
|
+
const pauseAudioLabel = computed(() =>
|
|
161
|
+
config?.settings?.customTranslations?.ariaLabels?.pauseAudio || 'Pause audio'
|
|
162
|
+
)
|
|
163
|
+
const downloadTranscriptLabel = computed(() =>
|
|
164
|
+
config?.settings?.customTranslations?.ariaLabels?.downloadTranscript || 'Download transcript'
|
|
165
|
+
)
|
|
166
|
+
const audioTimeRemainingLabel = computed(() =>
|
|
167
|
+
config?.settings?.customTranslations?.ariaLabels?.audioTimeRemaining ?? '{time} remaining'
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
// Audio time value text for ARIA
|
|
171
|
+
const audioTimeValueText = computed(() =>
|
|
172
|
+
interpolateString(audioTimeRemainingLabel.value, {
|
|
173
|
+
time: timeToText(formattedTime.value),
|
|
174
|
+
})
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
// Handlers
|
|
178
|
+
const handlePlay = () => {
|
|
179
|
+
playing.value = true
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const handlePause = () => {
|
|
183
|
+
playing.value = false
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const handleTimeUpdate = () => {
|
|
187
|
+
if (audioRef.value && duration.value > 0) {
|
|
188
|
+
currentTime.value = audioRef.value.currentTime
|
|
189
|
+
progress.value = currentTime.value / duration.value
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const handleLoadedMetadata = () => {
|
|
194
|
+
if (audioRef.value) {
|
|
195
|
+
duration.value = audioRef.value.duration
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const handleEnded = () => {
|
|
200
|
+
playing.value = false
|
|
201
|
+
progress.value = 0
|
|
202
|
+
if (audioRef.value) {
|
|
203
|
+
audioRef.value.currentTime = 0
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const togglePlayPause = () => {
|
|
208
|
+
if (audioRef.value) {
|
|
209
|
+
if (playing.value) {
|
|
210
|
+
audioRef.value.pause()
|
|
211
|
+
} else {
|
|
212
|
+
audioRef.value.play()
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const handleSeekStart = () => {
|
|
218
|
+
if (audioRef.value && playing.value) {
|
|
219
|
+
audioRef.value.pause()
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const handleSeekChange = (event: Event) => {
|
|
224
|
+
const target = event.target as HTMLInputElement
|
|
225
|
+
const newProgress = parseFloat(target.value)
|
|
226
|
+
progress.value = newProgress
|
|
227
|
+
|
|
228
|
+
if (audioRef.value && duration.value > 0) {
|
|
229
|
+
audioRef.value.currentTime = newProgress * duration.value
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const handleSeekEnd = () => {
|
|
234
|
+
if (audioRef.value && !playing.value) {
|
|
235
|
+
audioRef.value.play()
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const downloadTranscript = () => {
|
|
240
|
+
downloadLinkRef.value?.click()
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Auto-focus audio on mount if configured
|
|
244
|
+
onMounted(() => {
|
|
245
|
+
if (!config?.settings?.widgetSettings?.enableAutoFocus) return
|
|
246
|
+
|
|
247
|
+
const chatHistory = document.getElementById('webchatChatHistoryWrapperLiveLogPanel')
|
|
248
|
+
if (!chatHistory?.contains(document.activeElement)) return
|
|
249
|
+
|
|
250
|
+
setTimeout(() => {
|
|
251
|
+
audioRef.value?.focus()
|
|
252
|
+
}, 100)
|
|
253
|
+
})
|
|
254
|
+
</script>
|
|
255
|
+
|
|
256
|
+
<style module>
|
|
257
|
+
.wrapper {
|
|
258
|
+
border-radius: var(--cc-bubble-border-radius, 15px);
|
|
259
|
+
max-width: 295px;
|
|
260
|
+
position: relative;
|
|
261
|
+
display: flex;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.audioWrapper {
|
|
265
|
+
display: flex;
|
|
266
|
+
align-items: center;
|
|
267
|
+
justify-content: space-between;
|
|
268
|
+
height: 52px;
|
|
269
|
+
width: 100%;
|
|
270
|
+
gap: 10px;
|
|
271
|
+
background-color: var(--cc-white, #ffffff);
|
|
272
|
+
border-radius: var(--cc-bubble-border-radius, 15px);
|
|
273
|
+
border: 1px solid var(--cc-black-80, rgba(0, 0, 0, 0.8));
|
|
274
|
+
padding: 0px 12px;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.audioWrapper .controls {
|
|
278
|
+
display: flex;
|
|
279
|
+
flex-direction: row;
|
|
280
|
+
align-items: center;
|
|
281
|
+
justify-content: space-between;
|
|
282
|
+
gap: 16px;
|
|
283
|
+
width: 100%;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.audioWrapper .downloadButton {
|
|
287
|
+
background-color: transparent;
|
|
288
|
+
border: none;
|
|
289
|
+
outline: none;
|
|
290
|
+
cursor: pointer;
|
|
291
|
+
width: 30px;
|
|
292
|
+
height: 30px;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.audioWrapper .downloadButton:hover :deep(svg path) {
|
|
296
|
+
fill-opacity: 0.85;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.audioWrapper .downloadButton:focus-visible {
|
|
300
|
+
outline: 2px solid var(--cc-primary-color-focus, #1976d2);
|
|
301
|
+
outline-offset: 2px;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
button.playButton {
|
|
305
|
+
all: unset;
|
|
306
|
+
outline: revert;
|
|
307
|
+
line-height: 0;
|
|
308
|
+
display: block;
|
|
309
|
+
cursor: pointer;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
button.playButton:hover,
|
|
313
|
+
button.playButton:focus {
|
|
314
|
+
outline: none;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
button.playButton:focus-visible {
|
|
318
|
+
outline: 2px solid var(--cc-primary-color-focus, #1976d2);
|
|
319
|
+
outline-offset: 2px;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
button.playButton:focus :deep(svg circle),
|
|
323
|
+
button.playButton:hover :deep(svg circle) {
|
|
324
|
+
fill-opacity: 0.85;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/* Custom range input */
|
|
328
|
+
.progressBar {
|
|
329
|
+
display: flex;
|
|
330
|
+
width: 100%;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
.progressBar input[type="range"] {
|
|
334
|
+
-webkit-appearance: none;
|
|
335
|
+
appearance: none;
|
|
336
|
+
width: 100%;
|
|
337
|
+
cursor: pointer;
|
|
338
|
+
outline: none;
|
|
339
|
+
border-radius: var(--cc-bubble-border-radius, 15px);
|
|
340
|
+
height: 3px;
|
|
341
|
+
background: var(--cc-black-80, rgba(0, 0, 0, 0.8));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/* Thumb: Webkit */
|
|
345
|
+
.progressBar input[type="range"]::-webkit-slider-thumb {
|
|
346
|
+
-webkit-appearance: none;
|
|
347
|
+
appearance: none;
|
|
348
|
+
height: 13px;
|
|
349
|
+
width: 13px;
|
|
350
|
+
background-color: var(--cc-primary-color-focus, #1976d2);
|
|
351
|
+
border-radius: 50%;
|
|
352
|
+
border: none;
|
|
353
|
+
transition: 0.2s ease-in-out;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/* Thumb: Firefox */
|
|
357
|
+
.progressBar input[type="range"]::-moz-range-thumb {
|
|
358
|
+
height: 13px;
|
|
359
|
+
width: 13px;
|
|
360
|
+
background-color: var(--cc-primary-color-focus, #1976d2);
|
|
361
|
+
border-radius: 50%;
|
|
362
|
+
border: none;
|
|
363
|
+
transition: 0.2s ease-in-out;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/* Hover, active & focus Thumb: Webkit */
|
|
367
|
+
.progressBar input[type="range"]::-webkit-slider-thumb:hover {
|
|
368
|
+
box-shadow: 0 0 0 7px var(--cc-primary-color-opacity-10, rgba(25, 118, 210, 0.1));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.progressBar input[type="range"]:active::-webkit-slider-thumb {
|
|
372
|
+
box-shadow: 0 0 0 9px var(--cc-primary-color-opacity-20, rgba(25, 118, 210, 0.2));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
.progressBar input[type="range"]:focus::-webkit-slider-thumb {
|
|
376
|
+
box-shadow: 0 0 0 9px var(--cc-primary-color-opacity-20, rgba(25, 118, 210, 0.2));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/* Hover, active & focus Thumb: Firefox */
|
|
380
|
+
.progressBar input[type="range"]::-moz-range-thumb:hover {
|
|
381
|
+
box-shadow: 0 0 0 7px var(--cc-primary-color-opacity-10, rgba(25, 118, 210, 0.1));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
.progressBar input[type="range"]:active::-moz-range-thumb {
|
|
385
|
+
box-shadow: 0 0 0 9px var(--cc-primary-color-opacity-20, rgba(25, 118, 210, 0.2));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
.progressBar input[type="range"]:focus::-moz-range-thumb {
|
|
389
|
+
box-shadow: 0 0 0 9px var(--cc-primary-color-opacity-20, rgba(25, 118, 210, 0.2));
|
|
390
|
+
}
|
|
391
|
+
</style>
|