@cdevhub/ngx-chat 1.0.8 → 1.0.9

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.
@@ -1,11 +1,11 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, makeEnvironmentProviders, inject, Injectable, TemplateRef, Directive, input, ChangeDetectionStrategy, Component, output, signal, HostListener, viewChild, computed, DestroyRef, effect, viewChildren, linkedSignal, afterRenderEffect, contentChild, Injector, afterNextRender, PLATFORM_ID } from '@angular/core';
2
+ import { InjectionToken, makeEnvironmentProviders, inject, Injectable, TemplateRef, Directive, input, ChangeDetectionStrategy, Component, output, signal, HostListener, viewChild, computed, DestroyRef, effect, viewChildren, linkedSignal, afterRenderEffect, contentChild, Injector, afterNextRender, ElementRef, PLATFORM_ID } from '@angular/core';
3
3
  import * as i1 from '@angular/common';
4
4
  import { CommonModule, DOCUMENT, isPlatformBrowser } from '@angular/common';
5
5
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
6
6
  import { Subject } from 'rxjs';
7
7
  import { NgScrollbar } from 'ngx-scrollbar';
8
- import { LiveAnnouncer } from '@angular/cdk/a11y';
8
+ import { LiveAnnouncer, FocusTrapFactory } from '@angular/cdk/a11y';
9
9
  import { DomSanitizer } from '@angular/platform-browser';
10
10
  import { trigger, transition, style, animate, state, keyframes } from '@angular/animations';
11
11
 
@@ -73,6 +73,13 @@ const DEFAULT_MARKDOWN_CONFIG = {
73
73
  'h4',
74
74
  'h5',
75
75
  'h6',
76
+ // Table elements (GFM tables)
77
+ 'table',
78
+ 'thead',
79
+ 'tbody',
80
+ 'tr',
81
+ 'th',
82
+ 'td',
76
83
  ],
77
84
  allowedAttributes: {
78
85
  a: ['href', 'title'],
@@ -3344,6 +3351,7 @@ class ChatSenderComponent {
3344
3351
  name: a.file.name,
3345
3352
  mimeType: a.file.type,
3346
3353
  size: a.file.size,
3354
+ file: a.file,
3347
3355
  }));
3348
3356
  // Emit send event
