@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.
Files changed (40) hide show
  1. package/dist/chat-components-vue.css +1 -1
  2. package/dist/chat-components-vue.js +12386 -5741
  3. package/dist/components/Message.vue.d.ts +4 -0
  4. package/dist/components/common/Typography.vue.d.ts +1 -1
  5. package/dist/components/messages/AdaptiveCardRenderer.vue.d.ts +35 -0
  6. package/dist/components/messages/ListItem.vue.d.ts +1 -1
  7. package/dist/composables/useLiveRegion.d.ts +30 -0
  8. package/dist/index.d.ts +3 -2
  9. package/dist/types/index.d.ts +105 -1
  10. package/dist/utils/helpers.d.ts +3 -2
  11. package/dist/utils/matcher.d.ts +3 -3
  12. package/dist/utils/theme.d.ts +12 -1
  13. package/package.json +8 -3
  14. package/src/components/Message.vue +98 -55
  15. package/src/components/common/ActionButton.vue +16 -7
  16. package/src/components/common/ChatBubble.vue +8 -6
  17. package/src/components/common/ChatEvent.vue +5 -2
  18. package/src/components/common/TypingIndicator.vue +4 -1
  19. package/src/components/common/Typography.vue +56 -67
  20. package/src/components/messages/AdaptiveCard.vue +322 -225
  21. package/src/components/messages/AdaptiveCardRenderer.vue +260 -0
  22. package/src/components/messages/AudioMessage.vue +4 -1
  23. package/src/components/messages/DatePicker.vue +5 -27
  24. package/src/components/messages/FileMessage.vue +12 -3
  25. package/src/components/messages/Gallery.vue +96 -10
  26. package/src/components/messages/GalleryItem.vue +17 -5
  27. package/src/components/messages/ImageMessage.vue +20 -5
  28. package/src/components/messages/List.vue +56 -42
  29. package/src/components/messages/ListItem.vue +105 -68
  30. package/src/components/messages/TextMessage.vue +1 -1
  31. package/src/components/messages/TextWithButtons.vue +35 -11
  32. package/src/components/messages/VideoMessage.vue +35 -26
  33. package/src/composables/useCollation.ts +28 -45
  34. package/src/composables/useLiveRegion.ts +101 -0
  35. package/src/index.ts +4 -1
  36. package/src/types/index.ts +127 -2
  37. package/src/utils/helpers.ts +46 -24
  38. package/src/utils/matcher.ts +20 -6
  39. package/src/utils/sanitize.ts +1 -2
  40. package/src/utils/theme.ts +42 -1
@@ -64,23 +64,23 @@
64
64
  </video>
65
65
  </div>
66
66
 
67
- <!-- Download transcript button -->
68
- <div v-if="videoData.altText" :class="$style.downloadButtonWrapper">
69
- <button
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
- ref="downloadLinkRef"
78
- :href="transcriptDataUrl"
79
- download="video-transcript.txt"
80
- style="display: none"
81
- aria-hidden="true"
82
- />
83
- </div>
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
- .wrapperWithButton {
254
- border: 1px solid var(--cc-black-80, rgba(0, 0, 0, 0.8));
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
- .downloadButtonWrapper {
263
- padding: 16px;
264
- background-color: var(--cc-white, #ffffff);
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: none;
279
- border-radius: 8px;
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
- // Must both be bot messages
51
- if (current.source !== 'bot' || previous.source !== 'bot') {
52
- return false
53
- }
54
-
55
- // Must both be simple text messages (no rich content)
56
- if (current.data?._cognigy?._webchat || previous.data?._cognigy?._webchat) {
57
- return false
58
- }
59
-
60
- // Must both have text
61
- if (!current.text || !previous.text) {
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 (let i = 0; i < msgs.length; i++) {
113
- const current = msgs[i]
99
+ for (const current of msgs) {
100
+ const lastIndex = result.length - 1
101
+ const lastCollated = result[lastIndex]
114
102
 
115
- // Check if we can collate with the last message in result
116
- if (result.length > 0) {
117
- const lastCollated = result[result.length - 1]
118
- const lastOriginal = lastCollated.collatedFrom
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: combine texts with newline separator
124
- const existingText = lastCollated.text ?? ''
125
- const currentText = current.text ?? ''
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
- // Update the last message with combined text (joined by newline)
132
- result[result.length - 1] = {
115
+ result[lastIndex] = {
133
116
  ...lastCollated,
134
- text: existingText + '\n' + currentText,
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 as CollatedMessage)
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,
@@ -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 (message as IMessageWithId).id === 'string'
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
  }
@@ -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(/{(\w+)}/g, (_, key) => {
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, '&amp;')
71
+ .replace(/"/g, '&quot;')
72
+ .replace(/'/g, '&#39;')
73
+ .replace(/</g, '&lt;')
74
+ .replace(/>/g, '&gt;')
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
- // Enhanced regex to capture URLs with parameters
66
- const urlMatcherRegex =
67
- /(^|\s)(\b(https?):\/\/([-A-Z0-9+&@$#/%?=~_|!:,.;\p{L}]*[-A-Z0-9+&$@#/%=~_|\p{L}]))/giu
68
-
69
- return text.replace(urlMatcherRegex, (url) => {
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(/[\r\n\f]/g, '')
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 (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(sanitized)) {
104
+ if (URL_SCHEME_REGEX.test(sanitized)) {
86
105
  try {
87
106
  const parsed = new URL(sanitized)
88
- if (!/^https?:$/i.test(parsed.protocol)) {
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 splitName = fileNameWithExtension.split('.')
121
- if (splitName.length > 1) {
122
- return `${splitName.slice(0, -1).join('.')}.`
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 splitName = fileNameWithExtension.split('.')
135
- if (splitName.length > 1) {
136
- return splitName.pop() || null
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 = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
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.includes(mimeType)
185
+ return VALID_IMAGE_MIME_TYPES.has(mimeType)
164
186
  }