3349
3357
  const event = {
@@ -3589,6 +3597,8 @@ function groupMessages(messages, thresholdMs = DEFAULT_GROUPING_THRESHOLD_MS) {
3589
3597
  function createGroup(messages) {
3590
3598
  const firstMessage = messages[0];
3591
3599
  return {
3600
+ // Use first message ID as stable group identifier for tracking
3601
+ id: `group-${firstMessage.id}`,
3592
3602
  senderId: firstMessage.sender,
3593
3603
  senderName: firstMessage.senderName,
3594
3604
  avatar: firstMessage.avatar,
@@ -3936,6 +3946,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
3936
3946
  * }
3937
3947
  * ```
3938
3948
  */
3949
+ /**
3950
+ * @Injectable without `providedIn` - must be provided at component level.
3951
+ *
3952
+ * This service maintains stateful data (scroll position, item heights, etc.)
3953
+ * that must be scoped to each ChatMessagesComponent instance. If provided
3954
+ * at root level, multiple chat instances would share state, causing bugs.
3955
+ *
3956
+ * @see ChatMessagesComponent which provides this service
3957
+ */
3939
3958
  class ChatVirtualScrollService {
3940
3959
  configService = inject(ChatConfigService);
3941
3960
  config = this.configService.getConfig().virtualScroll;
@@ -4219,11 +4238,10 @@ class ChatVirtualScrollService {
4219
4238
  return low;
4220
4239
  }
4221
4240
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: ChatVirtualScrollService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
4222
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: ChatVirtualScrollService, providedIn: 'root' });
4241
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: ChatVirtualScrollService });
4223
4242
  }
4224
4243
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: ChatVirtualScrollService, decorators: [{
4225
- type: Injectable,
4226
- args: [{ providedIn: 'root' }]
4244
+ type: Injectable
4227
4245
  }] });
4228
4246
 
4229
4247
  /**
@@ -7962,10 +7980,8 @@ class ChatMessagesComponent {
7962
7980
  loadMoreDebounced = signal(false, ...(ngDevMode ? [{ debugName: "loadMoreDebounced" }] : []));
7963
7981
  /** Timestamp formatter cache */
7964
7982
  timestampFormatter = null;
7965
- /** ResizeObserver for measuring message heights in virtual scroll mode */
7983
+ /** Native ResizeObserver for measuring message heights in virtual scroll mode */
7966
7984
  resizeObserver = null;
7967
- /** Map of message IDs to their indices for height measurement */
7968
- messageIndexMap = new Map();
7969
7985
  // ===========================================================================
7970
7986
  // Computed Values
7971
7987
  // ===========================================================================
@@ -8051,21 +8067,28 @@ class ChatMessagesComponent {
8051
8067
  visibleStartIndex = computed(() => {
8052
8068
  return this.virtualScrollService.visibleRange().startIndex;
8053
8069
  }, ...(ngDevMode ? [{ debugName: "visibleStartIndex" }] : []));
8070
+ /**
8071
+ * Computed map of message IDs to their indices.
8072
+ * Used for height measurement in virtual scroll mode.
8073
+ * Uses computed() instead of effect() to avoid anti-pattern.
8074
+ */
8075
+ messageIndexMap = computed(() => {
8076
+ const msgs = this.messages();
8077
+ const map = new Map();
8078
+ for (let i = 0; i < msgs.length; i++) {
8079
+ map.set(msgs[i].id, i);
8080
+ }
8081
+ return map;
8082
+ }, ...(ngDevMode ? [{ debugName: "messageIndexMap" }] : []));
8083
+ /**
8084
+ * Tracks the previous message count to detect changes.
8085
+ * Used to sync virtual scroll service only when needed.
8086
+ */
8087
+ lastSyncedMessageCount = 0;
8054
8088
  // ===========================================================================
8055
8089
  // Constructor & Lifecycle
8056
8090
  // ===========================================================================
8057
8091
  constructor() {
8058
- // Effect to sync message count with virtual scroll service
8059
- effect(() => {
8060
- const count = this.messages().length;
8061
- this.virtualScrollService.setItemCount(count);
8062
- // Build message ID to index map for height measurement
8063
- this.messageIndexMap.clear();
8064
- const msgs = this.messages();
8065
- for (let i = 0; i < msgs.length; i++) {
8066
- this.messageIndexMap.set(msgs[i].id, i);
8067
- }
8068
- });
8069
8092
  // Auto-scroll effect using afterRenderEffect with earlyRead/write phases
8070
8093
  // Phase order: earlyRead → write → mixedReadWrite → read
8071
8094
  afterRenderEffect({
@@ -8083,10 +8106,14 @@ class ChatMessagesComponent {
8083
8106
  const lastProcessedId = this.lastProcessedMessageId();
8084
8107
  const hasNewMessage = currentLastMessage &&
8085
8108
  currentLastMessage.id !== lastProcessedId;
8086
- // Determine if we should scroll
8087
- const shouldScroll = this.isNearBottom() &&
8088
- hasNewMessage &&
8089
- this.effectiveConfig().behavior.autoScroll;
8109
+ const isFromSelf = currentLastMessage?.sender === 'self';
8110
+ const autoScrollEnabled = this.effectiveConfig().behavior.autoScroll;
8111
+ // Determine if we should scroll:
8112
+ // - Always scroll for self messages (user sent it)
8113
+ // - For other messages, only scroll if user is near bottom
8114
+ const shouldScroll = hasNewMessage &&
8115
+ autoScrollEnabled &&
8116
+ (isFromSelf || this.isNearBottom());
8090
8117
  return {
8091
8118
  shouldScroll,
8092
8119
  newMessageId: currentLastMessage?.id ?? null,
@@ -8096,6 +8123,13 @@ class ChatMessagesComponent {
8096
8123
  },
8097
8124
  write: (earlyReadResultSignal) => {
8098
8125
  const readResult = earlyReadResultSignal();
8126
+ // Sync message count with virtual scroll service (replaces effect() anti-pattern)
8127
+ // Only update when count changes to avoid unnecessary work
8128
+ const currentCount = this.messages().length;
8129
+ if (currentCount !== this.lastSyncedMessageCount) {
8130
+ this.virtualScrollService.setItemCount(currentCount);
8131
+ this.lastSyncedMessageCount = currentCount;
8132
+ }
8099
8133
  if (!readResult || !readResult.newMessageId)
8100
8134
  return;
8101
8135
  const { shouldScroll, newMessageId, isFromOther, message } = readResult;
@@ -8114,11 +8148,10 @@ class ChatMessagesComponent {
8114
8148
  },
8115
8149
  });
8116
8150
  // Height measurement effect for virtual scrolling
8117
- // Uses read phase for DOM reading after Angular has rendered
8151
+ // Uses native ResizeObserver for measuring message heights
8118
8152
  afterRenderEffect(() => {
8119
8153
  // Only measure heights when virtual scrolling is active
8120
8154
  if (!this.shouldUseVirtualScroll()) {
8121
- // Disconnect observer when not in virtual mode
8122
8155
  if (this.resizeObserver) {
8123
8156
  this.resizeObserver.disconnect();
8124
8157
  }
@@ -8130,7 +8163,7 @@ class ChatMessagesComponent {
8130
8163
  }
8131
8164
  const viewport = scrollbarInstance.viewport.nativeElement;
8132
8165
  const virtualItems = viewport.querySelectorAll('.ngx-chat-messages__virtual-item');
8133
- // Create ResizeObserver if it doesn't exist
8166
+ // Create ResizeObserver lazily
8134
8167
  if (!this.resizeObserver) {
8135
8168
  this.resizeObserver = new ResizeObserver((entries) => {
8136
8169
  for (const entry of entries) {
@@ -8188,14 +8221,18 @@ class ChatMessagesComponent {
8188
8221
  // ===========================================================================
8189
8222
  /**
8190
8223
  * Programmatically scrolls to the bottom of the message list.
8224
+ * Uses requestAnimationFrame to ensure DOM is fully updated before scrolling.
8191
8225
  */
8192
8226
  scrollToBottom() {
8193
8227
  const scrollbarInstance = this.scrollbar();
8194
8228
  if (!scrollbarInstance)
8195
8229
  return;
8196
- scrollbarInstance.scrollTo({
8197
- bottom: 0,
8198
- duration: 200,
8230
+ // Use requestAnimationFrame to ensure DOM has updated before scrolling
8231
+ requestAnimationFrame(() => {
8232
+ scrollbarInstance.scrollTo({
8233
+ bottom: 0,
8234
+ duration: 200,
8235
+ });
8199
8236
  });
8200
8237
  }
8201
8238
  /**
@@ -8252,7 +8289,7 @@ class ChatMessagesComponent {
8252
8289
  * Reports measured heights to virtual scroll service.
8253
8290
  */
8254
8291
  onMessageHeightChange(messageId, height) {
8255
- const index = this.messageIndexMap.get(messageId);
8292
+ const index = this.messageIndexMap().get(messageId);
8256
8293
  if (index !== undefined && height > 0) {
8257
8294
  this.virtualScrollService.setItemHeightAt(index, height);
8258
8295
  }
@@ -8275,10 +8312,9 @@ class ChatMessagesComponent {
8275
8312
  this.resizeObserver.disconnect();
8276
8313
  this.resizeObserver = null;
8277
8314
  }
8278
- this.messageIndexMap.clear();
8279
8315
  }
8280
8316
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: ChatMessagesComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
8281
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.0.6", type: ChatMessagesComponent, isStandalone: true, selector: "ngx-chat-messages", inputs: { messages: { classPropertyName: "messages", publicName: "messages", isSignal: true, isRequired: false, transformFunction: null }, isTyping: { classPropertyName: "isTyping", publicName: "isTyping", isSignal: true, isRequired: false, transformFunction: null }, typingLabel: { classPropertyName: "typingLabel", publicName: "typingLabel", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, loadingMore: { classPropertyName: "loadingMore", publicName: "loadingMore", isSignal: true, isRequired: false, transformFunction: null }, hasMore: { classPropertyName: "hasMore", publicName: "hasMore", isSignal: true, isRequired: false, transformFunction: null }, config: { classPropertyName: "config", publicName: "config", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { loadMore: "loadMore" }, host: { attributes: { "role": "log", "aria-live": "polite" }, properties: { "attr.aria-busy": "loading() || loadingMore()" }, classAttribute: "ngx-chat-messages" }, viewQueries: [{ propertyName: "scrollbar", first: true, predicate: ["scrollbar"], descendants: true, isSignal: true }], ngImport: i0, template: `
8317
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.0.6", type: ChatMessagesComponent, isStandalone: true, selector: "ngx-chat-messages", inputs: { messages: { classPropertyName: "messages", publicName: "messages", isSignal: true, isRequired: false, transformFunction: null }, isTyping: { classPropertyName: "isTyping", publicName: "isTyping", isSignal: true, isRequired: false, transformFunction: null }, typingLabel: { classPropertyName: "typingLabel", publicName: "typingLabel", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, loadingMore: { classPropertyName: "loadingMore", publicName: "loadingMore", isSignal: true, isRequired: false, transformFunction: null }, hasMore: { classPropertyName: "hasMore", publicName: "hasMore", isSignal: true, isRequired: false, transformFunction: null }, config: { classPropertyName: "config", publicName: "config", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { loadMore: "loadMore" }, host: { attributes: { "role": "log", "aria-live": "polite" }, properties: { "attr.aria-busy": "loading() || loadingMore()" }, classAttribute: "ngx-chat-messages" }, providers: [ChatVirtualScrollService], viewQueries: [{ propertyName: "scrollbar", first: true, predicate: ["scrollbar"], descendants: true, isSignal: true }], ngImport: i0, template: `
8282
8318
  <ng-scrollbar
8283
8319
  #scrollbar
8284
8320
  class="ngx-chat-messages__scrollbar"
@@ -8358,7 +8394,7 @@ class ChatMessagesComponent {
8358
8394
  }
8359
8395
 
8360
8396
  <!-- Message groups -->
8361
- @for (group of messageGroups(); track group.timestamp.getTime()) {
8397
+ @for (group of messageGroups(); track group.id) {
8362
8398
  <div
8363
8399
  class="ngx-chat-messages__group"
8364
8400
  [class.ngx-chat-messages__group--self]="group.senderId === 'self'"
@@ -8393,7 +8429,7 @@ class ChatMessagesComponent {
8393
8429
  }
8394
8430
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: ChatMessagesComponent, decorators: [{
8395
8431
  type: Component,
8396
- args: [{ selector: 'ngx-chat-messages', standalone: true, imports: [NgScrollbar, ChatTypingIndicatorComponent, ChatMessageBubbleComponent], template: `
8432
+ args: [{ selector: 'ngx-chat-messages', standalone: true, imports: [NgScrollbar, ChatTypingIndicatorComponent, ChatMessageBubbleComponent], providers: [ChatVirtualScrollService], template: `
8397
8433
  <ng-scrollbar
8398
8434
  #scrollbar
8399
8435
  class="ngx-chat-messages__scrollbar"
@@ -8473,7 +8509,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
8473
8509
  }
8474
8510
 
8475
8511
  <!-- Message groups -->
8476
- @for (group of messageGroups(); track group.timestamp.getTime()) {
8512
+ @for (group of messageGroups(); track group.id) {
8477
8513
  <div
8478
8514
  class="ngx-chat-messages__group"
8479
8515
  [class.ngx-chat-messages__group--self]="group.senderId === 'self'"
@@ -10707,13 +10743,148 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
10707
10743
  */
10708
10744
  // Main chat component
10709
10745
 
10746
+ /**
10747
+ * @fileoverview Focus trap directive for modal and action components.
10748
+ * Traps keyboard focus within a container for accessibility compliance.
10749
+ * @module ngx-chat/directives
10750
+ */
10751
+ /**
10752
+ * Directive that traps focus within its host element.
10753
+ *
10754
+ * This directive is used for accessibility compliance in modal dialogs,
10755
+ * action panels, and other overlay content where keyboard focus should
10756
+ * be constrained to the interactive elements within.
10757
+ *
10758
+ * Features:
10759
+ * - Traps Tab/Shift+Tab navigation within the element
10760
+ * - Auto-focuses the first focusable element when enabled
10761
+ * - Restores focus to the previously focused element on destroy
10762
+ * - Supports deferred focus for async content
10763
+ *
10764
+ * @example Basic usage
10765
+ * ```html
10766
+ * <div ngxChatFocusTrap>
10767
+ * <button>First</button>
10768
+ * <button>Second</button>
10769
+ * <button>Third</button>
10770
+ * </div>
10771
+ * ```
10772
+ *
10773
+ * @example Conditional focus trap
10774
+ * ```html
10775
+ * <div [ngxChatFocusTrap]="isOpen()">
10776
+ * <button>Close</button>
10777
+ * <div>Modal content</div>
10778
+ * </div>
10779
+ * ```
10780
+ *
10781
+ * @example With auto-focus disabled
10782
+ * ```html
10783
+ * <div ngxChatFocusTrap [ngxChatFocusTrapAutoFocus]="false">
10784
+ * <button>First</button>
10785
+ * </div>
10786
+ * ```
10787
+ */
10788
+ class ChatFocusTrapDirective {
10789
+ elementRef = inject((ElementRef));
10790
+ focusTrapFactory = inject(FocusTrapFactory);
10791
+ injector = inject(Injector);
10792
+ /**
10793
+ * Whether the focus trap is enabled.
10794
+ * When false, focus can move freely in and out of the element.
10795
+ */
10796
+ ngxChatFocusTrap = input(true, ...(ngDevMode ? [{ debugName: "ngxChatFocusTrap" }] : []));
10797
+ /**
10798
+ * Whether to auto-focus the first tabbable element when the trap is created.
10799
+ */
10800
+ ngxChatFocusTrapAutoFocus = input(true, ...(ngDevMode ? [{ debugName: "ngxChatFocusTrapAutoFocus" }] : []));
10801
+ /**
10802
+ * The underlying CDK FocusTrap instance.
10803
+ */
10804
+ focusTrap = null;
10805
+ /**
10806
+ * Element that had focus before the trap was activated.
10807
+ * Used to restore focus on destroy.
10808
+ */
10809
+ previouslyFocusedElement = null;
10810
+ constructor() {
10811
+ afterNextRender(() => {
10812
+ this.initializeFocusTrap();
10813
+ }, { injector: this.injector });
10814
+ }
10815
+ /**
10816
+ * Initializes the focus trap after render.
10817
+ */
10818
+ initializeFocusTrap() {
10819
+ if (!this.ngxChatFocusTrap()) {
10820
+ return;
10821
+ }
10822
+ // Store the currently focused element
10823
+ this.previouslyFocusedElement = document.activeElement;
10824
+ // Create the focus trap
10825
+ this.focusTrap = this.focusTrapFactory.create(this.elementRef.nativeElement);
10826
+ // Enable the trap
10827
+ this.focusTrap.enabled = true;
10828
+ // Auto-focus the first tabbable element
10829
+ if (this.ngxChatFocusTrapAutoFocus()) {
10830
+ this.focusTrap.focusInitialElementWhenReady();
10831
+ }
10832
+ }
10833
+ /**
10834
+ * Manually focuses the first tabbable element within the trap.
10835
+ */
10836
+ focusFirstTabbableElement() {
10837
+ this.focusTrap?.focusFirstTabbableElement();
10838
+ }
10839
+ /**
10840
+ * Manually focuses the last tabbable element within the trap.
10841
+ */
10842
+ focusLastTabbableElement() {
10843
+ this.focusTrap?.focusLastTabbableElement();
10844
+ }
10845
+ /**
10846
+ * Checks if an element is focusable within the trap.
10847
+ */
10848
+ hasFocusableElements() {
10849
+ const element = this.elementRef.nativeElement;
10850
+ const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
10851
+ return element.querySelectorAll(focusableSelector).length > 0;
10852
+ }
10853
+ /**
10854
+ * Cleanup on destroy.
10855
+ * Disables the trap and restores focus to the previous element.
10856
+ */
10857
+ ngOnDestroy() {
10858
+ if (this.focusTrap) {
10859
+ this.focusTrap.destroy();
10860
+ this.focusTrap = null;
10861
+ }
10862
+ // Restore focus to the previously focused element
10863
+ if (this.previouslyFocusedElement &&
10864
+ typeof this.previouslyFocusedElement.focus === 'function') {
10865
+ // Use setTimeout to ensure focus is restored after any animations
10866
+ setTimeout(() => {
10867
+ this.previouslyFocusedElement?.focus();
10868
+ }, 0);
10869
+ }
10870
+ }
10871
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: ChatFocusTrapDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
10872
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.6", type: ChatFocusTrapDirective, isStandalone: true, selector: "[ngxChatFocusTrap]", inputs: { ngxChatFocusTrap: { classPropertyName: "ngxChatFocusTrap", publicName: "ngxChatFocusTrap", isSignal: true, isRequired: false, transformFunction: null }, ngxChatFocusTrapAutoFocus: { classPropertyName: "ngxChatFocusTrapAutoFocus", publicName: "ngxChatFocusTrapAutoFocus", isSignal: true, isRequired: false, transformFunction: null } }, exportAs: ["ngxChatFocusTrap"], ngImport: i0 });
10873
+ }
10874
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: ChatFocusTrapDirective, decorators: [{
10875
+ type: Directive,
10876
+ args: [{
10877
+ selector: '[ngxChatFocusTrap]',
10878
+ standalone: true,
10879
+ exportAs: 'ngxChatFocusTrap',
10880
+ }]
10881
+ }], ctorParameters: () => [], propDecorators: { ngxChatFocusTrap: [{ type: i0.Input, args: [{ isSignal: true, alias: "ngxChatFocusTrap", required: false }] }], ngxChatFocusTrapAutoFocus: [{ type: i0.Input, args: [{ isSignal: true, alias: "ngxChatFocusTrapAutoFocus", required: false }] }] } });
10882
+
10710
10883
  /**
10711
10884
  * @fileoverview Directives barrel export.
10712
10885
  * @module ngx-chat/directives
10713
10886
  */
10714
10887
  // Header content projection directive
10715
- // Additional directives will be added as they are created:
10716
- // export { ChatFocusTrapDirective } from './chat-focus-trap.directive';
10717
10888
 
10718
10889
  /**
10719
10890
  * @fileoverview Error constants and factory functions for ngx-chat.
@@ -11598,6 +11769,386 @@ function getActionById(message, actionId) {
11598
11769
  return message.actions.find((action) => action.id === actionId);
11599
11770
  }
11600
11771
 
11772
+ /**
11773
+ * @fileoverview Attachment utility functions for ngx-chat library.
11774
+ * Pure functions for creating, validating, and transforming attachments.
11775
+ * @module ngx-chat/utils
11776
+ */
11777
+ // ============================================================================
11778
+ // Constants
11779
+ // ============================================================================
11780
+ /**
11781
+ * MIME type to attachment type mapping.
11782
+ */
11783
+ const MIME_TYPE_MAP = {
11784
+ // Images
11785
+ 'image/jpeg': 'image',
11786
+ 'image/jpg': 'image',
11787
+ 'image/png': 'image',
11788
+ 'image/gif': 'image',
11789
+ 'image/webp': 'image',
11790
+ 'image/svg+xml': 'image',
11791
+ 'image/bmp': 'image',
11792
+ 'image/tiff': 'image',
11793
+ 'image/avif': 'image',
11794
+ 'image/heic': 'image',
11795
+ 'image/heif': 'image',
11796
+ // Videos
11797
+ 'video/mp4': 'video',
11798
+ 'video/webm': 'video',
11799
+ 'video/ogg': 'video',
11800
+ 'video/quicktime': 'video',
11801
+ 'video/x-msvideo': 'video',
11802
+ 'video/x-matroska': 'video',
11803
+ // Audio
11804
+ 'audio/mpeg': 'audio',
11805
+ 'audio/mp3': 'audio',
11806
+ 'audio/wav': 'audio',
11807
+ 'audio/ogg': 'audio',
11808
+ 'audio/webm': 'audio',
11809
+ 'audio/aac': 'audio',
11810
+ 'audio/flac': 'audio',
11811
+ 'audio/x-m4a': 'audio',
11812
+ };
11813
+ /**
11814
+ * File extensions that support preview generation.
11815
+ */
11816
+ const PREVIEWABLE_EXTENSIONS = new Set([
11817
+ 'jpg',
11818
+ 'jpeg',
11819
+ 'png',
11820
+ 'gif',
11821
+ 'webp',
11822
+ 'bmp',
11823
+ 'svg',
11824
+ 'mp4',
11825
+ 'webm',
11826
+ 'ogg',
11827
+ ]);
11828
+ // ============================================================================
11829
+ // Type Detection Functions
11830
+ // ============================================================================
11831
+ /**
11832
+ * Determines the attachment type from a MIME type string.
11833
+ *
11834
+ * @param mimeType - The MIME type to check (e.g., 'image/jpeg')
11835
+ * @returns The attachment type, defaults to 'file' for unknown types
11836
+ *
11837
+ * @example
11838
+ * ```typescript
11839
+ * getAttachmentType('image/jpeg'); // 'image'
11840
+ * getAttachmentType('video/mp4'); // 'video'
11841
+ * getAttachmentType('application/pdf'); // 'file'
11842
+ * ```
11843
+ */
11844
+ function getAttachmentType(mimeType) {
11845
+ // Normalize the MIME type
11846
+ const normalized = mimeType.toLowerCase().trim();
11847
+ // Check exact match first
11848
+ if (MIME_TYPE_MAP[normalized]) {
11849
+ return MIME_TYPE_MAP[normalized];
11850
+ }
11851
+ // Check by prefix for generic types
11852
+ if (normalized.startsWith('image/')) {
11853
+ return 'image';
11854
+ }
11855
+ if (normalized.startsWith('video/')) {
11856
+ return 'video';
11857
+ }
11858
+ if (normalized.startsWith('audio/')) {
11859
+ return 'audio';
11860
+ }
11861
+ return 'file';
11862
+ }
11863
+ /**
11864
+ * Determines the attachment type from a File object.
11865
+ *
11866
+ * @param file - The File object to check
11867
+ * @returns The attachment type
11868
+ *
11869
+ * @example
11870
+ * ```typescript
11871
+ * const file = new File([''], 'photo.jpg', { type: 'image/jpeg' });
11872
+ * getAttachmentTypeFromFile(file); // 'image'
11873
+ * ```
11874
+ */
11875
+ function getAttachmentTypeFromFile(file) {
11876
+ return getAttachmentType(file.type);
11877
+ }
11878
+ /**
11879
+ * Checks if an attachment type supports preview generation.
11880
+ *
11881
+ * @param type - The attachment type to check
11882
+ * @returns True if previews can be generated
11883
+ */
11884
+ function isPreviewable(type) {
11885
+ return type === 'image' || type === 'video';
11886
+ }
11887
+ /**
11888
+ * Checks if a file extension supports preview generation.
11889
+ *
11890
+ * @param filename - The filename to check
11891
+ * @returns True if the file extension supports previews
11892
+ */
11893
+ function isPreviewableByExtension(filename) {
11894
+ const ext = getFileExtension(filename);
11895
+ return PREVIEWABLE_EXTENSIONS.has(ext);
11896
+ }
11897
+ // ============================================================================
11898
+ // Factory Functions
11899
+ // ============================================================================
11900
+ /**
11901
+ * Creates a PendingAttachment from a File object.
11902
+ *
11903
+ * @param file - The File object to create an attachment from
11904
+ * @param options - Optional configuration
11905
+ * @returns A PendingAttachment ready for upload
11906
+ *
11907
+ * @example
11908
+ * ```typescript
11909
+ * const file = new File(['content'], 'document.pdf', { type: 'application/pdf' });
11910
+ * const pending = createPendingAttachment(file);
11911
+ * // { id: 'att-123', file, type: 'file', status: 'pending', progress: 0 }
11912
+ * ```
11913
+ */
11914
+ function createPendingAttachment(file, options = {}) {
11915
+ const type = getAttachmentTypeFromFile(file);
11916
+ const id = options.id ?? generateId('att');
11917
+ const attachment = {
11918
+ id,
11919
+ file,
11920
+ type,
11921
+ status: 'pending',
11922
+ progress: 0,
11923
+ };
11924
+ // Create preview URL for images and videos
11925
+ if (options.createPreview !== false && isPreviewable(type)) {
11926
+ return {
11927
+ ...attachment,
11928
+ previewUrl: URL.createObjectURL(file),
11929
+ };
11930
+ }
11931
+ return attachment;
11932
+ }
11933
+ /**
11934
+ * Creates a MessageAttachment from upload result data.
11935
+ *
11936
+ * @param data - The attachment data from the server
11937
+ * @returns A MessageAttachment object
11938
+ */
11939
+ function createMessageAttachment(data) {
11940
+ return {
11941
+ id: data.id,
11942
+ type: getAttachmentType(data.mimeType),
11943
+ url: data.url,
11944
+ name: data.name,
11945
+ size: data.size,
11946
+ mimeType: data.mimeType,
11947
+ thumbnail: data.thumbnail,
11948
+ dimensions: data.dimensions
11949
+ ? { width: data.dimensions.width, height: data.dimensions.height }
11950
+ : undefined,
11951
+ duration: data.duration,
11952
+ };
11953
+ }
11954
+ // ============================================================================
11955
+ // Validation Functions
11956
+ // ============================================================================
11957
+ /**
11958
+ * Validates a file against size and type constraints.
11959
+ *
11960
+ * @param file - The file to validate
11961
+ * @param options - Validation options
11962
+ * @returns Validation result with error message if invalid
11963
+ *
11964
+ * @example
11965
+ * ```typescript
11966
+ * const result = validateAttachment(file, {
11967
+ * maxSize: 5 * 1024 * 1024, // 5MB
11968
+ * allowedTypes: ['image/*', 'application/pdf']
11969
+ * });
11970
+ * if (!result.valid) {
11971
+ * console.error(result.error);
11972
+ * }
11973
+ * ```
11974
+ */
11975
+ function validateAttachment(file, options = {}) {
11976
+ // Check file size
11977
+ if (options.maxSize && file.size > options.maxSize) {
11978
+ return {
11979
+ valid: false,
11980
+ error: `File exceeds maximum size of ${formatFileSize(options.maxSize)}`,
11981
+ };
11982
+ }
11983
+ // Check MIME type
11984
+ if (options.allowedTypes && options.allowedTypes.length > 0) {
11985
+ const isAllowed = options.allowedTypes.some((pattern) => {
11986
+ if (pattern.endsWith('/*')) {
11987
+ const prefix = pattern.slice(0, -1);
11988
+ return file.type.startsWith(prefix);
11989
+ }
11990
+ return file.type === pattern;
11991
+ });
11992
+ if (!isAllowed) {
11993
+ return {
11994
+ valid: false,
11995
+ error: `File type ${file.type} is not allowed`,
11996
+ };
11997
+ }
11998
+ }
11999
+ // Check extension
12000
+ if (options.allowedExtensions && options.allowedExtensions.length > 0) {
12001
+ const ext = getFileExtension(file.name);
12002
+ if (!options.allowedExtensions.includes(ext)) {
12003
+ return {
12004
+ valid: false,
12005
+ error: `File extension .${ext} is not allowed`,
12006
+ };
12007
+ }
12008
+ }
12009
+ return { valid: true };
12010
+ }
12011
+ // ============================================================================
12012
+ // Formatting Functions
12013
+ // ============================================================================
12014
+ /**
12015
+ * Formats a file size in bytes to a human-readable string.
12016
+ *
12017
+ * @param bytes - The size in bytes
12018
+ * @param decimals - Number of decimal places (default: 1)
12019
+ * @returns Formatted string like "1.5 MB"
12020
+ *
12021
+ * @example
12022
+ * ```typescript
12023
+ * formatFileSize(1024); // "1 KB"
12024
+ * formatFileSize(1536); // "1.5 KB"
12025
+ * formatFileSize(1048576); // "1 MB"
12026
+ * formatFileSize(1073741824); // "1 GB"
12027
+ * ```
12028
+ */
12029
+ function formatFileSize(bytes, decimals = 1) {
12030
+ if (bytes === 0)
12031
+ return '0 B';
12032
+ if (bytes < 0)
12033
+ return '0 B';
12034
+ const k = 1024;
12035
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
12036
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
12037
+ const index = Math.min(i, sizes.length - 1);
12038
+ const value = bytes / Math.pow(k, index);
12039
+ // Use integer for small values
12040
+ if (value < 10 && index > 0) {
12041
+ return `${value.toFixed(decimals)} ${sizes[index]}`;
12042
+ }
12043
+ return `${Math.round(value)} ${sizes[index]}`;
12044
+ }
12045
+ /**
12046
+ * Formats a duration in seconds to a human-readable string.
12047
+ *
12048
+ * @param seconds - The duration in seconds
12049
+ * @returns Formatted string like "1:23" or "1:23:45"
12050
+ *
12051
+ * @example
12052
+ * ```typescript
12053
+ * formatDuration(65); // "1:05"
12054
+ * formatDuration(3665); // "1:01:05"
12055
+ * ```
12056
+ */
12057
+ function formatDuration(seconds) {
12058
+ if (seconds < 0)
12059
+ return '0:00';
12060
+ const hours = Math.floor(seconds / 3600);
12061
+ const minutes = Math.floor((seconds % 3600) / 60);
12062
+ const secs = Math.floor(seconds % 60);
12063
+ if (hours > 0) {
12064
+ return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
12065
+ }
12066
+ return `${minutes}:${secs.toString().padStart(2, '0')}`;
12067
+ }
12068
+ // ============================================================================
12069
+ // Helper Functions
12070
+ // ============================================================================
12071
+ /**
12072
+ * Extracts the file extension from a filename.
12073
+ *
12074
+ * @param filename - The filename to extract from
12075
+ * @returns Lowercase extension without the dot, or empty string
12076
+ *
12077
+ * @example
12078
+ * ```typescript
12079
+ * getFileExtension('photo.JPG'); // 'jpg'
12080
+ * getFileExtension('document.pdf'); // 'pdf'
12081
+ * getFileExtension('noextension'); // ''
12082
+ * ```
12083
+ */
12084
+ function getFileExtension(filename) {
12085
+ const lastDot = filename.lastIndexOf('.');
12086
+ if (lastDot === -1 || lastDot === filename.length - 1) {
12087
+ return '';
12088
+ }
12089
+ return filename.slice(lastDot + 1).toLowerCase();
12090
+ }
12091
+ /**
12092
+ * Gets a display-friendly file name, truncating if too long.
12093
+ *
12094
+ * @param filename - The full filename
12095
+ * @param maxLength - Maximum length (default: 30)
12096
+ * @returns Truncated filename with extension preserved
12097
+ *
12098
+ * @example
12099
+ * ```typescript
12100
+ * truncateFileName('very-long-file-name.pdf', 15); // 'very-lo...e.pdf'
12101
+ * ```
12102
+ */
12103
+ function truncateFileName(filename, maxLength = 30) {
12104
+ if (filename.length <= maxLength) {
12105
+ return filename;
12106
+ }
12107
+ const ext = getFileExtension(filename);
12108
+ const extWithDot = ext ? `.${ext}` : '';
12109
+ const nameWithoutExt = filename.slice(0, filename.length - extWithDot.length);
12110
+ const availableLength = maxLength - extWithDot.length - 3; // 3 for '...'
12111
+ if (availableLength <= 0) {
12112
+ return filename.slice(0, maxLength - 3) + '...';
12113
+ }
12114
+ const keepStart = Math.ceil(availableLength / 2);
12115
+ const keepEnd = Math.floor(availableLength / 2);
12116
+ return (nameWithoutExt.slice(0, keepStart) +
12117
+ '...' +
12118
+ nameWithoutExt.slice(-keepEnd) +
12119
+ extWithDot);
12120
+ }
12121
+ /**
12122
+ * Revokes a blob URL to free memory.
12123
+ * Safe to call with undefined/null.
12124
+ *
12125
+ * @param url - The blob URL to revoke
12126
+ */
12127
+ function revokePreviewUrl(url) {
12128
+ if (url && url.startsWith('blob:')) {
12129
+ URL.revokeObjectURL(url);
12130
+ }
12131
+ }
12132
+ /**
12133
+ * Gets the appropriate icon name for an attachment type.
12134
+ *
12135
+ * @param type - The attachment type
12136
+ * @returns Icon identifier
12137
+ */
12138
+ function getAttachmentIcon(type) {
12139
+ switch (type) {
12140
+ case 'image':
12141
+ return 'image';
12142
+ case 'video':
12143
+ return 'videocam';
12144
+ case 'audio':
12145
+ return 'audiotrack';
12146
+ case 'file':
12147
+ default:
12148
+ return 'insert_drive_file';
12149
+ }
12150
+ }
12151
+
11601
12152
  /**
11602
12153
  * Utility Functions
11603
12154
  *
@@ -12074,5 +12625,5 @@ const chatAnimations = {
12074
12625
  * Generated bundle index. Do not edit.
12075
12626
  */
12076
12627
 
12077
- export { AttachmentPickerComponent, AttachmentPreviewComponent, AudioPreviewComponent, ButtonsActionComponent, CHAT_CONFIG, CHAT_CONFIG_OVERRIDES, CHAT_FEATURES, CHAT_I18N, CHAT_I18N_OVERRIDES, ChatA11yService, ChatAttachmentComponent, ChatAttachmentService, ChatComponent, ChatConfigService, ChatDropZoneDirective, ChatErrorBoundaryComponent, ChatErrorRecoveryService, ChatHeaderComponent, ChatHeaderContentDirective, ChatMarkdownComponent, ChatMarkdownService, ChatMessageActionsComponent, ChatMessageBubbleComponent, ChatMessagesComponent, ChatSenderComponent, ChatTypingIndicatorComponent, ChatVirtualScrollService, ConfirmActionComponent, DEFAULT_ATTACHMENT_CONFIG, DEFAULT_BEHAVIOR_CONFIG, DEFAULT_CHAT_CONFIG, DEFAULT_CHAT_I18N, DEFAULT_ERROR_RECOVERY_CONFIG, DEFAULT_GROUPING_THRESHOLD_MS, DEFAULT_KEYBOARD_CONFIG, DEFAULT_MARKDOWN_CONFIG, DEFAULT_VALIDATION_CONFIG, DEFAULT_VIRTUAL_SCROLL_CONFIG, ERROR_CODES, FilePreviewComponent, ImagePreviewComponent, MultiSelectActionComponent, SelectActionComponent, VideoPreviewComponent, allActionsResponded, appendMessageContent, breakLongWords, canRetry, chatAnimations, createButtonsAction, createConfirmAction, createError, createMultiSelectAction, createOtherMessage, createSelectAction, createSelfMessage, createSystemMessage, disableAllActions, errorShake, fadeInOut, findMessage, generateId, getActionById, getErrorMessages, getLastMessage, getMessagesByStatus, getPendingActions, getRetryDelay, getRetryableMessages, groupMessages, incrementRetryCount, isAuthError, isClientError, isNetworkError, isSafeUrl, isUngroupable, messageEnter, provideChat, removeMessage, resetIdCounter, sanitizeContent, shouldGroupMessages, stripInvisibleChars, typingPulse, updateActionResponse, updateMessageContent, updateMessageStatus, validateMessage };
12628
+ export { AttachmentPickerComponent, AttachmentPreviewComponent, AudioPreviewComponent, ButtonsActionComponent, CHAT_CONFIG, CHAT_CONFIG_OVERRIDES, CHAT_FEATURES, CHAT_I18N, CHAT_I18N_OVERRIDES, ChatA11yService, ChatAttachmentComponent, ChatAttachmentService, ChatComponent, ChatConfigService, ChatDropZoneDirective, ChatErrorBoundaryComponent, ChatErrorRecoveryService, ChatFocusTrapDirective, ChatHeaderComponent, ChatHeaderContentDirective, ChatMarkdownComponent, ChatMarkdownService, ChatMessageActionsComponent, ChatMessageBubbleComponent, ChatMessagesComponent, ChatSenderComponent, ChatTypingIndicatorComponent, ChatVirtualScrollService, ConfirmActionComponent, DEFAULT_ATTACHMENT_CONFIG, DEFAULT_BEHAVIOR_CONFIG, DEFAULT_CHAT_CONFIG, DEFAULT_CHAT_I18N, DEFAULT_ERROR_RECOVERY_CONFIG, DEFAULT_GROUPING_THRESHOLD_MS, DEFAULT_KEYBOARD_CONFIG, DEFAULT_MARKDOWN_CONFIG, DEFAULT_VALIDATION_CONFIG, DEFAULT_VIRTUAL_SCROLL_CONFIG, ERROR_CODES, FilePreviewComponent, ImagePreviewComponent, MultiSelectActionComponent, SelectActionComponent, VideoPreviewComponent, allActionsResponded, appendMessageContent, breakLongWords, canRetry, chatAnimations, createButtonsAction, createConfirmAction, createError, createMessageAttachment, createMultiSelectAction, createOtherMessage, createPendingAttachment, createSelectAction, createSelfMessage, createSystemMessage, disableAllActions, errorShake, fadeInOut, findMessage, formatDuration, formatFileSize, generateId, getActionById, getAttachmentIcon, getAttachmentType, getAttachmentTypeFromFile, getErrorMessages, getFileExtension, getLastMessage, getMessagesByStatus, getPendingActions, getRetryDelay, getRetryableMessages, groupMessages, incrementRetryCount, isAuthError, isClientError, isNetworkError, isPreviewable, isPreviewableByExtension, isSafeUrl, isUngroupable, messageEnter, provideChat, removeMessage, resetIdCounter, revokePreviewUrl, sanitizeContent, shouldGroupMessages, stripInvisibleChars, truncateFileName, typingPulse, updateActionResponse, updateMessageContent, updateMessageStatus, validateAttachment, validateMessage };
12078
12629
  //# sourceMappingURL=cdevhub-ngx-chat.mjs.map