@acorex/components 20.8.28 → 20.8.29

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.
@@ -3,14 +3,14 @@ import { AXTabsComponent, AXTabItemComponent } from '@acorex/components/tabs';
3
3
  import * as i1$4 from '@angular/common';
4
4
  import { isPlatformBrowser, AsyncPipe, CommonModule, NgComponentOutlet, DOCUMENT } from '@angular/common';
5
5
  import * as i0 from '@angular/core';
6
- import { InjectionToken, signal, computed, inject, Injectable, input, output, ChangeDetectionStrategy, Component, model, ElementRef, afterNextRender, PLATFORM_ID, DestroyRef, Injector, runInInjectionContext, effect, viewChild, untracked, viewChildren, SecurityContext, ViewContainerRef, EventEmitter, HostListener, Directive, NgModule } from '@angular/core';
6
+ import { Injectable, InjectionToken, signal, computed, inject, input, output, ChangeDetectionStrategy, Component, model, ElementRef, afterNextRender, PLATFORM_ID, DestroyRef, Injector, runInInjectionContext, effect, viewChild, untracked, viewChildren, SecurityContext, ViewContainerRef, EventEmitter, HostListener, Directive, NgModule } from '@angular/core';
7
+ import { AXUploaderBrowseDirective, AXUploaderZoneDirective, AXUploaderService } from '@acorex/cdk/uploader';
7
8
  import { AXDialogService } from '@acorex/components/dialog';
8
9
  import { AXPopupService } from '@acorex/components/popup';
10
+ import { createFileTypeMetadata, AXFileTypeInfoProvider, resolveFileTypeExtension, runFileTypeUtility, formatFileSizeBytes, AXFileService, AXFileTypeRegistryService, runFileTypeUtilityForFile, provideFileValidationRules, provideFileTypeInfoProvider, getFileExtension, runFileTypeCopy, resolveFileCopyText, runFileTypeOpen } from '@acorex/core/file';
9
11
  import * as i3 from '@acorex/core/translation';
10
12
  import { translateSync, AXTranslationService, AXTranslationModule } from '@acorex/core/translation';
11
13
  import { Subject, BehaviorSubject, Observable, filter, firstValueFrom, takeUntil, catchError, EMPTY } from 'rxjs';
12
- import { AXUploaderBrowseDirective, AXUploaderZoneDirective, AXUploaderService } from '@acorex/cdk/uploader';
13
- import { createFileTypeMetadata, AXFileTypeInfoProvider, resolveFileTypeExtension, runFileTypeUtility, formatFileSizeBytes, AXFileService, AXFileTypeRegistryService, runFileTypeUtilityForFile, provideFileValidationRules, provideFileTypeInfoProvider, getFileExtension, runFileTypeCopy, resolveFileCopyText, runFileTypeOpen } from '@acorex/core/file';
14
14
  import * as i1$2 from '@acorex/components/progress-bar';
15
15
  import { AXProgressBarModule } from '@acorex/components/progress-bar';
16
16
  import { AXToastService } from '@acorex/components/toast';
@@ -260,163 +260,6 @@ class AXUserApi {
260
260
  */
261
261
  // Shared types
262
262
 
263
- /**
264
- * Conversation Configuration Interface
265
- * Centralized configuration values to avoid magic numbers
266
- */
267
-
268
- /**
269
- * Default Configuration Values
270
- * Centralized defaults to avoid magic numbers throughout the codebase
271
- */
272
- /**
273
- * Default conversation configuration
274
- * All values are explicitly defined here for easy maintenance and documentation
275
- */
276
- const AX_DEFAULT_CONVERSATION_CONFIG = {
277
- // Pagination
278
- messagePageSize: 30,
279
- conversationPageSize: 20,
280
- // Scroll Configuration
281
- scrollThreshold: 100,
282
- infiniteScrollThreshold: 200,
283
- // Timeout Durations (milliseconds)
284
- typingIndicatorTimeout: 3000,
285
- typingIndicatorThrottle: 1000,
286
- messageHighlightDuration: 2000,
287
- debounceSearch: 300,
288
- // Message Storage Limits
289
- maxMessagesPerConversation: 1000,
290
- maxTotalMessages: 10000,
291
- maxCachedConversations: 50,
292
- // UI Dimensions (pixels)
293
- minSidebarWidth: 250,
294
- maxSidebarWidth: 500,
295
- defaultSidebarWidth: 320,
296
- // Cache
297
- filterCacheSize: 100,
298
- // Message Validation
299
- maxMessageLength: 10000,
300
- minMessageLength: 1,
301
- maxFilesPerMessage: 3,
302
- // Intersection Observer
303
- messageReadThreshold: 0.3,
304
- // Message list
305
- messageListBackground: '',
306
- };
307
- /**
308
- * Helper function to merge user config with defaults
309
- * Properly handles array merging to avoid reference issues
310
- * @param userConfig - User-provided configuration
311
- * @returns Merged configuration with all required fields
312
- */
313
- function mergeWithDefaults(userConfig) {
314
- if (!userConfig) {
315
- return { ...AX_DEFAULT_CONVERSATION_CONFIG };
316
- }
317
- return {
318
- ...AX_DEFAULT_CONVERSATION_CONFIG,
319
- ...userConfig,
320
- };
321
- }
322
-
323
- /**
324
- * Dependency Injection Tokens
325
- * InjectionTokens for configuration and dependencies
326
- */
327
- /**
328
- * Configuration token for conversation component
329
- * Uses centralized defaults from AX_DEFAULT_CONVERSATION_CONFIG
330
- */
331
- const CONVERSATION_CONFIG = new InjectionToken('CONVERSATION_CONFIG', {
332
- providedIn: 'root',
333
- factory: () => mergeWithDefaults(),
334
- });
335
- /**
336
- * Token for configuring AXErrorHandlerService
337
- */
338
- const ERROR_HANDLER_CONFIG = new InjectionToken('ERROR_HANDLER_CONFIG', {
339
- providedIn: 'root',
340
- factory: () => ({}),
341
- });
342
-
343
- /**
344
- * Pluggable avatar components for the conversation UI.
345
- * Register via `provideConversation({ avatarComponents: { user, conversation } })`.
346
- */
347
- const AX_CONVERSATION_USER_AVATAR_COMPONENT = new InjectionToken('AX_CONVERSATION_USER_AVATAR_COMPONENT');
348
- const AX_CONVERSATION_CONVERSATION_AVATAR_COMPONENT = new InjectionToken('AX_CONVERSATION_CONVERSATION_AVATAR_COMPONENT');
349
-
350
- /**
351
- * Registry Configuration Tokens
352
- * Injection tokens for configuring default registry values
353
- */
354
- /**
355
- * Additional message renderers configuration
356
- * Provide this token to add custom message renderers in addition to built-in ones
357
- * Note: Built-in renderers (text, system, fallback) are registered by default; other types are provided via plugins/constants.
358
- */
359
- const DEFAULT_MESSAGE_RENDERERS = new InjectionToken('DEFAULT_MESSAGE_RENDERERS', {
360
- providedIn: 'root',
361
- factory: () => [],
362
- });
363
- /**
364
- * Default message actions configuration
365
- * Provide this token to override default message actions
366
- */
367
- const DEFAULT_MESSAGE_ACTIONS = new InjectionToken('DEFAULT_MESSAGE_ACTIONS', {
368
- providedIn: 'root',
369
- factory: () => [],
370
- });
371
- /**
372
- * Default composer tabs configuration
373
- * Provide this token to override default composer tabs (emoji, stickers, etc.)
374
- */
375
- const DEFAULT_COMPOSER_TABS = new InjectionToken('DEFAULT_COMPOSER_TABS', {
376
- providedIn: 'root',
377
- factory: () => [],
378
- });
379
- /**
380
- * Default composer actions configuration
381
- * Provide this token to override default composer actions (attach, voice, etc.)
382
- */
383
- const DEFAULT_COMPOSER_ACTIONS = new InjectionToken('DEFAULT_COMPOSER_ACTIONS', {
384
- providedIn: 'root',
385
- factory: () => [],
386
- });
387
- /**
388
- * Default conversation tabs configuration
389
- * Provide this token to override default conversation tabs (all, private, groups, etc.)
390
- */
391
- const DEFAULT_CONVERSATION_TABS = new InjectionToken('DEFAULT_CONVERSATION_TABS', {
392
- providedIn: 'root',
393
- factory: () => [],
394
- });
395
- /**
396
- * Default info bar actions configuration
397
- * Provide this token to override default info bar actions (mute, archive, block, etc.)
398
- */
399
- const DEFAULT_INFO_BAR_ACTIONS = new InjectionToken('DEFAULT_INFO_BAR_ACTIONS', {
400
- providedIn: 'root',
401
- factory: () => [],
402
- });
403
- /**
404
- * Default conversation item actions configuration
405
- * Provide this token to override default conversation item actions (mute, delete, archive, etc.)
406
- */
407
- const DEFAULT_CONVERSATION_ITEM_ACTIONS = new InjectionToken('DEFAULT_CONVERSATION_ITEM_ACTIONS', {
408
- providedIn: 'root',
409
- factory: () => [],
410
- });
411
- /**
412
- * Complete registry configuration token
413
- * Provide this for comprehensive registry configuration
414
- */
415
- const REGISTRY_CONFIG = new InjectionToken('REGISTRY_CONFIG', {
416
- providedIn: 'root',
417
- factory: () => ({}),
418
- });
419
-
420
263
  /** Inline or session-only URLs that must not be stored on message payloads or sent to APIs. */
421
264
  function isNonPersistableMediaUrl(url) {
422
265
  if (!url) {
@@ -463,20 +306,22 @@ function str$3(value) {
463
306
  function num$2(value, fallback = 0) {
464
307
  return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
465
308
  }
466
- function sanitizeAudioItem(item) {
309
+ function sanitizeImageItem(item) {
467
310
  const url = resolvePersistableMediaUrl(item.url);
468
- if (url === item.url) {
311
+ const thumbnailUrl = resolvePersistedThumbnailUrl(item.thumbnailUrl, url);
312
+ if (url === item.url && thumbnailUrl === item.thumbnailUrl) {
469
313
  return item;
470
314
  }
471
- return { ...item, url };
315
+ return { ...item, url, thumbnailUrl };
472
316
  }
473
- function normalizeAudioPayload(payload) {
317
+ /** Format image message payload for rendering (array shape, safe thumbnails). */
318
+ function normalizeImagePayload(payload) {
474
319
  const raw = loose$3(payload);
475
- const existing = raw['audios'];
320
+ const existing = raw['images'];
476
321
  if (Array.isArray(existing) && existing.length > 0) {
477
322
  return {
478
- type: 'audio',
479
- audios: existing.map(sanitizeAudioItem),
323
+ type: 'image',
324
+ images: existing.map(sanitizeImageItem),
480
325
  caption: payload.caption,
481
326
  };
482
327
  }
@@ -484,24 +329,27 @@ function normalizeAudioPayload(payload) {
484
329
  const mediaId = str$3(raw['mediaId']);
485
330
  if (url || mediaId) {
486
331
  return {
487
- type: 'audio',
332
+ type: 'image',
488
333
  caption: payload.caption,
489
- audios: [
334
+ images: [
490
335
  {
491
336
  ...(url ? { url } : {}),
492
- title: str$3(raw['title']),
493
- duration: num$2(raw['duration']),
337
+ thumbnailUrl: resolvePersistedThumbnailUrl(str$3(raw['thumbnailUrl']), url),
338
+ width: num$2(raw['width']),
339
+ height: num$2(raw['height']),
494
340
  mimeType: str$3(raw['mimeType']),
495
341
  size: typeof raw['size'] === 'number' ? raw['size'] : undefined,
496
342
  mediaId,
497
- artist: str$3(raw['artist']),
498
- coverUrl: str$3(raw['coverUrl']),
499
- waveform: Array.isArray(raw['waveform']) ? raw['waveform'] : undefined,
343
+ blurhash: str$3(raw['blurhash']),
500
344
  },
501
345
  ],
502
346
  };
503
347
  }
504
- return { type: 'audio', audios: [], caption: payload.caption };
348
+ return { type: 'image', images: [], caption: payload.caption };
349
+ }
350
+ /** Preferred URL for grid / lightbox (never inline base64 thumbnails). */
351
+ function resolveImageDisplayUrl(image) {
352
+ return resolvePersistedThumbnailUrl(image.thumbnailUrl, image.url) ?? image.url;
505
353
  }
506
354
 
507
355
  function loose$2(payload) {
@@ -510,21 +358,23 @@ function loose$2(payload) {
510
358
  function str$2(value) {
511
359
  return typeof value === 'string' && value.length > 0 ? value : undefined;
512
360
  }
513
- function sanitizeFileItem(item) {
361
+ function num$1(value, fallback = 0) {
362
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
363
+ }
364
+ function sanitizeAudioItem(item) {
514
365
  const url = resolvePersistableMediaUrl(item.url);
515
- const thumbnailUrl = resolvePersistedThumbnailUrl(item.thumbnailUrl, url);
516
- if (url === item.url && thumbnailUrl === item.thumbnailUrl) {
366
+ if (url === item.url) {
517
367
  return item;
518
368
  }
519
- return { ...item, url, thumbnailUrl };
369
+ return { ...item, url };
520
370
  }
521
- function normalizeFilePayload(payload) {
371
+ function normalizeAudioPayload(payload) {
522
372
  const raw = loose$2(payload);
523
- const existing = raw['files'];
373
+ const existing = raw['audios'];
524
374
  if (Array.isArray(existing) && existing.length > 0) {
525
375
  return {
526
- type: 'file',
527
- files: existing.map(sanitizeFileItem),
376
+ type: 'audio',
377
+ audios: existing.map(sanitizeAudioItem),
528
378
  caption: payload.caption,
529
379
  };
530
380
  }
@@ -532,49 +382,207 @@ function normalizeFilePayload(payload) {
532
382
  const mediaId = str$2(raw['mediaId']);
533
383
  if (url || mediaId) {
534
384
  return {
535
- type: 'file',
385
+ type: 'audio',
536
386
  caption: payload.caption,
537
- files: [
387
+ audios: [
538
388
  {
539
389
  ...(url ? { url } : {}),
540
- name: str$2(raw['name']) ?? 'file',
541
- mimeType: str$2(raw['mimeType']) ?? 'application/octet-stream',
390
+ title: str$2(raw['title']),
391
+ duration: num$1(raw['duration']),
392
+ mimeType: str$2(raw['mimeType']),
542
393
  size: typeof raw['size'] === 'number' ? raw['size'] : undefined,
543
394
  mediaId,
544
- thumbnailUrl: resolvePersistedThumbnailUrl(str$2(raw['thumbnailUrl']), url),
545
- extension: str$2(raw['extension']),
395
+ artist: str$2(raw['artist']),
396
+ coverUrl: str$2(raw['coverUrl']),
397
+ waveform: Array.isArray(raw['waveform']) ? raw['waveform'] : undefined,
546
398
  },
547
399
  ],
548
400
  };
549
401
  }
550
- return { type: 'file', files: [], caption: payload.caption };
402
+ return { type: 'audio', audios: [], caption: payload.caption };
551
403
  }
552
404
 
553
- function loose$1(payload) {
554
- return payload;
555
- }
556
- function str$1(value) {
557
- return typeof value === 'string' && value.length > 0 ? value : undefined;
405
+ function formatFileSize(bytes) {
406
+ if (bytes < 1024)
407
+ return `${bytes} B`;
408
+ if (bytes < 1024 * 1024)
409
+ return `${(bytes / 1024).toFixed(1)} KB`;
410
+ if (bytes < 1024 * 1024 * 1024)
411
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
412
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
558
413
  }
559
- function num$1(value, fallback = 0) {
560
- return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
414
+ function formatDuration(seconds) {
415
+ const mins = Math.floor(seconds / 60);
416
+ const secs = Math.floor(seconds % 60);
417
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
561
418
  }
562
- function sanitizeImageItem(item) {
563
- const url = resolvePersistableMediaUrl(item.url);
564
- const thumbnailUrl = resolvePersistedThumbnailUrl(item.thumbnailUrl, url);
565
- if (url === item.url && thumbnailUrl === item.thumbnailUrl) {
566
- return item;
419
+
420
+ const CONVERSATION_AUDIO_CATALOG = 'conversation-audio';
421
+ const MB$4 = 1024 * 1024;
422
+ const CONVERSATION_AUDIO_PRESENTATION = {
423
+ icon: 'fa-light fa-music ax-text-amber-500',
424
+ title: 'Audio',
425
+ };
426
+ const AUDIO_UTILITY = {
427
+ preview: 'preview',
428
+ duration: 'duration',
429
+ formatSize: 'formatSize',
430
+ formatDuration: 'formatDuration',
431
+ createLocalPreviewUrl: 'createLocalPreviewUrl',
432
+ };
433
+ function blobUrl$4(ctx, blob) {
434
+ if (!isPlatformBrowser(ctx.platformId)) {
435
+ return '';
567
436
  }
568
- return { ...item, url, thumbnailUrl };
437
+ return URL.createObjectURL(blob);
569
438
  }
570
- /** Format image message payload for rendering (array shape, safe thumbnails). */
571
- function normalizeImagePayload(payload) {
439
+ function mediaDuration$2(ctx, file) {
440
+ if (!isPlatformBrowser(ctx.platformId)) {
441
+ return Promise.resolve(0);
442
+ }
443
+ return new Promise((resolve, reject) => {
444
+ const el = document.createElement('audio');
445
+ el.preload = 'metadata';
446
+ el.onloadedmetadata = () => {
447
+ URL.revokeObjectURL(el.src);
448
+ resolve(el.duration);
449
+ };
450
+ el.onerror = () => {
451
+ URL.revokeObjectURL(el.src);
452
+ reject(new Error('Failed to load audio metadata'));
453
+ };
454
+ el.src = URL.createObjectURL(file);
455
+ });
456
+ }
457
+ function conversationAudioUtilities() {
458
+ return {
459
+ [AUDIO_UTILITY.preview]: (ctx, file) => ctx.readAsDataUrl(file),
460
+ [AUDIO_UTILITY.duration]: (ctx, file) => mediaDuration$2(ctx, file),
461
+ [AUDIO_UTILITY.formatSize]: (_ctx, bytes) => formatFileSize(bytes),
462
+ [AUDIO_UTILITY.formatDuration]: (_ctx, seconds) => formatDuration(seconds),
463
+ [AUDIO_UTILITY.createLocalPreviewUrl]: (ctx, source) => blobUrl$4(ctx, source),
464
+ };
465
+ }
466
+ function createConversationAudioFileType() {
467
+ const presentation = CONVERSATION_AUDIO_PRESENTATION;
468
+ return {
469
+ name: CONVERSATION_AUDIO_CATALOG,
470
+ metadata: createFileTypeMetadata('conversation'),
471
+ title: presentation.title,
472
+ icon: presentation.icon,
473
+ validations: {
474
+ mimeTypes: ['audio/*'],
475
+ minSize: 1,
476
+ maxSize: 50 * MB$4,
477
+ },
478
+ extensions: [
479
+ { name: 'mp3', title: 'MP3' },
480
+ { name: 'wav', title: 'WAV', validations: { maxSize: 30 * MB$4 } },
481
+ { name: 'ogg', title: 'OGG' },
482
+ { name: 'm4a', title: 'M4A' },
483
+ ],
484
+ utilities: conversationAudioUtilities(),
485
+ copy: (payload) => {
486
+ const audio = normalizeAudioPayload(payload);
487
+ const caption = audio.caption?.trim();
488
+ const items = audio.audios.map((item) => ({
489
+ url: item.url?.trim(),
490
+ title: item.title?.trim(),
491
+ }));
492
+ return {
493
+ text: caption ?? '',
494
+ meta: { kind: 'audio', caption, items, count: audio.audios.length },
495
+ };
496
+ },
497
+ };
498
+ }
499
+ class AXConversationAudioFileTypeProvider extends AXFileTypeInfoProvider {
500
+ items() {
501
+ return Promise.resolve([createConversationAudioFileType()]);
502
+ }
503
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationAudioFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
504
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationAudioFileTypeProvider, providedIn: 'root' }); }
505
+ }
506
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationAudioFileTypeProvider, decorators: [{
507
+ type: Injectable,
508
+ args: [{ providedIn: 'root' }]
509
+ }] });
510
+
511
+ function audioItemFromUpload(file, result, duration) {
512
+ const url = resolvePersistableMediaUrl(result.url);
513
+ const item = {
514
+ mediaId: result.mediaId,
515
+ mimeType: result.mimeType,
516
+ size: result.size,
517
+ duration,
518
+ title: file.name,
519
+ metadata: result.metadata,
520
+ };
521
+ if (url) {
522
+ item.url = url;
523
+ }
524
+ return item;
525
+ }
526
+ function mergeAudioUploadResult(payload, result) {
527
+ const base = normalizeAudioPayload(payload);
528
+ const audios = [...base.audios];
529
+ const i = audios.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
530
+ const slot = i >= 0 ? audios[i] : undefined;
531
+ const url = resolvePersistableMediaUrl(result.url);
532
+ const next = {
533
+ ...(slot ?? { duration: 0 }),
534
+ mediaId: result.mediaId,
535
+ mimeType: result.mimeType,
536
+ size: result.size,
537
+ metadata: result.metadata,
538
+ };
539
+ if (url) {
540
+ next.url = url;
541
+ }
542
+ if (i >= 0)
543
+ audios[i] = next;
544
+ else
545
+ audios.push(next);
546
+ return { ...base, type: 'audio', audios };
547
+ }
548
+ function applyAudioLocalPreview(payload, localUrl, mimeType = 'application/octet-stream') {
549
+ const base = normalizeAudioPayload(payload);
550
+ const audios = [...base.audios];
551
+ const slot = audios.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
552
+ const preview = { url: localUrl, duration: 0, mimeType };
553
+ if (slot >= 0) {
554
+ audios[slot] = { ...audios[slot], ...preview };
555
+ }
556
+ else if (audios.length === 0) {
557
+ audios.push(preview);
558
+ }
559
+ else {
560
+ audios[0] = { ...audios[0], ...preview };
561
+ }
562
+ return { ...base, type: 'audio', audios };
563
+ }
564
+
565
+ function loose$1(payload) {
566
+ return payload;
567
+ }
568
+ function str$1(value) {
569
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
570
+ }
571
+ function sanitizeFileItem(item) {
572
+ const url = resolvePersistableMediaUrl(item.url);
573
+ const thumbnailUrl = resolvePersistedThumbnailUrl(item.thumbnailUrl, url);
574
+ if (url === item.url && thumbnailUrl === item.thumbnailUrl) {
575
+ return item;
576
+ }
577
+ return { ...item, url, thumbnailUrl };
578
+ }
579
+ function normalizeFilePayload(payload) {
572
580
  const raw = loose$1(payload);
573
- const existing = raw['images'];
581
+ const existing = raw['files'];
574
582
  if (Array.isArray(existing) && existing.length > 0) {
575
583
  return {
576
- type: 'image',
577
- images: existing.map(sanitizeImageItem),
584
+ type: 'file',
585
+ files: existing.map(sanitizeFileItem),
578
586
  caption: payload.caption,
579
587
  };
580
588
  }
@@ -582,28 +590,99 @@ function normalizeImagePayload(payload) {
582
590
  const mediaId = str$1(raw['mediaId']);
583
591
  if (url || mediaId) {
584
592
  return {
585
- type: 'image',
593
+ type: 'file',
586
594
  caption: payload.caption,
587
- images: [
595
+ files: [
588
596
  {
589
597
  ...(url ? { url } : {}),
590
- thumbnailUrl: resolvePersistedThumbnailUrl(str$1(raw['thumbnailUrl']), url),
591
- width: num$1(raw['width']),
592
- height: num$1(raw['height']),
593
- mimeType: str$1(raw['mimeType']),
598
+ name: str$1(raw['name']) ?? 'file',
599
+ mimeType: str$1(raw['mimeType']) ?? 'application/octet-stream',
594
600
  size: typeof raw['size'] === 'number' ? raw['size'] : undefined,
595
601
  mediaId,
596
- blurhash: str$1(raw['blurhash']),
602
+ thumbnailUrl: resolvePersistedThumbnailUrl(str$1(raw['thumbnailUrl']), url),
603
+ extension: str$1(raw['extension']),
597
604
  },
598
605
  ],
599
606
  };
600
607
  }
601
- return { type: 'image', images: [], caption: payload.caption };
608
+ return { type: 'file', files: [], caption: payload.caption };
602
609
  }
603
- /** Preferred URL for grid / lightbox (never inline base64 thumbnails). */
604
- function resolveImageDisplayUrl(image) {
605
- return resolvePersistedThumbnailUrl(image.thumbnailUrl, image.url) ?? image.url;
610
+
611
+ const CONVERSATION_IMAGE_CATALOG = 'conversation-image';
612
+ const MB$3 = 1024 * 1024;
613
+ const CONVERSATION_IMAGE_PRESENTATION = {
614
+ icon: 'fa-light fa-image ax-text-purple-500',
615
+ title: 'Image',
616
+ };
617
+ const IMAGE_UTILITY = {
618
+ preview: 'preview',
619
+ formatSize: 'formatSize',
620
+ createLocalPreviewUrl: 'createLocalPreviewUrl',
621
+ };
622
+ function blobUrl$3(ctx, blob) {
623
+ if (!isPlatformBrowser(ctx.platformId)) {
624
+ return '';
625
+ }
626
+ return URL.createObjectURL(blob);
627
+ }
628
+ function conversationImageUtilities() {
629
+ return {
630
+ [IMAGE_UTILITY.preview]: (ctx, file) => ctx.readAsDataUrl(file),
631
+ [IMAGE_UTILITY.formatSize]: (_ctx, bytes) => formatFileSize(bytes),
632
+ [IMAGE_UTILITY.createLocalPreviewUrl]: (ctx, source) => {
633
+ const blob = source;
634
+ if (blob.type.startsWith('image/')) {
635
+ return ctx.readAsDataUrl(blob);
636
+ }
637
+ return blobUrl$3(ctx, blob);
638
+ },
639
+ };
640
+ }
641
+ function createConversationImageFileType() {
642
+ const presentation = CONVERSATION_IMAGE_PRESENTATION;
643
+ return {
644
+ name: CONVERSATION_IMAGE_CATALOG,
645
+ metadata: createFileTypeMetadata('conversation'),
646
+ title: presentation.title,
647
+ icon: presentation.icon,
648
+ validations: {
649
+ mimeTypes: ['image/*'],
650
+ minSize: 1,
651
+ maxSize: 100 * MB$3,
652
+ },
653
+ extensions: [
654
+ { name: 'jpg', title: 'JPEG' },
655
+ { name: 'jpeg', title: 'JPEG', validations: { maxSize: 5 * MB$3 } },
656
+ { name: 'png', title: 'PNG' },
657
+ { name: 'gif', title: 'GIF' },
658
+ { name: 'webp', title: 'WebP' },
659
+ { name: 'svg', title: 'SVG', validations: { maxSize: 2 * MB$3 } },
660
+ ],
661
+ utilities: conversationImageUtilities(),
662
+ copy: (payload) => {
663
+ const image = normalizeImagePayload(payload);
664
+ const caption = image.caption?.trim();
665
+ const urls = image.images
666
+ .map((item) => item.url?.trim() || item.thumbnailUrl?.trim())
667
+ .filter((url) => !!url);
668
+ return {
669
+ text: caption ?? '',
670
+ meta: { kind: 'image', caption, urls, count: image.images.length },
671
+ };
672
+ },
673
+ };
606
674
  }
675
+ class AXConversationImageFileTypeProvider extends AXFileTypeInfoProvider {
676
+ items() {
677
+ return Promise.resolve([createConversationImageFileType()]);
678
+ }
679
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationImageFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
680
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationImageFileTypeProvider, providedIn: 'root' }); }
681
+ }
682
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationImageFileTypeProvider, decorators: [{
683
+ type: Injectable,
684
+ args: [{ providedIn: 'root' }]
685
+ }] });
607
686
 
608
687
  function loose(payload) {
609
688
  return payload;
@@ -655,2175 +734,2098 @@ function normalizeVideoPayload(payload) {
655
734
  return { type: 'video', videos: [], caption: payload.caption };
656
735
  }
657
736
 
658
- function normalizeMessagePayload(payload) {
659
- switch (payload.type) {
660
- case 'image':
661
- return normalizeImagePayload(payload);
662
- case 'video':
663
- return normalizeVideoPayload(payload);
664
- case 'audio':
665
- return normalizeAudioPayload(payload);
666
- case 'file':
667
- return normalizeFilePayload(payload);
668
- default:
669
- return payload;
670
- }
671
- }
672
-
673
- /**
674
- * Validation Utilities
675
- * Centralized validation functions for messages and user input
676
- */
677
- /**
678
- * Validate message text content
679
- * @param text - Text to validate
680
- * @param config - Configuration for validation rules
681
- * @returns Validation result
682
- */
683
- function validateMessageText(text, config) {
684
- // Check for empty text
685
- if (!text || text.trim().length === 0) {
686
- return {
687
- valid: false,
688
- error: 'Message text cannot be empty',
689
- errorCode: 'EMPTY_MESSAGE',
690
- };
691
- }
692
- // Check minimum length
693
- const minLength = config.minMessageLength ?? 1;
694
- if (text.trim().length < minLength) {
695
- return {
696
- valid: false,
697
- error: `Message must be at least ${minLength} character(s)`,
698
- errorCode: 'MESSAGE_TOO_SHORT',
699
- };
700
- }
701
- // Check maximum length
702
- const maxLength = config.maxMessageLength ?? 10000;
703
- if (text.length > maxLength) {
704
- return {
705
- valid: false,
706
- error: `Message exceeds ${maxLength} character limit`,
707
- errorCode: 'MESSAGE_TOO_LONG',
708
- };
737
+ const CONVERSATION_VIDEO_CATALOG = 'conversation-video';
738
+ const MB$2 = 1024 * 1024;
739
+ const CONVERSATION_VIDEO_PRESENTATION = {
740
+ icon: 'fa-light fa-video ax-text-blue-500',
741
+ title: 'Video',
742
+ };
743
+ const VIDEO_UTILITY = {
744
+ preview: 'preview',
745
+ duration: 'duration',
746
+ formatSize: 'formatSize',
747
+ formatDuration: 'formatDuration',
748
+ createLocalPreviewUrl: 'createLocalPreviewUrl',
749
+ };
750
+ function blobUrl$2(ctx, blob) {
751
+ if (!isPlatformBrowser(ctx.platformId)) {
752
+ return '';
709
753
  }
710
- return { valid: true };
754
+ return URL.createObjectURL(blob);
711
755
  }
712
- /**
713
- * Validate conversation ID
714
- * @param conversationId - Conversation ID to validate
715
- * @returns Validation result
716
- */
717
- function validateConversationId(conversationId) {
718
- if (!conversationId || typeof conversationId !== 'string' || conversationId.trim().length === 0) {
719
- return {
720
- valid: false,
721
- error: 'Conversation ID is required',
722
- errorCode: 'MISSING_CONVERSATION_ID',
723
- };
756
+ function mediaDuration$1(ctx, file, tag) {
757
+ if (!isPlatformBrowser(ctx.platformId)) {
758
+ return Promise.resolve(0);
724
759
  }
725
- // Check for reasonable length
726
- if (conversationId.length > 255) {
727
- return {
728
- valid: false,
729
- error: 'Conversation ID is too long',
730
- errorCode: 'MISSING_CONVERSATION_ID',
760
+ return new Promise((resolve, reject) => {
761
+ const el = document.createElement(tag);
762
+ el.preload = 'metadata';
763
+ el.onloadedmetadata = () => {
764
+ URL.revokeObjectURL(el.src);
765
+ resolve(el.duration);
731
766
  };
732
- }
733
- return { valid: true };
734
- }
735
- /**
736
- * Validate message type
737
- * @param type - Message type to validate
738
- * @returns Validation result
739
- */
740
- function validateMessageType(type) {
741
- if (!type || type.trim().length === 0) {
742
- return {
743
- valid: false,
744
- error: 'Message type is required',
745
- errorCode: 'MISSING_MESSAGE_TYPE',
767
+ el.onerror = () => {
768
+ URL.revokeObjectURL(el.src);
769
+ reject(new Error(`Failed to load ${tag} metadata`));
746
770
  };
747
- }
748
- return { valid: true };
771
+ el.src = URL.createObjectURL(file);
772
+ });
749
773
  }
750
- function validateMediaItems(items, label) {
751
- if (!Array.isArray(items) || items.length === 0) {
752
- return {
753
- valid: false,
754
- error: `${label} message must include at least one attachment`,
755
- errorCode: 'INVALID_MEDIA_PAYLOAD',
756
- };
757
- }
758
- for (const item of items) {
759
- const ok = (typeof item.url === 'string' && item.url.length > 0) ||
760
- (typeof item.mediaId === 'string' && item.mediaId.length > 0);
761
- if (!ok) {
774
+ function conversationVideoUtilities() {
775
+ return {
776
+ [VIDEO_UTILITY.preview]: (ctx, file) => ctx.readAsDataUrl(file),
777
+ [VIDEO_UTILITY.duration]: (ctx, file) => mediaDuration$1(ctx, file, 'video'),
778
+ [VIDEO_UTILITY.formatSize]: (_ctx, bytes) => formatFileSize(bytes),
779
+ [VIDEO_UTILITY.formatDuration]: (_ctx, seconds) => formatDuration(seconds),
780
+ [VIDEO_UTILITY.createLocalPreviewUrl]: async (ctx, source) => {
781
+ const blob = source;
782
+ if (blob.type.startsWith('image/')) {
783
+ return ctx.readAsDataUrl(blob);
784
+ }
785
+ return blobUrl$2(ctx, blob);
786
+ },
787
+ };
788
+ }
789
+ function createConversationVideoFileType() {
790
+ const presentation = CONVERSATION_VIDEO_PRESENTATION;
791
+ return {
792
+ name: CONVERSATION_VIDEO_CATALOG,
793
+ metadata: createFileTypeMetadata('conversation'),
794
+ title: presentation.title,
795
+ icon: presentation.icon,
796
+ validations: {
797
+ mimeTypes: ['video/*'],
798
+ minSize: 1,
799
+ maxSize: 500 * MB$2,
800
+ },
801
+ extensions: [
802
+ { name: 'mp4', title: 'MP4' },
803
+ { name: 'webm', title: 'WebM', validations: { maxSize: 200 * MB$2 } },
804
+ { name: 'ogg', title: 'OGG' },
805
+ ],
806
+ utilities: conversationVideoUtilities(),
807
+ copy: (payload) => {
808
+ const video = normalizeVideoPayload(payload);
809
+ const caption = video.caption?.trim();
810
+ const urls = video.videos.map((item) => item.url?.trim()).filter((url) => !!url);
762
811
  return {
763
- valid: false,
764
- error: `Each ${label.toLowerCase()} attachment must have a url or mediaId`,
765
- errorCode: 'INVALID_MEDIA_PAYLOAD',
812
+ text: caption ?? '',
813
+ meta: { kind: 'video', caption, urls, count: video.videos.length },
766
814
  };
767
- }
815
+ },
816
+ };
817
+ }
818
+ class AXConversationVideoFileTypeProvider extends AXFileTypeInfoProvider {
819
+ items() {
820
+ return Promise.resolve([createConversationVideoFileType()]);
768
821
  }
769
- return { valid: true };
822
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationVideoFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
823
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationVideoFileTypeProvider, providedIn: 'root' }); }
770
824
  }
771
- /**
772
- * Validate message payload
773
- * @param payload - Message payload to validate
774
- * @param type - Message type
775
- * @returns Validation result
776
- */
777
- function validateMessagePayload(payload, type) {
778
- if (!payload) {
779
- return {
780
- valid: false,
781
- error: 'Message payload is required',
782
- errorCode: 'MISSING_PAYLOAD',
783
- };
825
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationVideoFileTypeProvider, decorators: [{
826
+ type: Injectable,
827
+ args: [{ providedIn: 'root' }]
828
+ }] });
829
+
830
+ const CONVERSATION_FILE_CATALOG = 'conversation-file';
831
+ const MB$1 = 1024 * 1024;
832
+ const CONVERSATION_FILE_PRESENTATION = {
833
+ icon: 'fa-light fa-file ax-text-neutral-500',
834
+ title: 'File',
835
+ };
836
+ const CONVERSATION_FILE_ALLOWED_MIME_TYPES = [
837
+ 'image/*',
838
+ 'video/*',
839
+ 'audio/*',
840
+ 'application/pdf',
841
+ 'application/msword',
842
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
843
+ 'application/vnd.ms-excel',
844
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
845
+ 'application/zip',
846
+ 'application/x-zip-compressed',
847
+ 'application/x-7z-compressed',
848
+ 'application/vnd.rar',
849
+ 'application/octet-stream',
850
+ 'text/plain',
851
+ ];
852
+ const FILE_UTILITY = {
853
+ preview: 'preview',
854
+ formatSize: 'formatSize',
855
+ createLocalPreviewUrl: 'createLocalPreviewUrl',
856
+ pickerCatalog: 'pickerCatalog',
857
+ };
858
+ function blobUrl$1(ctx, blob) {
859
+ if (!isPlatformBrowser(ctx.platformId)) {
860
+ return '';
784
861
  }
785
- const normalized = type === 'image' || type === 'video' || type === 'audio' || type === 'file'
786
- ? normalizeMessagePayload(payload)
787
- : payload;
788
- // Type-specific validation
789
- switch (type) {
790
- case 'text':
791
- if (!('text' in payload) || typeof payload.text !== 'string') {
792
- return {
793
- valid: false,
794
- error: 'Text message must have a text property',
795
- errorCode: 'INVALID_TEXT_PAYLOAD',
796
- };
797
- }
798
- break;
799
- case 'image':
800
- return validateMediaItems(normalized.images, 'Image');
801
- case 'video':
802
- return validateMediaItems(normalized.videos, 'Video');
803
- case 'audio':
804
- return validateMediaItems(normalized.audios, 'Audio');
805
- case 'file':
806
- return validateMediaItems(normalized.files, 'File');
807
- case 'voice':
808
- case 'sticker': {
809
- const media = payload;
810
- const hasUrl = typeof media.url === 'string' && media.url.length > 0;
811
- const hasMediaId = typeof media.mediaId === 'string' && media.mediaId.length > 0;
812
- if (!hasUrl && !hasMediaId) {
813
- return {
814
- valid: false,
815
- error: `${type} message must have a url or mediaId`,
816
- errorCode: 'INVALID_MEDIA_PAYLOAD',
817
- };
862
+ return URL.createObjectURL(blob);
863
+ }
864
+ function conversationFileUtilities() {
865
+ return {
866
+ [FILE_UTILITY.preview]: async (ctx, file) => {
867
+ const f = file;
868
+ if (f.type.startsWith('image/')) {
869
+ return ctx.readAsDataUrl(f);
818
870
  }
819
- break;
820
- }
821
- case 'location':
822
- if (!('latitude' in payload) ||
823
- !('longitude' in payload) ||
824
- typeof payload.latitude !== 'number' ||
825
- typeof payload.longitude !== 'number') {
826
- return {
827
- valid: false,
828
- error: 'Location message must have latitude and longitude properties',
829
- errorCode: 'INVALID_LOCATION_PAYLOAD',
830
- };
831
- }
832
- break;
833
- }
834
- return { valid: true };
871
+ return undefined;
872
+ },
873
+ [FILE_UTILITY.formatSize]: (_ctx, bytes) => formatFileSize(bytes),
874
+ [FILE_UTILITY.createLocalPreviewUrl]: (ctx, source) => blobUrl$1(ctx, source),
875
+ [FILE_UTILITY.pickerCatalog]: (_ctx, file) => {
876
+ const f = file;
877
+ if (f.type.startsWith('image/'))
878
+ return CONVERSATION_IMAGE_CATALOG;
879
+ if (f.type.startsWith('video/'))
880
+ return CONVERSATION_VIDEO_CATALOG;
881
+ if (f.type.startsWith('audio/'))
882
+ return CONVERSATION_AUDIO_CATALOG;
883
+ return CONVERSATION_FILE_CATALOG;
884
+ },
885
+ };
835
886
  }
836
- /**
837
- * Validate user ID
838
- * @param userId - User ID to validate
839
- * @returns Validation result
840
- */
841
- function validateUserId(userId) {
842
- if (!userId || typeof userId !== 'string' || userId.trim().length === 0) {
843
- return {
844
- valid: false,
845
- error: 'User ID is required',
846
- errorCode: 'MISSING_USER_ID',
847
- };
887
+ function createConversationFileFileType() {
888
+ const presentation = CONVERSATION_FILE_PRESENTATION;
889
+ return {
890
+ name: CONVERSATION_FILE_CATALOG,
891
+ metadata: createFileTypeMetadata('conversation'),
892
+ title: presentation.title,
893
+ icon: presentation.icon,
894
+ validations: {
895
+ mimeTypes: [...CONVERSATION_FILE_ALLOWED_MIME_TYPES],
896
+ minSize: 1,
897
+ maxSize: 100 * MB$1,
898
+ },
899
+ extensions: [
900
+ { name: 'pdf', title: 'PDF', validations: { mimeTypes: ['application/pdf'], maxSize: 25 * MB$1 } },
901
+ { name: 'doc', title: 'Word' },
902
+ { name: 'docx', title: 'Word' },
903
+ { name: 'txt', title: 'Text', validations: { mimeTypes: ['text/plain'], maxSize: 5 * MB$1 } },
904
+ {
905
+ name: 'zip',
906
+ title: 'ZIP',
907
+ validations: {
908
+ mimeTypes: ['application/zip', 'application/x-zip-compressed', 'application/octet-stream'],
909
+ maxSize: 50 * MB$1,
910
+ },
911
+ },
912
+ ],
913
+ utilities: conversationFileUtilities(),
914
+ copy: (payload) => {
915
+ const file = normalizeFilePayload(payload);
916
+ const caption = file.caption?.trim();
917
+ const items = file.files.map((item) => {
918
+ const name = item.name?.trim();
919
+ const url = item.url?.trim();
920
+ const line = name && url ? `${name} — ${url}` : url || name;
921
+ return { name, url, line };
922
+ });
923
+ return {
924
+ text: caption ?? '',
925
+ meta: { kind: 'file', caption, items, count: file.files.length },
926
+ };
927
+ },
928
+ };
929
+ }
930
+ class AXConversationFileFileTypeProvider extends AXFileTypeInfoProvider {
931
+ items() {
932
+ return Promise.resolve([createConversationFileFileType()]);
848
933
  }
849
- // Check for reasonable length (prevent extremely long IDs)
850
- if (userId.length > 255) {
851
- return {
852
- valid: false,
853
- error: 'User ID is too long',
854
- errorCode: 'INVALID_USER_ID',
855
- };
934
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationFileFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
935
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationFileFileTypeProvider, providedIn: 'root' }); }
936
+ }
937
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationFileFileTypeProvider, decorators: [{
938
+ type: Injectable,
939
+ args: [{ providedIn: 'root' }]
940
+ }] });
941
+
942
+ function fileItemFromUpload(file, result) {
943
+ const extension = file.name.includes('.') ? file.name.split('.').pop() : undefined;
944
+ const url = resolvePersistableMediaUrl(result.url);
945
+ const item = {
946
+ mediaId: result.mediaId,
947
+ mimeType: result.mimeType,
948
+ size: result.size,
949
+ name: file.name,
950
+ thumbnailUrl: resolvePersistedThumbnailUrl(result.thumbnailUrl, url),
951
+ extension,
952
+ metadata: result.metadata,
953
+ };
954
+ if (url) {
955
+ item.url = url;
856
956
  }
857
- return { valid: true };
957
+ return item;
858
958
  }
859
- /**
860
- * Validate array of user IDs
861
- * @param userIds - Array of user IDs to validate
862
- * @param minCount - Minimum number of users required
863
- * @param maxCount - Maximum number of users allowed
864
- * @returns Validation result
865
- */
866
- function validateUserIds(userIds, minCount = 1, maxCount) {
867
- if (!userIds || !Array.isArray(userIds)) {
868
- return {
869
- valid: false,
870
- error: 'User IDs must be an array',
871
- errorCode: 'INVALID_USER_IDS',
872
- };
959
+ function mergeFileUploadResult(payload, result) {
960
+ const base = normalizeFilePayload(payload);
961
+ const files = [...base.files];
962
+ const i = files.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
963
+ const slot = i >= 0 ? files[i] : undefined;
964
+ const name = slot?.name ?? result.metadata?.['fileName'] ?? 'file';
965
+ const url = resolvePersistableMediaUrl(result.url);
966
+ const next = {
967
+ ...(slot ?? { name, mimeType: result.mimeType }),
968
+ mediaId: result.mediaId,
969
+ mimeType: result.mimeType,
970
+ size: result.size,
971
+ name,
972
+ thumbnailUrl: resolvePersistedThumbnailUrl(result.thumbnailUrl ?? slot?.thumbnailUrl, url),
973
+ metadata: result.metadata,
974
+ };
975
+ if (url) {
976
+ next.url = url;
873
977
  }
874
- if (userIds.length < minCount) {
875
- return {
876
- valid: false,
877
- error: `At least ${minCount} user(s) required`,
878
- errorCode: 'TOO_FEW_USERS',
879
- };
978
+ if (i >= 0)
979
+ files[i] = next;
980
+ else
981
+ files.push(next);
982
+ return { ...base, type: 'file', files };
983
+ }
984
+ function applyFileLocalPreview(payload, localUrl, mimeType = 'application/octet-stream') {
985
+ const base = normalizeFilePayload(payload);
986
+ const files = [...base.files];
987
+ const slot = files.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
988
+ const preview = { url: localUrl, name: 'upload', mimeType };
989
+ if (slot >= 0) {
990
+ files[slot] = { ...files[slot], ...preview };
880
991
  }
881
- if (maxCount && userIds.length > maxCount) {
882
- return {
883
- valid: false,
884
- error: `Maximum ${maxCount} user(s) allowed`,
885
- errorCode: 'TOO_MANY_USERS',
886
- };
992
+ else if (files.length === 0) {
993
+ files.push(preview);
887
994
  }
888
- // Check for empty or invalid IDs
889
- const invalidIds = userIds.filter((id) => !id || id.trim().length === 0);
890
- if (invalidIds.length > 0) {
891
- return {
892
- valid: false,
893
- error: 'All user IDs must be non-empty strings',
894
- errorCode: 'INVALID_USER_ID',
895
- };
995
+ else {
996
+ files[0] = { ...files[0], ...preview };
896
997
  }
897
- return { valid: true };
998
+ return { ...base, type: 'file', files };
898
999
  }
899
- /**
900
- * Validate email address
901
- * @param email - Email to validate
902
- * @returns Validation result
903
- */
904
- function validateEmail(email) {
905
- if (!email || email.trim().length === 0) {
906
- return {
907
- valid: false,
908
- error: 'Email is required',
909
- errorCode: 'MISSING_EMAIL',
910
- };
911
- }
912
- // Trim whitespace
913
- const trimmedEmail = email.trim();
914
- // Check length constraints
915
- if (trimmedEmail.length > 254) {
916
- return {
917
- valid: false,
918
- error: 'Email is too long',
919
- errorCode: 'INVALID_EMAIL',
920
- };
1000
+
1001
+ function videoItemFromUpload(file, result, duration) {
1002
+ const url = resolvePersistableMediaUrl(result.url);
1003
+ const item = {
1004
+ mediaId: result.mediaId,
1005
+ mimeType: result.mimeType,
1006
+ size: result.size,
1007
+ thumbnailUrl: resolvePersistedThumbnailUrl(result.thumbnailUrl, url),
1008
+ duration,
1009
+ width: 0,
1010
+ height: 0,
1011
+ metadata: result.metadata,
1012
+ };
1013
+ if (url) {
1014
+ item.url = url;
921
1015
  }
922
- // Enhanced email regex with better validation
923
- const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
924
- if (!emailRegex.test(trimmedEmail)) {
925
- return {
926
- valid: false,
927
- error: 'Invalid email format',
928
- errorCode: 'INVALID_EMAIL',
929
- };
1016
+ return item;
1017
+ }
1018
+ function mergeVideoUploadResult(payload, result) {
1019
+ const base = normalizeVideoPayload(payload);
1020
+ const videos = [...base.videos];
1021
+ const i = videos.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
1022
+ const slot = i >= 0 ? videos[i] : undefined;
1023
+ const url = resolvePersistableMediaUrl(result.url);
1024
+ const next = {
1025
+ ...(slot ?? { duration: 0, width: 0, height: 0 }),
1026
+ mediaId: result.mediaId,
1027
+ mimeType: result.mimeType,
1028
+ size: result.size,
1029
+ thumbnailUrl: resolvePersistedThumbnailUrl(result.thumbnailUrl ?? slot?.thumbnailUrl, url),
1030
+ metadata: result.metadata,
1031
+ };
1032
+ if (url) {
1033
+ next.url = url;
930
1034
  }
931
- return { valid: true };
1035
+ if (i >= 0)
1036
+ videos[i] = next;
1037
+ else
1038
+ videos.push(next);
1039
+ return { ...base, type: 'video', videos };
932
1040
  }
933
- /**
934
- * Validate URL
935
- * @param url - URL to validate
936
- * @returns Validation result
937
- */
938
- function validateUrl(url) {
939
- if (!url || url.trim().length === 0) {
940
- return {
941
- valid: false,
942
- error: 'URL is required',
943
- errorCode: 'MISSING_URL',
944
- };
945
- }
946
- const trimmedUrl = url.trim();
947
- // Check for common URL issues
948
- if (trimmedUrl.length > 2048) {
949
- return {
950
- valid: false,
951
- error: 'URL is too long',
952
- errorCode: 'INVALID_URL',
953
- };
1041
+ function applyVideoLocalPreview(payload, localUrl, mimeType = 'application/octet-stream') {
1042
+ const base = normalizeVideoPayload(payload);
1043
+ const videos = [...base.videos];
1044
+ const slot = videos.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
1045
+ const preview = { url: localUrl, duration: 0, width: 0, height: 0, mimeType };
1046
+ if (slot >= 0) {
1047
+ videos[slot] = { ...videos[slot], ...preview };
954
1048
  }
955
- try {
956
- const urlObj = new URL(trimmedUrl);
957
- // Validate protocol
958
- if (!['http:', 'https:', 'ftp:', 'ftps:'].includes(urlObj.protocol)) {
959
- return {
960
- valid: false,
961
- error: 'Invalid URL protocol',
962
- errorCode: 'INVALID_URL',
963
- };
964
- }
965
- return { valid: true };
1049
+ else if (videos.length === 0) {
1050
+ videos.push(preview);
966
1051
  }
967
- catch {
968
- return {
969
- valid: false,
970
- error: 'Invalid URL format',
971
- errorCode: 'INVALID_URL',
972
- };
1052
+ else {
1053
+ videos[0] = { ...videos[0], ...preview };
973
1054
  }
1055
+ return { ...base, type: 'video', videos };
974
1056
  }
975
- // =====================
976
- // Helper Functions
977
- // =====================
978
- /**
979
- * Sanitize user input to prevent XSS
980
- * Note: Angular provides built-in sanitization, but this is an additional layer
981
- * @param input - User input to sanitize
982
- * @returns Sanitized input
983
- */
984
- function sanitizeInput(input) {
985
- if (!input)
1057
+
1058
+ const CONVERSATION_VOICE_CATALOG = 'conversation-voice';
1059
+ const MB = 1024 * 1024;
1060
+ const CONVERSATION_VOICE_PRESENTATION = {
1061
+ icon: 'fa-light fa-microphone ax-text-green-500',
1062
+ title: 'Voice message',
1063
+ };
1064
+ const VOICE_UTILITY = {
1065
+ duration: 'duration',
1066
+ formatDuration: 'formatDuration',
1067
+ createLocalPreviewUrl: 'createLocalPreviewUrl',
1068
+ };
1069
+ function blobUrl(ctx, blob) {
1070
+ if (!isPlatformBrowser(ctx.platformId)) {
986
1071
  return '';
987
- return input
988
- .replace(/&/g, '&amp;')
989
- .replace(/</g, '&lt;')
990
- .replace(/>/g, '&gt;')
991
- .replace(/"/g, '&quot;')
992
- .replace(/'/g, '&#x27;')
993
- .replace(/\//g, '&#x2F;')
994
- .replace(/`/g, '&#x60;')
995
- .replace(/=/g, '&#x3D;');
996
- }
997
- /**
998
- * Validate latitude coordinate
999
- * @param latitude - Latitude to validate
1000
- * @returns Validation result
1001
- */
1002
- function validateLatitude(latitude) {
1003
- if (latitude === undefined || latitude === null || typeof latitude !== 'number' || isNaN(latitude)) {
1004
- return {
1005
- valid: false,
1006
- error: 'Latitude is required',
1007
- errorCode: 'MISSING_LATITUDE',
1008
- };
1009
- }
1010
- if (latitude < -90 || latitude > 90) {
1011
- return {
1012
- valid: false,
1013
- error: 'Latitude must be between -90 and 90',
1014
- errorCode: 'INVALID_LATITUDE',
1015
- };
1016
1072
  }
1017
- return { valid: true };
1073
+ return URL.createObjectURL(blob);
1018
1074
  }
1019
- /**
1020
- * Validate longitude coordinate
1021
- * @param longitude - Longitude to validate
1022
- * @returns Validation result
1023
- */
1024
- function validateLongitude(longitude) {
1025
- if (longitude === undefined || longitude === null || typeof longitude !== 'number' || isNaN(longitude)) {
1026
- return {
1027
- valid: false,
1028
- error: 'Longitude is required',
1029
- errorCode: 'MISSING_LONGITUDE',
1030
- };
1075
+ function mediaDuration(ctx, file) {
1076
+ if (!isPlatformBrowser(ctx.platformId)) {
1077
+ return Promise.resolve(0);
1031
1078
  }
1032
- if (longitude < -180 || longitude > 180) {
1033
- return {
1034
- valid: false,
1035
- error: 'Longitude must be between -180 and 180',
1036
- errorCode: 'INVALID_LONGITUDE',
1079
+ return new Promise((resolve, reject) => {
1080
+ const el = document.createElement('audio');
1081
+ el.preload = 'metadata';
1082
+ el.onloadedmetadata = () => {
1083
+ URL.revokeObjectURL(el.src);
1084
+ resolve(el.duration);
1085
+ };
1086
+ el.onerror = () => {
1087
+ URL.revokeObjectURL(el.src);
1088
+ reject(new Error('Failed to load audio metadata'));
1037
1089
  };
1090
+ el.src = URL.createObjectURL(file);
1091
+ });
1092
+ }
1093
+ function conversationVoiceUtilities() {
1094
+ return {
1095
+ [VOICE_UTILITY.duration]: (ctx, file) => mediaDuration(ctx, file),
1096
+ [VOICE_UTILITY.formatDuration]: (_ctx, seconds) => formatDuration(seconds),
1097
+ [VOICE_UTILITY.createLocalPreviewUrl]: (ctx, source) => blobUrl(ctx, source),
1098
+ };
1099
+ }
1100
+ function createConversationVoiceFileType() {
1101
+ const presentation = CONVERSATION_VOICE_PRESENTATION;
1102
+ return {
1103
+ name: CONVERSATION_VOICE_CATALOG,
1104
+ metadata: createFileTypeMetadata('conversation'),
1105
+ title: presentation.title,
1106
+ icon: presentation.icon,
1107
+ validations: {
1108
+ mimeTypes: ['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/*'],
1109
+ minSize: 1,
1110
+ maxSize: 50 * MB,
1111
+ },
1112
+ utilities: conversationVoiceUtilities(),
1113
+ copy: (payload) => {
1114
+ const voice = payload;
1115
+ const url = voice.url?.trim() ?? '';
1116
+ return {
1117
+ text: '',
1118
+ meta: {
1119
+ kind: 'voice',
1120
+ url,
1121
+ duration: voice.duration,
1122
+ mimeType: voice.mimeType,
1123
+ },
1124
+ };
1125
+ },
1126
+ };
1127
+ }
1128
+ class AXConversationVoiceFileTypeProvider extends AXFileTypeInfoProvider {
1129
+ items() {
1130
+ return Promise.resolve([createConversationVoiceFileType()]);
1038
1131
  }
1039
- return { valid: true };
1132
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationVoiceFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
1133
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationVoiceFileTypeProvider, providedIn: 'root' }); }
1040
1134
  }
1135
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationVoiceFileTypeProvider, decorators: [{
1136
+ type: Injectable,
1137
+ args: [{ providedIn: 'root' }]
1138
+ }] });
1041
1139
 
1042
- class AXConversationMessageUtilsService {
1043
- /**
1044
- * Normalize optional avatar/icon values so empty or whitespace-only strings
1045
- * are treated as missing data.
1046
- */
1047
- static normalizeOptionalMediaValue(value) {
1048
- if (typeof value !== 'string') {
1049
- return undefined;
1050
- }
1051
- const normalized = value.trim();
1052
- return normalized.length > 0 ? normalized : undefined;
1140
+ function mergeVoiceUploadResult(payload, result) {
1141
+ return {
1142
+ ...payload,
1143
+ type: 'voice',
1144
+ url: result.url,
1145
+ mediaId: result.mediaId,
1146
+ mimeType: result.mimeType,
1147
+ size: result.size,
1148
+ metadata: result.metadata,
1149
+ };
1150
+ }
1151
+ function applyVoiceLocalPreview(payload, localUrl) {
1152
+ return { ...payload, type: 'voice', url: localUrl };
1153
+ }
1154
+
1155
+ const MESSAGE_TYPE_FILE_TYPE = {
1156
+ image: CONVERSATION_IMAGE_CATALOG,
1157
+ video: CONVERSATION_VIDEO_CATALOG,
1158
+ audio: CONVERSATION_AUDIO_CATALOG,
1159
+ file: CONVERSATION_FILE_CATALOG,
1160
+ voice: CONVERSATION_VOICE_CATALOG,
1161
+ sticker: CONVERSATION_IMAGE_CATALOG,
1162
+ };
1163
+ /** Resolves {@link AXMessage.fileType} from command or message type. */
1164
+ function resolveMessageFileType(type, fileType) {
1165
+ return fileType ?? MESSAGE_TYPE_FILE_TYPE[type];
1166
+ }
1167
+ function mergeImageUploadResult(payload, result) {
1168
+ const base = normalizeImagePayload(payload);
1169
+ const images = [...base.images];
1170
+ const i = images.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
1171
+ const slot = i >= 0 ? images[i] : undefined;
1172
+ const url = resolvePersistableMediaUrl(result.url);
1173
+ const next = {
1174
+ ...(slot ?? { width: 0, height: 0 }),
1175
+ mediaId: result.mediaId,
1176
+ mimeType: result.mimeType,
1177
+ size: result.size,
1178
+ thumbnailUrl: resolvePersistedThumbnailUrl(result.thumbnailUrl ?? slot?.thumbnailUrl, url),
1179
+ metadata: result.metadata,
1180
+ };
1181
+ if (url) {
1182
+ next.url = url;
1053
1183
  }
1054
- /**
1055
- * Get conversation avatar image URL.
1056
- */
1057
- static getConversationAvatar(conversation) {
1058
- return AXConversationMessageUtilsService.normalizeOptionalMediaValue(conversation.avatar);
1184
+ if (i >= 0)
1185
+ images[i] = next;
1186
+ else
1187
+ images.push(next);
1188
+ return { ...base, type: 'image', images };
1189
+ }
1190
+ function applyImageLocalPreview(payload, localUrl, mimeType = 'application/octet-stream') {
1191
+ const base = normalizeImagePayload(payload);
1192
+ const images = [...base.images];
1193
+ const slot = images.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
1194
+ const preview = { url: localUrl, width: 0, height: 0, mimeType };
1195
+ if (slot >= 0) {
1196
+ images[slot] = { ...images[slot], ...preview };
1059
1197
  }
1060
- /**
1061
- * Font Awesome icon class(es) for a conversation when there is no avatar image.
1062
- */
1063
- static getConversationAvatarIcon(conversation) {
1064
- if (AXConversationMessageUtilsService.getConversationAvatar(conversation)) {
1065
- return undefined;
1066
- }
1067
- return AXConversationMessageUtilsService.normalizeOptionalMediaValue(conversation.icon);
1068
- }
1069
- /**
1070
- * Get sender name from message
1071
- */
1072
- static getSenderName(message, conversation) {
1073
- const participant = conversation.participants.find((p) => p.id === message.senderId);
1074
- return participant?.name || translateSync('@acorex:chat.fallbacks.unknown-user');
1075
- }
1076
- /**
1077
- * Get sender avatar image URL (takes precedence over {@link getSenderAvatarIcon}).
1078
- */
1079
- static getSenderAvatar(message, conversation) {
1080
- const participant = conversation.participants.find((p) => p.id === message.senderId);
1081
- return AXConversationMessageUtilsService.normalizeOptionalMediaValue(participant?.avatar);
1198
+ else if (images.length === 0) {
1199
+ images.push(preview);
1082
1200
  }
1083
- /**
1084
- * Font Awesome icon class(es) for the sender when there is no avatar image:
1085
- * participant `icon` first, then conversation-level `icon`.
1086
- */
1087
- static getSenderAvatarIcon(message, conversation) {
1088
- const participant = conversation.participants.find((p) => p.id === message.senderId);
1089
- if (AXConversationMessageUtilsService.normalizeOptionalMediaValue(participant?.avatar)) {
1090
- return undefined;
1091
- }
1092
- return (AXConversationMessageUtilsService.normalizeOptionalMediaValue(participant?.icon) ??
1093
- AXConversationMessageUtilsService.getConversationAvatarIcon(conversation));
1201
+ else {
1202
+ images[0] = { ...images[0], ...preview };
1094
1203
  }
1095
- /**
1096
- * Get initials from name
1097
- */
1098
- static getInitials(name) {
1099
- if (!name)
1100
- return '?';
1101
- return name
1102
- .split(' ')
1103
- .map((n) => n[0])
1104
- .join('')
1105
- .toUpperCase()
1106
- .substring(0, 2);
1204
+ return { ...base, type: 'image', images };
1205
+ }
1206
+ function mergeUploadResult(type, payload, result) {
1207
+ switch (type) {
1208
+ case 'image':
1209
+ return mergeImageUploadResult(payload, result);
1210
+ case 'video':
1211
+ return mergeVideoUploadResult(payload, result);
1212
+ case 'audio':
1213
+ return mergeAudioUploadResult(payload, result);
1214
+ case 'file':
1215
+ return mergeFileUploadResult(payload, result);
1216
+ case 'voice':
1217
+ return mergeVoiceUploadResult(payload, result);
1218
+ case 'sticker':
1219
+ return {
1220
+ ...payload,
1221
+ type: 'sticker',
1222
+ url: result.url,
1223
+ mediaId: result.mediaId,
1224
+ };
1225
+ default:
1226
+ return payload;
1107
1227
  }
1108
- /**
1109
- * Type guard for text payload
1110
- */
1111
- static isTextPayload(payload) {
1112
- return 'text' in payload && typeof payload.text === 'string';
1228
+ }
1229
+ function applyLocalPreview(type, payload, localUrl, mimeType = 'application/octet-stream') {
1230
+ switch (type) {
1231
+ case 'image':
1232
+ return applyImageLocalPreview(payload, localUrl, mimeType);
1233
+ case 'video':
1234
+ return applyVideoLocalPreview(payload, localUrl, mimeType);
1235
+ case 'audio':
1236
+ return applyAudioLocalPreview(payload, localUrl, mimeType);
1237
+ case 'file':
1238
+ return applyFileLocalPreview(payload, localUrl, mimeType);
1239
+ case 'voice':
1240
+ return applyVoiceLocalPreview(payload, localUrl);
1241
+ case 'sticker':
1242
+ return { ...payload, type: 'sticker', url: localUrl };
1243
+ default:
1244
+ return payload;
1113
1245
  }
1114
- /**
1115
- * Get message text content
1116
- */
1117
- static getMessageText(message) {
1118
- if (message.type === 'text' && AXConversationMessageUtilsService.isTextPayload(message.payload)) {
1119
- return message.payload.text;
1246
+ }
1247
+ function toUploaderReference$1(payload) {
1248
+ switch (payload.type) {
1249
+ case 'image': {
1250
+ const first = payload.images[0];
1251
+ return { url: first?.url, mediaId: first?.mediaId, mimeType: first?.mimeType, size: first?.size };
1120
1252
  }
1121
- return `[${message.type}]`;
1122
- }
1123
- /**
1124
- * Format message preview text
1125
- */
1126
- static getPreviewText(message, maxLength = 50) {
1127
- // Handle different message types
1128
- switch (message.type) {
1129
- case 'text': {
1130
- const textPayload = message.payload;
1131
- const text = textPayload.text || '';
1132
- const truncatedText = text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
1133
- return {
1134
- value: truncatedText,
1135
- type: 'text',
1136
- icon: 'fa-light fa-message',
1137
- };
1138
- }
1139
- case 'image': {
1140
- const imagePayload = normalizeImagePayload(message.payload);
1141
- const first = imagePayload.images[0];
1142
- const label = imagePayload.caption?.trim() ||
1143
- (imagePayload.images && imagePayload.images.length > 1
1144
- ? `${imagePayload.images.length} images`
1145
- : '');
1146
- return {
1147
- value: label || first?.thumbnailUrl || first?.url || '',
1148
- type: 'image',
1149
- icon: 'fa-light fa-image',
1150
- };
1151
- }
1152
- case 'video': {
1153
- const videoPayload = normalizeVideoPayload(message.payload);
1154
- const first = videoPayload.videos[0];
1155
- const label = videoPayload.caption?.trim() ||
1156
- (videoPayload.videos.length > 1 ? `${videoPayload.videos.length} videos` : '');
1157
- return {
1158
- value: label || first?.url || '',
1159
- type: 'video',
1160
- icon: 'fa-light fa-video',
1161
- };
1162
- }
1163
- case 'audio': {
1164
- const audioPayload = normalizeAudioPayload(message.payload);
1165
- const names = audioPayload.audios.map((a) => a.title).filter(Boolean).join(', ');
1166
- const first = audioPayload.audios[0];
1167
- const label = audioPayload.caption?.trim() ||
1168
- (audioPayload.audios.length > 1 ? `${audioPayload.audios.length} audio files` : names);
1169
- return {
1170
- value: label || first?.url || '',
1171
- type: 'audio',
1172
- icon: 'fa-light fa-music',
1173
- };
1174
- }
1175
- case 'voice': {
1176
- const voicePayload = message.payload;
1177
- return {
1178
- value: voicePayload.url || '',
1179
- type: 'voice',
1180
- icon: 'fa-light fa-microphone',
1181
- };
1182
- }
1183
- case 'file': {
1184
- const filePayload = normalizeFilePayload(message.payload);
1185
- const names = filePayload.files.map((f) => f.name).join(', ');
1186
- const label = filePayload.caption?.trim() ||
1187
- (filePayload.files.length > 1 ? `${filePayload.files.length} files` : names);
1188
- return {
1189
- value: label || names,
1190
- type: 'file',
1191
- icon: 'fa-light fa-file',
1192
- };
1193
- }
1194
- case 'location': {
1195
- const locationPayload = message.payload;
1196
- return {
1197
- value: locationPayload.latitude && locationPayload.longitude
1198
- ? `${locationPayload.latitude},${locationPayload.longitude}`
1199
- : '',
1200
- type: 'location',
1201
- icon: 'fa-light fa-location-dot',
1202
- };
1203
- }
1204
- case 'sticker': {
1205
- const stickerPayload = message.payload;
1206
- return {
1207
- value: stickerPayload.url || '',
1208
- type: 'sticker',
1209
- icon: 'fa-light fa-face-smile',
1210
- };
1211
- }
1212
- default:
1213
- return {
1214
- value: '',
1215
- type: message.type,
1216
- icon: 'fa-light fa-message',
1217
- };
1253
+ case 'video': {
1254
+ const first = payload.videos[0];
1255
+ return { url: first?.url, mediaId: first?.mediaId, mimeType: first?.mimeType, size: first?.size };
1256
+ }
1257
+ case 'audio': {
1258
+ const first = payload.audios[0];
1259
+ return { url: first?.url, mediaId: first?.mediaId, mimeType: first?.mimeType, size: first?.size };
1218
1260
  }
1261
+ case 'file': {
1262
+ const first = payload.files[0];
1263
+ return { url: first?.url, mediaId: first?.mediaId, mimeType: first?.mimeType, size: first?.size };
1264
+ }
1265
+ case 'voice':
1266
+ case 'sticker':
1267
+ return {
1268
+ url: payload.url,
1269
+ mediaId: payload.mediaId,
1270
+ mimeType: payload.mimeType,
1271
+ size: payload.size,
1272
+ };
1273
+ default:
1274
+ return {};
1219
1275
  }
1220
- /**
1221
- * Check if message is from current user
1222
- */
1223
- static isOwnMessage(message, currentUserId) {
1224
- return message.senderId === currentUserId;
1276
+ }
1277
+ function createObjectUrl(platformId, blob) {
1278
+ if (!isPlatformBrowser(platformId)) {
1279
+ return '';
1225
1280
  }
1226
- /**
1227
- * Get message status icon class (Font Awesome)
1228
- */
1229
- static getStatusIcon(message) {
1230
- switch (message.status) {
1231
- case 'sending':
1232
- return 'fa-light fa-clock';
1233
- case 'sent':
1234
- return 'fa-light fa-check';
1235
- case 'delivered':
1236
- return 'fa-light fa-check-double';
1237
- case 'read':
1238
- return 'fa-light fa-check-double';
1239
- case 'failed':
1240
- return 'fa-light fa-circle-exclamation';
1241
- default:
1242
- return 'fa-light fa-check';
1243
- }
1281
+ return URL.createObjectURL(blob);
1282
+ }
1283
+ function revokeObjectUrl(platformId, url) {
1284
+ if (isPlatformBrowser(platformId) && url.startsWith('blob:')) {
1285
+ URL.revokeObjectURL(url);
1244
1286
  }
1245
- /**
1246
- * Check if message should show avatar
1247
- */
1248
- static shouldShowAvatar(message, previousMessage, conversation) {
1249
- // Always show avatar for group conversations
1250
- if (conversation.type === 'group' || conversation.type === 'channel') {
1251
- // Don't show if same sender as previous message within 5 minutes
1252
- if (previousMessage && previousMessage.senderId === message.senderId) {
1253
- const timeDiff = message.timestamp.getTime() - previousMessage.timestamp.getTime();
1254
- return timeDiff > 5 * 60 * 1000; // 5 minutes
1255
- }
1256
- return true;
1257
- }
1258
- return false;
1259
- }
1260
- /**
1261
- * Group messages by sender for consecutive messages
1262
- */
1263
- static shouldGroupWithPrevious(message, previousMessage) {
1264
- if (!previousMessage)
1265
- return false;
1266
- // Same sender
1267
- if (message.senderId !== previousMessage.senderId)
1268
- return false;
1269
- // Within 5 minutes
1270
- const timeDiff = message.timestamp.getTime() - previousMessage.timestamp.getTime();
1271
- return timeDiff < 5 * 60 * 1000;
1272
- }
1273
- /**
1274
- * Get conversation status for avatar (private conversations only)
1275
- */
1276
- static getConversationStatus(conversation) {
1277
- if (conversation.type === 'private') {
1278
- return conversation.status.presence;
1279
- }
1287
+ }
1288
+ async function createLocalPreviewUrl(fileService, platformId, source, messageType) {
1289
+ const catalog = MESSAGE_TYPE_FILE_TYPE[messageType];
1290
+ if (!catalog) {
1280
1291
  return undefined;
1281
1292
  }
1282
- /**
1283
- * Get typing indicator text for conversation
1284
- */
1285
- static getTypingText(conversation) {
1286
- const typingUsers = conversation.status.typingUsers;
1287
- if (typingUsers.length === 0)
1288
- return '';
1289
- if (conversation.type === 'private') {
1290
- return translateSync('@acorex:chat.status.typing');
1291
- }
1292
- const firstUser = conversation.participants.find((p) => p.id === typingUsers[0]);
1293
- if (typingUsers.length === 1) {
1294
- return translateSync('@acorex:chat.status.user-is-typing', {
1295
- params: { userName: firstUser?.name || translateSync('@acorex:chat.fallbacks.someone') },
1296
- });
1297
- }
1298
- return translateSync('@acorex:chat.status.people-typing', { params: { count: typingUsers.length } });
1299
- }
1300
- /**
1301
- * Format last seen time
1302
- */
1303
- static formatLastSeen(date) {
1304
- const now = new Date();
1305
- const diff = now.getTime() - date.getTime();
1306
- const seconds = Math.floor(diff / 1000);
1307
- if (seconds < 60)
1308
- return translateSync('@acorex:chat.time.just-now');
1309
- if (seconds < 3600)
1310
- return translateSync('@acorex:chat.time.minutes-ago', { params: { count: Math.floor(seconds / 60) } });
1311
- if (seconds < 86400)
1312
- return translateSync('@acorex:chat.time.hours-ago', { params: { count: Math.floor(seconds / 3600) } });
1313
- if (seconds < 604800)
1314
- return translateSync('@acorex:chat.time.days-ago', { params: { count: Math.floor(seconds / 86400) } });
1315
- return date.toLocaleDateString();
1316
- }
1317
- /**
1318
- * Get conversation subtitle (status or member count)
1319
- */
1320
- static getConversationSubtitle(conversation) {
1321
- if (conversation.status.isTyping) {
1322
- return AXConversationMessageUtilsService.getTypingText(conversation);
1323
- }
1324
- switch (conversation.type) {
1325
- case 'private':
1326
- if (conversation.status.presence === 'online') {
1327
- return translateSync('@acorex:chat.status.online');
1328
- }
1329
- if (conversation.status.lastSeen) {
1330
- return translateSync('@acorex:chat.status.last-seen', {
1331
- params: { value: AXConversationMessageUtilsService.formatLastSeen(conversation.status.lastSeen) },
1332
- });
1333
- }
1334
- return translateSync('@acorex:chat.status.offline');
1335
- case 'group':
1336
- return translateSync('@acorex:chat.members.count', { params: { count: conversation.participants.length } });
1337
- case 'channel':
1338
- return translateSync('@acorex:chat.members.subscribers-count', { params: { count: conversation.participants.length } });
1339
- case 'bot':
1340
- return translateSync('@acorex:chat.bot');
1341
- default:
1342
- return '';
1343
- }
1293
+ const fileType = await fileService.getFileType(catalog);
1294
+ if (!fileType) {
1295
+ return undefined;
1344
1296
  }
1297
+ const ctx = { readAsDataUrl: (f) => fileService.blobToBase64(f), platformId };
1298
+ const extension = resolveFileTypeExtension(fileType, {
1299
+ file: source instanceof File ? source : undefined,
1300
+ mimeType: source.type,
1301
+ });
1302
+ const result = await runFileTypeUtility(fileType, ctx, extension, 'createLocalPreviewUrl', source);
1303
+ return typeof result === 'string' ? result : undefined;
1345
1304
  }
1346
1305
 
1347
- /** Other participant in a private chat (excludes the current user). */
1348
- function resolvePrivatePeerUserId(conversation, currentUserId) {
1349
- if (conversation.type !== 'private') {
1350
- return undefined;
1306
+ function normalizeMessagePayload(payload) {
1307
+ switch (payload.type) {
1308
+ case 'image':
1309
+ return normalizeImagePayload(payload);
1310
+ case 'video':
1311
+ return normalizeVideoPayload(payload);
1312
+ case 'audio':
1313
+ return normalizeAudioPayload(payload);
1314
+ case 'file':
1315
+ return normalizeFilePayload(payload);
1316
+ default:
1317
+ return payload;
1351
1318
  }
1352
- const currentId = currentUserId ?? 'current-user';
1353
- return conversation.participants.find((participant) => participant.id !== currentId)?.id;
1354
- }
1355
- /** Whether `auto` kind should render a user avatar for this conversation. */
1356
- function shouldUseUserAvatarForConversation(conversation, currentUserId) {
1357
- return conversation.type === 'private' && !!resolvePrivatePeerUserId(conversation, currentUserId);
1358
1319
  }
1359
- function resolveUserAvatarDisplay(userId, conversation, message) {
1360
- if (message && conversation) {
1361
- return {
1362
- name: AXConversationMessageUtilsService.getSenderName(message, conversation),
1363
- avatar: AXConversationMessageUtilsService.getSenderAvatar(message, conversation),
1364
- icon: AXConversationMessageUtilsService.getSenderAvatarIcon(message, conversation),
1365
- };
1320
+
1321
+ /**
1322
+ * Conversation Configuration Interface
1323
+ * Centralized configuration values to avoid magic numbers
1324
+ */
1325
+
1326
+ /**
1327
+ * Default Configuration Values
1328
+ * Centralized defaults to avoid magic numbers throughout the codebase
1329
+ */
1330
+ /**
1331
+ * Default conversation configuration
1332
+ * All values are explicitly defined here for easy maintenance and documentation
1333
+ */
1334
+ const AX_DEFAULT_CONVERSATION_CONFIG = {
1335
+ // Pagination
1336
+ messagePageSize: 30,
1337
+ conversationPageSize: 20,
1338
+ // Scroll Configuration
1339
+ scrollThreshold: 100,
1340
+ infiniteScrollThreshold: 200,
1341
+ // Timeout Durations (milliseconds)
1342
+ typingIndicatorTimeout: 3000,
1343
+ typingIndicatorThrottle: 1000,
1344
+ messageHighlightDuration: 2000,
1345
+ debounceSearch: 300,
1346
+ // Message Storage Limits
1347
+ maxMessagesPerConversation: 1000,
1348
+ maxTotalMessages: 10000,
1349
+ maxCachedConversations: 50,
1350
+ // UI Dimensions (pixels)
1351
+ minSidebarWidth: 250,
1352
+ maxSidebarWidth: 500,
1353
+ defaultSidebarWidth: 320,
1354
+ // Cache
1355
+ filterCacheSize: 100,
1356
+ // Message Validation
1357
+ maxMessageLength: 10000,
1358
+ minMessageLength: 1,
1359
+ maxFilesPerMessage: 3,
1360
+ // Intersection Observer
1361
+ messageReadThreshold: 0.3,
1362
+ // Message list
1363
+ messageListBackground: '',
1364
+ };
1365
+ /**
1366
+ * Helper function to merge user config with defaults
1367
+ * Properly handles array merging to avoid reference issues
1368
+ * @param userConfig - User-provided configuration
1369
+ * @returns Merged configuration with all required fields
1370
+ */
1371
+ function mergeWithDefaults(userConfig) {
1372
+ if (!userConfig) {
1373
+ return { ...AX_DEFAULT_CONVERSATION_CONFIG };
1366
1374
  }
1367
- const participant = conversation?.participants.find((p) => p.id === userId);
1368
- return {
1369
- name: participant?.name ?? userId,
1370
- avatar: participant?.avatar,
1371
- icon: participant?.icon,
1372
- };
1373
- }
1374
- function resolveConversationAvatarDisplay(conversation, currentUserId) {
1375
- const title = currentUserId !== undefined
1376
- ? resolveConversationTitleForViewer(conversation, currentUserId)
1377
- : conversation.title;
1378
1375
  return {
1379
- name: title,
1380
- avatar: AXConversationMessageUtilsService.getConversationAvatar(conversation),
1381
- icon: AXConversationMessageUtilsService.getConversationAvatarIcon(conversation),
1376
+ ...AX_DEFAULT_CONVERSATION_CONFIG,
1377
+ ...userConfig,
1382
1378
  };
1383
1379
  }
1384
1380
 
1385
- const GENERIC_PRIVATE_TITLES = new Set(['', 'new chat', 'new conversation']);
1386
- /** True when the stored title is a placeholder, not a user-defined name. */
1387
- function isGenericPrivateConversationTitle(title) {
1388
- return GENERIC_PRIVATE_TITLES.has((title ?? '').trim().toLowerCase());
1389
- }
1390
- /** Other participant in a private 1v1 chat (excludes the current viewer). */
1391
- function resolvePrivatePeerParticipant(conversation, currentUserId) {
1392
- if (conversation.type !== 'private') {
1393
- return undefined;
1394
- }
1395
- const peerId = resolvePrivatePeerUserId(conversation, currentUserId);
1396
- if (!peerId) {
1397
- return undefined;
1398
- }
1399
- return conversation.participants.find((participant) => participant.id === peerId);
1400
- }
1401
1381
  /**
1402
- * Resolves the display title for the current viewer.
1403
- * Private 1v1 chats show the other participant's name when no custom title is set.
1382
+ * Dependency Injection Tokens
1383
+ * InjectionTokens for configuration and dependencies
1404
1384
  */
1405
- function resolveConversationTitleForViewer(conversation, currentUserId) {
1406
- if (conversation.type !== 'private') {
1407
- return conversation.title;
1408
- }
1409
- const peer = resolvePrivatePeerParticipant(conversation, currentUserId);
1410
- if (peer?.name) {
1411
- return peer.name;
1412
- }
1413
- if (isGenericPrivateConversationTitle(conversation.title)) {
1414
- return conversation.title || 'New Chat';
1415
- }
1416
- return conversation.title;
1417
- }
1418
1385
  /**
1419
- * Returns a viewer-scoped copy of a conversation with dynamic private title/avatar/icon.
1420
- * Does not mutate the source object.
1386
+ * Configuration token for conversation component
1387
+ * Uses centralized defaults from AX_DEFAULT_CONVERSATION_CONFIG
1421
1388
  */
1422
- function resolveConversationForViewer(conversation, currentUserId) {
1423
- if (conversation.type !== 'private' || !currentUserId) {
1424
- return conversation;
1425
- }
1426
- const peer = resolvePrivatePeerParticipant(conversation, currentUserId);
1427
- if (!peer) {
1428
- return conversation;
1429
- }
1430
- const title = resolveConversationTitleForViewer(conversation, currentUserId);
1431
- const avatar = conversation.avatar ?? peer.avatar;
1432
- const icon = conversation.icon ?? peer.icon;
1433
- if (title === conversation.title &&
1434
- avatar === conversation.avatar &&
1435
- icon === conversation.icon) {
1436
- return conversation;
1437
- }
1438
- return {
1439
- ...conversation,
1440
- title,
1441
- avatar,
1442
- icon,
1443
- };
1444
- }
1389
+ const CONVERSATION_CONFIG = new InjectionToken('CONVERSATION_CONFIG', {
1390
+ providedIn: 'root',
1391
+ factory: () => mergeWithDefaults(),
1392
+ });
1393
+ /**
1394
+ * Token for configuring AXErrorHandlerService
1395
+ */
1396
+ const ERROR_HANDLER_CONFIG = new InjectionToken('ERROR_HANDLER_CONFIG', {
1397
+ providedIn: 'root',
1398
+ factory: () => ({}),
1399
+ });
1445
1400
 
1446
1401
  /**
1447
- * In-memory conversation and message graph (signal-based).
1448
- * Plain class not DI-registered; instantiated by `AXConversationService`.
1402
+ * Pluggable avatar components for the conversation UI.
1403
+ * Register via `provideConversation({ avatarComponents: { user, conversation } })`.
1449
1404
  */
1450
- function withNormalizedPayload(message) {
1451
- return { ...message, payload: normalizeMessagePayload(message.payload) };
1452
- }
1453
- class ConversationState {
1454
- constructor(config) {
1455
- this.config = config;
1456
- this._conversations = signal(new Map(), ...(ngDevMode ? [{ debugName: "_conversations" }] : []));
1457
- this._messages = signal(new Map(), ...(ngDevMode ? [{ debugName: "_messages" }] : []));
1458
- this._conversationMessages = signal(new Map(), ...(ngDevMode ? [{ debugName: "_conversationMessages" }] : []));
1459
- this.conversations = computed(() => {
1460
- const convMap = this._conversations();
1461
- return Array.from(convMap.values());
1462
- }, ...(ngDevMode ? [{ debugName: "conversations", equal: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]) }] : [{
1463
- equal: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]),
1464
- }]));
1465
- }
1466
- setConversations(conversations) {
1467
- const convMap = new Map();
1468
- conversations.forEach((conv) => convMap.set(conv.id, conv));
1469
- this._conversations.set(convMap);
1405
+ const AX_CONVERSATION_USER_AVATAR_COMPONENT = new InjectionToken('AX_CONVERSATION_USER_AVATAR_COMPONENT');
1406
+ const AX_CONVERSATION_CONVERSATION_AVATAR_COMPONENT = new InjectionToken('AX_CONVERSATION_CONVERSATION_AVATAR_COMPONENT');
1407
+
1408
+ /**
1409
+ * Registry Configuration Tokens
1410
+ * Injection tokens for configuring default registry values
1411
+ */
1412
+ /**
1413
+ * Additional message renderers configuration
1414
+ * Provide this token to add custom message renderers in addition to built-in ones
1415
+ * Note: Built-in renderers (text, system, fallback) are registered by default; other types are provided via plugins/constants.
1416
+ */
1417
+ const DEFAULT_MESSAGE_RENDERERS = new InjectionToken('DEFAULT_MESSAGE_RENDERERS', {
1418
+ providedIn: 'root',
1419
+ factory: () => [],
1420
+ });
1421
+ /**
1422
+ * Default message actions configuration
1423
+ * Provide this token to override default message actions
1424
+ */
1425
+ const DEFAULT_MESSAGE_ACTIONS = new InjectionToken('DEFAULT_MESSAGE_ACTIONS', {
1426
+ providedIn: 'root',
1427
+ factory: () => [],
1428
+ });
1429
+ /**
1430
+ * Default composer tabs configuration
1431
+ * Provide this token to override default composer tabs (emoji, stickers, etc.)
1432
+ */
1433
+ const DEFAULT_COMPOSER_TABS = new InjectionToken('DEFAULT_COMPOSER_TABS', {
1434
+ providedIn: 'root',
1435
+ factory: () => [],
1436
+ });
1437
+ /**
1438
+ * Default composer actions configuration
1439
+ * Provide this token to override default composer actions (attach, voice, etc.)
1440
+ */
1441
+ const DEFAULT_COMPOSER_ACTIONS = new InjectionToken('DEFAULT_COMPOSER_ACTIONS', {
1442
+ providedIn: 'root',
1443
+ factory: () => [],
1444
+ });
1445
+ /**
1446
+ * Default conversation tabs configuration
1447
+ * Provide this token to override default conversation tabs (all, private, groups, etc.)
1448
+ */
1449
+ const DEFAULT_CONVERSATION_TABS = new InjectionToken('DEFAULT_CONVERSATION_TABS', {
1450
+ providedIn: 'root',
1451
+ factory: () => [],
1452
+ });
1453
+ /**
1454
+ * Default info bar actions configuration
1455
+ * Provide this token to override default info bar actions (mute, archive, block, etc.)
1456
+ */
1457
+ const DEFAULT_INFO_BAR_ACTIONS = new InjectionToken('DEFAULT_INFO_BAR_ACTIONS', {
1458
+ providedIn: 'root',
1459
+ factory: () => [],
1460
+ });
1461
+ /**
1462
+ * Default conversation item actions configuration
1463
+ * Provide this token to override default conversation item actions (mute, delete, archive, etc.)
1464
+ */
1465
+ const DEFAULT_CONVERSATION_ITEM_ACTIONS = new InjectionToken('DEFAULT_CONVERSATION_ITEM_ACTIONS', {
1466
+ providedIn: 'root',
1467
+ factory: () => [],
1468
+ });
1469
+ /**
1470
+ * Complete registry configuration token
1471
+ * Provide this for comprehensive registry configuration
1472
+ */
1473
+ const REGISTRY_CONFIG = new InjectionToken('REGISTRY_CONFIG', {
1474
+ providedIn: 'root',
1475
+ factory: () => ({}),
1476
+ });
1477
+
1478
+ class AXConversationMessageUtilsService {
1479
+ /**
1480
+ * Normalize optional avatar/icon values so empty or whitespace-only strings
1481
+ * are treated as missing data.
1482
+ */
1483
+ static normalizeOptionalMediaValue(value) {
1484
+ if (typeof value !== 'string') {
1485
+ return undefined;
1486
+ }
1487
+ const normalized = value.trim();
1488
+ return normalized.length > 0 ? normalized : undefined;
1470
1489
  }
1471
- addConversations(conversations) {
1472
- this._conversations.update((existingConversations) => {
1473
- const newConversations = new Map(existingConversations);
1474
- conversations.forEach((conv) => newConversations.set(conv.id, conv));
1475
- const maxCached = this.config.maxCachedConversations;
1476
- if (newConversations.size > maxCached) {
1477
- const sorted = Array.from(newConversations.values()).sort((a, b) => (b.lastMessageAt?.getTime() ?? 0) - (a.lastMessageAt?.getTime() ?? 0));
1478
- const toKeep = sorted.slice(0, maxCached);
1479
- const cleanedMap = new Map();
1480
- toKeep.forEach((conv) => cleanedMap.set(conv.id, conv));
1481
- return cleanedMap;
1482
- }
1483
- return newConversations;
1484
- });
1490
+ /**
1491
+ * Get conversation avatar image URL.
1492
+ */
1493
+ static getConversationAvatar(conversation) {
1494
+ return AXConversationMessageUtilsService.normalizeOptionalMediaValue(conversation.avatar);
1485
1495
  }
1486
- setConversation(conversation) {
1487
- this._conversations.update((conversations) => {
1488
- const newConversations = new Map(conversations);
1489
- newConversations.set(conversation.id, conversation);
1490
- return newConversations;
1491
- });
1496
+ /**
1497
+ * Font Awesome icon class(es) for a conversation when there is no avatar image.
1498
+ */
1499
+ static getConversationAvatarIcon(conversation) {
1500
+ if (AXConversationMessageUtilsService.getConversationAvatar(conversation)) {
1501
+ return undefined;
1502
+ }
1503
+ return AXConversationMessageUtilsService.normalizeOptionalMediaValue(conversation.icon);
1492
1504
  }
1493
- getConversation(conversationId) {
1494
- return this._conversations().get(conversationId);
1505
+ /**
1506
+ * Get sender name from message
1507
+ */
1508
+ static getSenderName(message, conversation) {
1509
+ const participant = conversation.participants.find((p) => p.id === message.senderId);
1510
+ return participant?.name || translateSync('@acorex:chat.fallbacks.unknown-user');
1495
1511
  }
1496
- updateConversation(conversationId, updates) {
1497
- const conversation = this._conversations().get(conversationId);
1498
- if (!conversation)
1499
- return;
1500
- this.setConversation({ ...conversation, ...updates });
1512
+ /**
1513
+ * Get sender avatar image URL (takes precedence over {@link getSenderAvatarIcon}).
1514
+ */
1515
+ static getSenderAvatar(message, conversation) {
1516
+ const participant = conversation.participants.find((p) => p.id === message.senderId);
1517
+ return AXConversationMessageUtilsService.normalizeOptionalMediaValue(participant?.avatar);
1501
1518
  }
1502
- deleteConversation(conversationId) {
1503
- this._conversations.update((conversations) => {
1504
- const newConversations = new Map(conversations);
1505
- newConversations.delete(conversationId);
1506
- return newConversations;
1507
- });
1508
- const messageIds = this._conversationMessages().get(conversationId) || [];
1509
- this._messages.update((messages) => {
1510
- const newMessages = new Map(messages);
1511
- messageIds.forEach((id) => newMessages.delete(id));
1512
- return newMessages;
1513
- });
1514
- this._conversationMessages.update((map) => {
1515
- const newMap = new Map(map);
1516
- newMap.delete(conversationId);
1517
- return newMap;
1518
- });
1519
+ /**
1520
+ * Font Awesome icon class(es) for the sender when there is no avatar image:
1521
+ * participant `icon` first, then conversation-level `icon`.
1522
+ */
1523
+ static getSenderAvatarIcon(message, conversation) {
1524
+ const participant = conversation.participants.find((p) => p.id === message.senderId);
1525
+ if (AXConversationMessageUtilsService.normalizeOptionalMediaValue(participant?.avatar)) {
1526
+ return undefined;
1527
+ }
1528
+ return (AXConversationMessageUtilsService.normalizeOptionalMediaValue(participant?.icon) ??
1529
+ AXConversationMessageUtilsService.getConversationAvatarIcon(conversation));
1519
1530
  }
1520
- updateLastMessage(message) {
1521
- this.updateConversation(message.conversationId, {
1522
- lastMessage: message,
1523
- lastMessageAt: message.timestamp,
1524
- updatedAt: message.timestamp,
1525
- });
1531
+ /**
1532
+ * Get initials from name
1533
+ */
1534
+ static getInitials(name) {
1535
+ if (!name)
1536
+ return '?';
1537
+ return name
1538
+ .split(' ')
1539
+ .map((n) => n[0])
1540
+ .join('')
1541
+ .toUpperCase()
1542
+ .substring(0, 2);
1526
1543
  }
1527
- incrementUnreadCount(conversationId) {
1528
- const conversation = this._conversations().get(conversationId);
1529
- if (!conversation)
1530
- return;
1531
- this.updateConversation(conversationId, { unreadCount: conversation.unreadCount + 1 });
1544
+ /**
1545
+ * Type guard for text payload
1546
+ */
1547
+ static isTextPayload(payload) {
1548
+ return 'text' in payload && typeof payload.text === 'string';
1532
1549
  }
1533
- resetUnreadCount(conversationId) {
1534
- this.updateConversation(conversationId, { unreadCount: 0 });
1550
+ /**
1551
+ * Get message text content
1552
+ */
1553
+ static getMessageText(message) {
1554
+ if (message.type === 'text' && AXConversationMessageUtilsService.isTextPayload(message.payload)) {
1555
+ return message.payload.text;
1556
+ }
1557
+ return `[${message.type}]`;
1535
1558
  }
1536
- updateSettings(conversationId, settings) {
1537
- const conversation = this._conversations().get(conversationId);
1538
- if (!conversation)
1539
- return;
1540
- this.updateConversation(conversationId, {
1541
- settings: { ...conversation.settings, ...settings },
1542
- updatedAt: new Date(),
1543
- });
1559
+ /**
1560
+ * Format message preview text
1561
+ */
1562
+ static getPreviewText(message, maxLength = 50) {
1563
+ // Handle different message types
1564
+ switch (message.type) {
1565
+ case 'text': {
1566
+ const textPayload = message.payload;
1567
+ const text = textPayload.text || '';
1568
+ const truncatedText = text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
1569
+ return {
1570
+ value: truncatedText,
1571
+ type: 'text',
1572
+ icon: 'fa-light fa-message',
1573
+ };
1574
+ }
1575
+ case 'image': {
1576
+ const imagePayload = normalizeImagePayload(message.payload);
1577
+ const first = imagePayload.images[0];
1578
+ const label = imagePayload.caption?.trim() ||
1579
+ (imagePayload.images && imagePayload.images.length > 1
1580
+ ? `${imagePayload.images.length} images`
1581
+ : '');
1582
+ return {
1583
+ value: label || first?.thumbnailUrl || first?.url || '',
1584
+ type: 'image',
1585
+ icon: 'fa-light fa-image',
1586
+ };
1587
+ }
1588
+ case 'video': {
1589
+ const videoPayload = normalizeVideoPayload(message.payload);
1590
+ const first = videoPayload.videos[0];
1591
+ const label = videoPayload.caption?.trim() ||
1592
+ (videoPayload.videos.length > 1 ? `${videoPayload.videos.length} videos` : '');
1593
+ return {
1594
+ value: label || first?.url || '',
1595
+ type: 'video',
1596
+ icon: 'fa-light fa-video',
1597
+ };
1598
+ }
1599
+ case 'audio': {
1600
+ const audioPayload = normalizeAudioPayload(message.payload);
1601
+ const names = audioPayload.audios.map((a) => a.title).filter(Boolean).join(', ');
1602
+ const first = audioPayload.audios[0];
1603
+ const label = audioPayload.caption?.trim() ||
1604
+ (audioPayload.audios.length > 1 ? `${audioPayload.audios.length} audio files` : names);
1605
+ return {
1606
+ value: label || first?.url || '',
1607
+ type: 'audio',
1608
+ icon: 'fa-light fa-music',
1609
+ };
1610
+ }
1611
+ case 'voice': {
1612
+ const voicePayload = message.payload;
1613
+ return {
1614
+ value: voicePayload.url || '',
1615
+ type: 'voice',
1616
+ icon: 'fa-light fa-microphone',
1617
+ };
1618
+ }
1619
+ case 'file': {
1620
+ const filePayload = normalizeFilePayload(message.payload);
1621
+ const names = filePayload.files.map((f) => f.name).join(', ');
1622
+ const label = filePayload.caption?.trim() ||
1623
+ (filePayload.files.length > 1 ? `${filePayload.files.length} files` : names);
1624
+ return {
1625
+ value: label || names,
1626
+ type: 'file',
1627
+ icon: 'fa-light fa-file',
1628
+ };
1629
+ }
1630
+ case 'location': {
1631
+ const locationPayload = message.payload;
1632
+ return {
1633
+ value: locationPayload.latitude && locationPayload.longitude
1634
+ ? `${locationPayload.latitude},${locationPayload.longitude}`
1635
+ : '',
1636
+ type: 'location',
1637
+ icon: 'fa-light fa-location-dot',
1638
+ };
1639
+ }
1640
+ case 'sticker': {
1641
+ const stickerPayload = message.payload;
1642
+ return {
1643
+ value: stickerPayload.url || '',
1644
+ type: 'sticker',
1645
+ icon: 'fa-light fa-face-smile',
1646
+ };
1647
+ }
1648
+ default:
1649
+ return {
1650
+ value: '',
1651
+ type: message.type,
1652
+ icon: 'fa-light fa-message',
1653
+ };
1654
+ }
1544
1655
  }
1545
- updateTitle(conversationId, title) {
1546
- this.updateConversation(conversationId, { title, updatedAt: new Date() });
1656
+ /**
1657
+ * Check if message is from current user
1658
+ */
1659
+ static isOwnMessage(message, currentUserId) {
1660
+ return message.senderId === currentUserId;
1547
1661
  }
1548
- updateMetadata(conversationId, metadata) {
1549
- const conversation = this._conversations().get(conversationId);
1550
- if (!conversation)
1551
- return;
1552
- this.updateConversation(conversationId, {
1553
- metadata: { ...conversation.metadata, ...metadata },
1554
- updatedAt: new Date(),
1555
- });
1662
+ /**
1663
+ * Get message status icon class (Font Awesome)
1664
+ */
1665
+ static getStatusIcon(message) {
1666
+ switch (message.status) {
1667
+ case 'sending':
1668
+ return 'fa-light fa-clock';
1669
+ case 'sent':
1670
+ return 'fa-light fa-check';
1671
+ case 'delivered':
1672
+ return 'fa-light fa-check-double';
1673
+ case 'read':
1674
+ return 'fa-light fa-check-double';
1675
+ case 'failed':
1676
+ return 'fa-light fa-circle-exclamation';
1677
+ default:
1678
+ return 'fa-light fa-check';
1679
+ }
1556
1680
  }
1557
- updateTypingIndicator(conversationId, userId, isTyping) {
1558
- const conversation = this._conversations().get(conversationId);
1559
- if (!conversation)
1560
- return;
1561
- let typingUsers = [...conversation.status.typingUsers];
1562
- if (isTyping) {
1563
- if (!typingUsers.includes(userId)) {
1564
- typingUsers.push(userId);
1681
+ /**
1682
+ * Check if message should show avatar
1683
+ */
1684
+ static shouldShowAvatar(message, previousMessage, conversation) {
1685
+ // Always show avatar for group conversations
1686
+ if (conversation.type === 'group' || conversation.type === 'channel') {
1687
+ // Don't show if same sender as previous message within 5 minutes
1688
+ if (previousMessage && previousMessage.senderId === message.senderId) {
1689
+ const timeDiff = message.timestamp.getTime() - previousMessage.timestamp.getTime();
1690
+ return timeDiff > 5 * 60 * 1000; // 5 minutes
1565
1691
  }
1692
+ return true;
1566
1693
  }
1567
- else {
1568
- typingUsers = typingUsers.filter((id) => id !== userId);
1569
- }
1570
- this.updateConversation(conversationId, {
1571
- status: {
1572
- ...conversation.status,
1573
- isTyping: typingUsers.length > 0,
1574
- typingUsers,
1575
- },
1576
- });
1694
+ return false;
1577
1695
  }
1578
- updateParticipantPresence(userId, status, lastSeen) {
1579
- this._conversations.update((conversations) => {
1580
- const newConversations = new Map(conversations);
1581
- for (const [id, conv] of conversations) {
1582
- const participant = conv.participants.find((p) => p.id === userId);
1583
- if (participant) {
1584
- const updatedConversation = {
1585
- ...conv,
1586
- participants: conv.participants.map((p) => (p.id === userId ? { ...p, status, lastSeen } : p)),
1587
- status: conv.type === 'private' ? { ...conv.status, presence: status, lastSeen } : conv.status,
1588
- };
1589
- newConversations.set(id, updatedConversation);
1590
- }
1591
- }
1592
- return newConversations;
1593
- });
1696
+ /**
1697
+ * Group messages by sender for consecutive messages
1698
+ */
1699
+ static shouldGroupWithPrevious(message, previousMessage) {
1700
+ if (!previousMessage)
1701
+ return false;
1702
+ // Same sender
1703
+ if (message.senderId !== previousMessage.senderId)
1704
+ return false;
1705
+ // Within 5 minutes
1706
+ const timeDiff = message.timestamp.getTime() - previousMessage.timestamp.getTime();
1707
+ return timeDiff < 5 * 60 * 1000;
1594
1708
  }
1595
- addMessage(message) {
1596
- const normalized = withNormalizedPayload(message);
1597
- this._messages.update((messages) => {
1598
- const newMessages = new Map(messages);
1599
- newMessages.set(normalized.id, normalized);
1600
- return newMessages;
1601
- });
1602
- this._conversationMessages.update((map) => {
1603
- const newMap = new Map(map);
1604
- const existing = newMap.get(normalized.conversationId) || [];
1605
- if (existing.includes(normalized.id)) {
1606
- return newMap;
1607
- }
1608
- const updated = [...existing, normalized.id].sort((a, b) => {
1609
- const msgA = this._messages().get(a);
1610
- const msgB = this._messages().get(b);
1611
- if (!msgA || !msgB)
1612
- return 0;
1613
- return msgA.timestamp.getTime() - msgB.timestamp.getTime();
1614
- });
1615
- newMap.set(normalized.conversationId, updated);
1616
- return newMap;
1617
- });
1709
+ /**
1710
+ * Get conversation status for avatar (private conversations only)
1711
+ */
1712
+ static getConversationStatus(conversation) {
1713
+ if (conversation.type === 'private') {
1714
+ return conversation.status.presence;
1715
+ }
1716
+ return undefined;
1618
1717
  }
1619
1718
  /**
1620
- * Replace all messages for a conversation (initial page load).
1719
+ * Get typing indicator text for conversation
1621
1720
  */
1622
- setConversationMessages(conversationId, messages) {
1623
- const sorted = [...messages].map(withNormalizedPayload).sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
1624
- const sortedIds = sorted.map((m) => m.id);
1625
- this._messages.update((msgs) => {
1626
- const newMessages = new Map(msgs);
1627
- const previousIds = this._conversationMessages().get(conversationId) || [];
1628
- for (const id of previousIds) {
1629
- newMessages.delete(id);
1630
- }
1631
- for (const msg of sorted) {
1632
- newMessages.set(msg.id, msg);
1633
- }
1634
- return newMessages;
1635
- });
1636
- this._conversationMessages.update((map) => {
1637
- const newMap = new Map(map);
1638
- newMap.set(conversationId, sortedIds);
1639
- return newMap;
1640
- });
1641
- this.cleanupOldMessages();
1721
+ static getTypingText(conversation) {
1722
+ const typingUsers = conversation.status.typingUsers;
1723
+ if (typingUsers.length === 0)
1724
+ return '';
1725
+ if (conversation.type === 'private') {
1726
+ return translateSync('@acorex:chat.status.typing');
1727
+ }
1728
+ const firstUser = conversation.participants.find((p) => p.id === typingUsers[0]);
1729
+ if (typingUsers.length === 1) {
1730
+ return translateSync('@acorex:chat.status.user-is-typing', {
1731
+ params: { userName: firstUser?.name || translateSync('@acorex:chat.fallbacks.someone') },
1732
+ });
1733
+ }
1734
+ return translateSync('@acorex:chat.status.people-typing', { params: { count: typingUsers.length } });
1642
1735
  }
1643
- addMessages(messages) {
1644
- if (messages.length === 0)
1645
- return;
1646
- const normalized = messages.map(withNormalizedPayload);
1647
- this._messages.update((msgs) => {
1648
- const newMessages = new Map(msgs);
1649
- normalized.forEach((msg) => newMessages.set(msg.id, msg));
1650
- return newMessages;
1651
- });
1652
- const conversationGroups = new Map();
1653
- normalized.forEach((msg) => {
1654
- const existing = conversationGroups.get(msg.conversationId) || [];
1655
- existing.push(msg.id);
1656
- conversationGroups.set(msg.conversationId, existing);
1657
- });
1658
- this._conversationMessages.update((map) => {
1659
- const newMap = new Map(map);
1660
- for (const [conversationId, newMsgIds] of conversationGroups) {
1661
- const existing = newMap.get(conversationId) || [];
1662
- const idSet = new Set(existing);
1663
- const merged = [...existing];
1664
- for (const id of newMsgIds) {
1665
- if (!idSet.has(id)) {
1666
- merged.push(id);
1667
- idSet.add(id);
1668
- }
1669
- }
1670
- const sorted = merged.sort((a, b) => {
1671
- const msgA = this._messages().get(a);
1672
- const msgB = this._messages().get(b);
1673
- if (!msgA || !msgB)
1674
- return 0;
1675
- return msgA.timestamp.getTime() - msgB.timestamp.getTime();
1676
- });
1677
- newMap.set(conversationId, sorted);
1678
- }
1679
- return newMap;
1680
- });
1681
- this.cleanupOldMessages();
1682
- this.cleanupConversationMessages();
1683
- }
1684
- getMessage(messageId) {
1685
- return this._messages().get(messageId);
1686
- }
1687
- getConversationMessages(conversationId) {
1688
- const messageIds = this._conversationMessages().get(conversationId) || [];
1689
- return messageIds.map((id) => this._messages().get(id)).filter((msg) => msg !== undefined);
1736
+ /**
1737
+ * Format last seen time
1738
+ */
1739
+ static formatLastSeen(date) {
1740
+ const now = new Date();
1741
+ const diff = now.getTime() - date.getTime();
1742
+ const seconds = Math.floor(diff / 1000);
1743
+ if (seconds < 60)
1744
+ return translateSync('@acorex:chat.time.just-now');
1745
+ if (seconds < 3600)
1746
+ return translateSync('@acorex:chat.time.minutes-ago', { params: { count: Math.floor(seconds / 60) } });
1747
+ if (seconds < 86400)
1748
+ return translateSync('@acorex:chat.time.hours-ago', { params: { count: Math.floor(seconds / 3600) } });
1749
+ if (seconds < 604800)
1750
+ return translateSync('@acorex:chat.time.days-ago', { params: { count: Math.floor(seconds / 86400) } });
1751
+ return date.toLocaleDateString();
1690
1752
  }
1691
- updateMessage(messageId, updates) {
1692
- const message = this._messages().get(messageId);
1693
- if (!message)
1694
- return;
1695
- const merged = { ...message, ...updates };
1696
- if (updates.payload) {
1697
- merged.payload = normalizeMessagePayload(merged.payload);
1753
+ /**
1754
+ * Get conversation subtitle (status or member count)
1755
+ */
1756
+ static getConversationSubtitle(conversation) {
1757
+ if (conversation.status.isTyping) {
1758
+ return AXConversationMessageUtilsService.getTypingText(conversation);
1698
1759
  }
1699
- this.addMessage(merged);
1700
- }
1701
- deleteMessage(messageId) {
1702
- const message = this._messages().get(messageId);
1703
- if (!message)
1704
- return;
1705
- this._messages.update((messages) => {
1706
- const newMessages = new Map(messages);
1707
- newMessages.delete(messageId);
1708
- return newMessages;
1709
- });
1710
- this._conversationMessages.update((map) => {
1711
- const newMap = new Map(map);
1712
- const existing = newMap.get(message.conversationId);
1713
- if (existing) {
1714
- newMap.set(message.conversationId, existing.filter((id) => id !== messageId));
1715
- }
1716
- return newMap;
1717
- });
1718
- }
1719
- cleanupOldMessages() {
1720
- const totalMessages = this._messages().size;
1721
- if (totalMessages > this.config.maxTotalMessages) {
1722
- const allMessages = Array.from(this._messages().values()).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
1723
- const toKeep = allMessages.slice(0, this.config.maxTotalMessages);
1724
- const toKeepIds = new Set(toKeep.map((m) => m.id));
1725
- this._messages.update(() => {
1726
- const newMessages = new Map();
1727
- toKeep.forEach((msg) => newMessages.set(msg.id, msg));
1728
- return newMessages;
1729
- });
1730
- this._conversationMessages.update((map) => {
1731
- const newMap = new Map(map);
1732
- for (const [convId, messageIds] of newMap) {
1733
- const filteredIds = messageIds.filter((id) => toKeepIds.has(id));
1734
- newMap.set(convId, filteredIds);
1760
+ switch (conversation.type) {
1761
+ case 'private':
1762
+ if (conversation.status.presence === 'online') {
1763
+ return translateSync('@acorex:chat.status.online');
1735
1764
  }
1736
- return newMap;
1737
- });
1738
- }
1739
- }
1740
- cleanupConversationMessages() {
1741
- const maxMessages = this.config.maxMessagesPerConversation;
1742
- const idsToRemove = [];
1743
- this._conversationMessages.update((map) => {
1744
- const newMap = new Map(map);
1745
- for (const [convId, messageIds] of newMap) {
1746
- if (messageIds.length > maxMessages) {
1747
- idsToRemove.push(...messageIds.slice(0, messageIds.length - maxMessages));
1748
- newMap.set(convId, messageIds.slice(-maxMessages));
1765
+ if (conversation.status.lastSeen) {
1766
+ return translateSync('@acorex:chat.status.last-seen', {
1767
+ params: { value: AXConversationMessageUtilsService.formatLastSeen(conversation.status.lastSeen) },
1768
+ });
1749
1769
  }
1750
- }
1751
- return newMap;
1752
- });
1753
- if (idsToRemove.length > 0) {
1754
- this._messages.update((messages) => {
1755
- const newMessages = new Map(messages);
1756
- idsToRemove.forEach((id) => newMessages.delete(id));
1757
- return newMessages;
1758
- });
1770
+ return translateSync('@acorex:chat.status.offline');
1771
+ case 'group':
1772
+ return translateSync('@acorex:chat.members.count', { params: { count: conversation.participants.length } });
1773
+ case 'channel':
1774
+ return translateSync('@acorex:chat.members.subscribers-count', { params: { count: conversation.participants.length } });
1775
+ case 'bot':
1776
+ return translateSync('@acorex:chat.bot');
1777
+ default:
1778
+ return '';
1759
1779
  }
1760
1780
  }
1761
1781
  }
1762
1782
 
1763
- /**
1764
- * Error Handler Service
1765
- * Centralized error handling and logging
1766
- */
1767
- /**
1768
- * Error Handler Service
1769
- */
1770
- class AXErrorHandlerService {
1771
- constructor() {
1772
- this.injectedConfig = inject(ERROR_HANDLER_CONFIG);
1773
- this._errors$ = new Subject();
1774
- this._config = {
1775
- logToConsole: true,
1776
- showUserMessages: true,
1777
- autoRetry: false,
1778
- maxRetries: 3,
1783
+ /** Other participant in a private chat (excludes the current user). */
1784
+ function resolvePrivatePeerUserId(conversation, currentUserId) {
1785
+ if (conversation.type !== 'private') {
1786
+ return undefined;
1787
+ }
1788
+ const currentId = currentUserId ?? 'current-user';
1789
+ return conversation.participants.find((participant) => participant.id !== currentId)?.id;
1790
+ }
1791
+ /** Whether `auto` kind should render a user avatar for this conversation. */
1792
+ function shouldUseUserAvatarForConversation(conversation, currentUserId) {
1793
+ return conversation.type === 'private' && !!resolvePrivatePeerUserId(conversation, currentUserId);
1794
+ }
1795
+ function resolveUserAvatarDisplay(userId, conversation, message) {
1796
+ if (message && conversation) {
1797
+ return {
1798
+ name: AXConversationMessageUtilsService.getSenderName(message, conversation),
1799
+ avatar: AXConversationMessageUtilsService.getSenderAvatar(message, conversation),
1800
+ icon: AXConversationMessageUtilsService.getSenderAvatarIcon(message, conversation),
1779
1801
  };
1780
- /** Error stream */
1781
- this.errors$ = this._errors$.asObservable();
1782
- this.configure(this.injectedConfig);
1783
1802
  }
1784
- /**
1785
- * Configure error handler
1786
- */
1787
- configure(config) {
1788
- Object.assign(this._config, config);
1803
+ const participant = conversation?.participants.find((p) => p.id === userId);
1804
+ return {
1805
+ name: participant?.name ?? userId,
1806
+ avatar: participant?.avatar,
1807
+ icon: participant?.icon,
1808
+ };
1809
+ }
1810
+ function resolveConversationAvatarDisplay(conversation, currentUserId) {
1811
+ const title = currentUserId !== undefined
1812
+ ? resolveConversationTitleForViewer(conversation, currentUserId)
1813
+ : conversation.title;
1814
+ return {
1815
+ name: title,
1816
+ avatar: AXConversationMessageUtilsService.getConversationAvatar(conversation),
1817
+ icon: AXConversationMessageUtilsService.getConversationAvatarIcon(conversation),
1818
+ };
1819
+ }
1820
+
1821
+ const GENERIC_PRIVATE_TITLES = new Set(['', 'new chat', 'new conversation']);
1822
+ /** True when the stored title is a placeholder, not a user-defined name. */
1823
+ function isGenericPrivateConversationTitle(title) {
1824
+ return GENERIC_PRIVATE_TITLES.has((title ?? '').trim().toLowerCase());
1825
+ }
1826
+ /** Other participant in a private 1v1 chat (excludes the current viewer). */
1827
+ function resolvePrivatePeerParticipant(conversation, currentUserId) {
1828
+ if (conversation.type !== 'private') {
1829
+ return undefined;
1789
1830
  }
1790
- /**
1791
- * Handle an error
1792
- */
1793
- handle(error, operation, context) {
1794
- const conversationError = this.normalizeError(error, operation, context);
1795
- this.publish(conversationError);
1796
- return conversationError;
1831
+ const peerId = resolvePrivatePeerUserId(conversation, currentUserId);
1832
+ if (!peerId) {
1833
+ return undefined;
1797
1834
  }
1798
- /**
1799
- * Handle API error (same pipeline as {@link handle}, for typed API failures)
1800
- */
1801
- handleApiError(apiError, operation, context) {
1802
- const conversationError = this.conversationErrorFromApi(apiError, operation, context);
1803
- this.publish(conversationError);
1804
- return conversationError;
1835
+ return conversation.participants.find((participant) => participant.id === peerId);
1836
+ }
1837
+ /**
1838
+ * Resolves the display title for the current viewer.
1839
+ * Private 1v1 chats show the other participant's name when no custom title is set.
1840
+ */
1841
+ function resolveConversationTitleForViewer(conversation, currentUserId) {
1842
+ if (conversation.type !== 'private') {
1843
+ return conversation.title;
1805
1844
  }
1806
- publish(conversationError) {
1807
- this._errors$.next(conversationError);
1808
- if (this._config.logToConsole) {
1809
- this.logError(conversationError);
1810
- }
1811
- if (this._config.customHandler) {
1812
- this._config.customHandler(conversationError);
1813
- }
1845
+ const peer = resolvePrivatePeerParticipant(conversation, currentUserId);
1846
+ if (peer?.name) {
1847
+ return peer.name;
1814
1848
  }
1815
- /**
1816
- * Normalize any error to conversation error format (does not publish — use {@link handle})
1817
- */
1818
- normalizeError(error, operation, context) {
1819
- if (this.isApiError(error)) {
1820
- return this.conversationErrorFromApi(error, operation, context);
1821
- }
1822
- const errorObj = error;
1823
- const message = (typeof errorObj['message'] === 'string' && errorObj['message']) ||
1824
- (error instanceof Error ? error.message : String(error)) ||
1825
- 'An unknown error occurred';
1826
- const code = (typeof errorObj['code'] === 'string' && errorObj['code']) || 'UNKNOWN_ERROR';
1827
- const statusCodeRaw = errorObj['statusCode'] ?? errorObj['status'];
1828
- const statusCode = typeof statusCodeRaw === 'number' ? statusCodeRaw : undefined;
1849
+ if (isGenericPrivateConversationTitle(conversation.title)) {
1850
+ return conversation.title || 'New Chat';
1851
+ }
1852
+ return conversation.title;
1853
+ }
1854
+ /**
1855
+ * Returns a viewer-scoped copy of a conversation with dynamic private title/avatar/icon.
1856
+ * Does not mutate the source object.
1857
+ */
1858
+ function resolveConversationForViewer(conversation, currentUserId) {
1859
+ if (conversation.type !== 'private' || !currentUserId) {
1860
+ return conversation;
1861
+ }
1862
+ const peer = resolvePrivatePeerParticipant(conversation, currentUserId);
1863
+ if (!peer) {
1864
+ return conversation;
1865
+ }
1866
+ const title = resolveConversationTitleForViewer(conversation, currentUserId);
1867
+ const avatar = conversation.avatar ?? peer.avatar;
1868
+ const icon = conversation.icon ?? peer.icon;
1869
+ if (title === conversation.title &&
1870
+ avatar === conversation.avatar &&
1871
+ icon === conversation.icon) {
1872
+ return conversation;
1873
+ }
1874
+ return {
1875
+ ...conversation,
1876
+ title,
1877
+ avatar,
1878
+ icon,
1879
+ };
1880
+ }
1881
+
1882
+ /**
1883
+ * Validation Utilities
1884
+ * Centralized validation functions for messages and user input
1885
+ */
1886
+ /**
1887
+ * Validate message text content
1888
+ * @param text - Text to validate
1889
+ * @param config - Configuration for validation rules
1890
+ * @returns Validation result
1891
+ */
1892
+ function validateMessageText(text, config) {
1893
+ // Check for empty text
1894
+ if (!text || text.trim().length === 0) {
1829
1895
  return {
1830
- code,
1831
- message,
1832
- severity: 'error',
1833
- operation,
1834
- originalError: error,
1835
- statusCode,
1836
- context,
1837
- timestamp: new Date(),
1838
- handled: false,
1839
- recoverySuggestions: this.getDefaultRecoverySuggestions(code),
1896
+ valid: false,
1897
+ error: 'Message text cannot be empty',
1898
+ errorCode: 'EMPTY_MESSAGE',
1840
1899
  };
1841
1900
  }
1842
- conversationErrorFromApi(apiError, operation, context) {
1901
+ // Check minimum length
1902
+ const minLength = config.minMessageLength ?? 1;
1903
+ if (text.trim().length < minLength) {
1843
1904
  return {
1844
- code: apiError.code,
1845
- message: apiError.message,
1846
- severity: this.determineSeverity(apiError.statusCode),
1847
- operation,
1848
- originalError: apiError,
1849
- statusCode: apiError.statusCode,
1850
- context,
1851
- timestamp: apiError.timestamp ?? new Date(),
1852
- handled: false,
1853
- recoverySuggestions: this.getRecoverySuggestions(apiError),
1905
+ valid: false,
1906
+ error: `Message must be at least ${minLength} character(s)`,
1907
+ errorCode: 'MESSAGE_TOO_SHORT',
1854
1908
  };
1855
1909
  }
1856
- isApiError(error) {
1857
- return (typeof error === 'object' &&
1858
- error !== null &&
1859
- 'code' in error &&
1860
- 'message' in error &&
1861
- typeof error.code === 'string' &&
1862
- typeof error.message === 'string');
1863
- }
1864
- /**
1865
- * Determine severity based on status code
1866
- */
1867
- determineSeverity(statusCode) {
1868
- if (!statusCode)
1869
- return 'error';
1870
- if (statusCode >= 500)
1871
- return 'critical';
1872
- if (statusCode >= 400)
1873
- return 'error';
1874
- if (statusCode >= 300)
1875
- return 'warning';
1876
- return 'info';
1877
- }
1878
- /**
1879
- * Get recovery suggestions based on error
1880
- */
1881
- getRecoverySuggestions(error) {
1882
- const suggestions = [];
1883
- if (error.statusCode === 401 || error.code === 'UNAUTHORIZED') {
1884
- suggestions.push('Please log in again');
1885
- suggestions.push('Check if your session has expired');
1886
- }
1887
- else if (error.statusCode === 403 || error.code === 'FORBIDDEN') {
1888
- suggestions.push('You do not have permission for this action');
1889
- suggestions.push('Ask your administrator for access');
1890
- }
1891
- else if (error.statusCode === 404 || error.code === 'NOT_FOUND') {
1892
- suggestions.push('The requested resource was not found');
1893
- suggestions.push('It may have been deleted or moved');
1894
- }
1895
- else if (error.statusCode === 429 || error.code === 'RATE_LIMIT_EXCEEDED') {
1896
- suggestions.push('Too many requests. Please wait and try again');
1897
- }
1898
- else if (error.statusCode && error.statusCode >= 500) {
1899
- suggestions.push('Server error occurred');
1900
- suggestions.push('Please try again later');
1901
- suggestions.push('If the problem persists, reach out to support');
1902
- }
1903
- else if (error.code === 'NETWORK_ERROR') {
1904
- suggestions.push('Check your internet connection');
1905
- suggestions.push('Try refreshing the page');
1906
- }
1907
- return suggestions;
1910
+ // Check maximum length
1911
+ const maxLength = config.maxMessageLength ?? 10000;
1912
+ if (text.length > maxLength) {
1913
+ return {
1914
+ valid: false,
1915
+ error: `Message exceeds ${maxLength} character limit`,
1916
+ errorCode: 'MESSAGE_TOO_LONG',
1917
+ };
1908
1918
  }
1909
- /**
1910
- * Get default recovery suggestions
1911
- */
1912
- getDefaultRecoverySuggestions(code) {
1913
- const suggestions = [];
1914
- if (code.includes('NETWORK') || code.includes('CONNECTION')) {
1915
- suggestions.push('Check your internet connection');
1916
- suggestions.push('Try refreshing the page');
1917
- }
1918
- else if (code.includes('TIMEOUT')) {
1919
- suggestions.push('The operation took too long');
1920
- suggestions.push('Please try again');
1921
- }
1922
- else {
1923
- suggestions.push('Please try again');
1924
- suggestions.push('If the problem persists, reach out to support');
1925
- }
1926
- return suggestions;
1919
+ return { valid: true };
1920
+ }
1921
+ /**
1922
+ * Validate conversation ID
1923
+ * @param conversationId - Conversation ID to validate
1924
+ * @returns Validation result
1925
+ */
1926
+ function validateConversationId(conversationId) {
1927
+ if (!conversationId || typeof conversationId !== 'string' || conversationId.trim().length === 0) {
1928
+ return {
1929
+ valid: false,
1930
+ error: 'Conversation ID is required',
1931
+ errorCode: 'MISSING_CONVERSATION_ID',
1932
+ };
1927
1933
  }
1928
- /**
1929
- * Log error to console
1930
- */
1931
- logError(error) {
1932
- const isError = error.severity === 'critical' || error.severity === 'error';
1933
- const header = `[Conversation ${error.severity.toUpperCase()}] ${error.operation}:`;
1934
- if (isError) {
1935
- console.error(header, error.message, error.context || '');
1936
- }
1937
- else {
1938
- console.warn(header, error.message, error.context || '');
1939
- }
1940
- if (error.originalError && error.severity !== 'info') {
1941
- console.error('Original error:', error.originalError);
1942
- }
1943
- if (error.recoverySuggestions && error.recoverySuggestions.length > 0) {
1944
- console.info('Recovery suggestions:', error.recoverySuggestions);
1945
- }
1934
+ // Check for reasonable length
1935
+ if (conversationId.length > 255) {
1936
+ return {
1937
+ valid: false,
1938
+ error: 'Conversation ID is too long',
1939
+ errorCode: 'MISSING_CONVERSATION_ID',
1940
+ };
1946
1941
  }
1947
- /**
1948
- * Get user-friendly error message
1949
- */
1950
- getUserFriendlyMessage(error) {
1951
- // Map technical errors to user-friendly messages
1952
- const messageMap = {
1953
- NETWORK_ERROR: 'Unable to connect. Please check your internet connection.',
1954
- UNAUTHORIZED: 'You are not authorized. Please log in again.',
1955
- FORBIDDEN: 'You do not have permission to perform this action.',
1956
- NOT_FOUND: 'The requested item could not be found.',
1957
- RATE_LIMIT_EXCEEDED: 'Too many requests. Please slow down.',
1958
- VALIDATION_ERROR: 'The provided data is invalid.',
1959
- SERVER_ERROR: 'A server error occurred. Please try again later.',
1960
- TIMEOUT: 'The operation timed out. Please try again.',
1942
+ return { valid: true };
1943
+ }
1944
+ /**
1945
+ * Validate message type
1946
+ * @param type - Message type to validate
1947
+ * @returns Validation result
1948
+ */
1949
+ function validateMessageType(type) {
1950
+ if (!type || type.trim().length === 0) {
1951
+ return {
1952
+ valid: false,
1953
+ error: 'Message type is required',
1954
+ errorCode: 'MISSING_MESSAGE_TYPE',
1961
1955
  };
1962
- return messageMap[error.code] || error.message || 'An unexpected error occurred.';
1963
1956
  }
1964
- /**
1965
- * Check if error is retryable
1966
- */
1967
- isRetryable(error) {
1968
- const retryableCodes = ['NETWORK_ERROR', 'TIMEOUT', 'RATE_LIMIT_EXCEEDED', 'SERVER_ERROR'];
1969
- const retryableStatusCodes = [408, 429, 500, 502, 503, 504];
1970
- return (retryableCodes.includes(error.code) ||
1971
- (error.statusCode !== undefined && retryableStatusCodes.includes(error.statusCode)));
1957
+ return { valid: true };
1958
+ }
1959
+ function validateMediaItems(items, label) {
1960
+ if (!Array.isArray(items) || items.length === 0) {
1961
+ return {
1962
+ valid: false,
1963
+ error: `${label} message must include at least one attachment`,
1964
+ errorCode: 'INVALID_MEDIA_PAYLOAD',
1965
+ };
1972
1966
  }
1973
- /**
1974
- * Execute an operation with automatic retry logic
1975
- * @param operation - The async operation to execute
1976
- * @param operationName - Name of the operation for error tracking
1977
- * @param context - Additional context for error handling
1978
- * @returns Promise resolving to the operation result
1979
- * @throws {AXConversationError} If all retries fail
1980
- */
1981
- async executeWithRetry(operation, operationName, context) {
1982
- if (!this._config.autoRetry) {
1983
- // If auto-retry is disabled, just execute once
1984
- return operation();
1967
+ for (const item of items) {
1968
+ const ok = (typeof item.url === 'string' && item.url.length > 0) ||
1969
+ (typeof item.mediaId === 'string' && item.mediaId.length > 0);
1970
+ if (!ok) {
1971
+ return {
1972
+ valid: false,
1973
+ error: `Each ${label.toLowerCase()} attachment must have a url or mediaId`,
1974
+ errorCode: 'INVALID_MEDIA_PAYLOAD',
1975
+ };
1985
1976
  }
1986
- const maxRetries = this._config.maxRetries ?? 3;
1987
- let lastError;
1988
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
1989
- try {
1990
- return await operation();
1977
+ }
1978
+ return { valid: true };
1979
+ }
1980
+ /**
1981
+ * Validate message payload
1982
+ * @param payload - Message payload to validate
1983
+ * @param type - Message type
1984
+ * @returns Validation result
1985
+ */
1986
+ function validateMessagePayload(payload, type) {
1987
+ if (!payload) {
1988
+ return {
1989
+ valid: false,
1990
+ error: 'Message payload is required',
1991
+ errorCode: 'MISSING_PAYLOAD',
1992
+ };
1993
+ }
1994
+ const normalized = type === 'image' || type === 'video' || type === 'audio' || type === 'file'
1995
+ ? normalizeMessagePayload(payload)
1996
+ : payload;
1997
+ // Type-specific validation
1998
+ switch (type) {
1999
+ case 'text':
2000
+ if (!('text' in payload) || typeof payload.text !== 'string') {
2001
+ return {
2002
+ valid: false,
2003
+ error: 'Text message must have a text property',
2004
+ errorCode: 'INVALID_TEXT_PAYLOAD',
2005
+ };
1991
2006
  }
1992
- catch (error) {
1993
- lastError = error;
1994
- const conversationError = this.handle(error, operationName, { ...context, attempt });
1995
- // Don't retry if error is not retryable or if this was the last attempt
1996
- if (!this.isRetryable(conversationError) || attempt >= maxRetries) {
1997
- throw conversationError;
1998
- }
1999
- // Exponential backoff: 1s, 2s, 4s, 8s...
2000
- const delayMs = Math.pow(2, attempt) * 1000;
2001
- await this.delay(delayMs);
2007
+ break;
2008
+ case 'image':
2009
+ return validateMediaItems(normalized.images, 'Image');
2010
+ case 'video':
2011
+ return validateMediaItems(normalized.videos, 'Video');
2012
+ case 'audio':
2013
+ return validateMediaItems(normalized.audios, 'Audio');
2014
+ case 'file':
2015
+ return validateMediaItems(normalized.files, 'File');
2016
+ case 'voice':
2017
+ case 'sticker': {
2018
+ const media = payload;
2019
+ const hasUrl = typeof media.url === 'string' && media.url.length > 0;
2020
+ const hasMediaId = typeof media.mediaId === 'string' && media.mediaId.length > 0;
2021
+ if (!hasUrl && !hasMediaId) {
2022
+ return {
2023
+ valid: false,
2024
+ error: `${type} message must have a url or mediaId`,
2025
+ errorCode: 'INVALID_MEDIA_PAYLOAD',
2026
+ };
2002
2027
  }
2028
+ break;
2003
2029
  }
2004
- throw this.handle(lastError, operationName, context);
2005
- }
2006
- /**
2007
- * Delay helper for retry backoff
2008
- * @param ms - Milliseconds to delay
2009
- */
2010
- delay(ms) {
2011
- return new Promise((resolve) => setTimeout(resolve, ms));
2030
+ case 'location':
2031
+ if (!('latitude' in payload) ||
2032
+ !('longitude' in payload) ||
2033
+ typeof payload.latitude !== 'number' ||
2034
+ typeof payload.longitude !== 'number') {
2035
+ return {
2036
+ valid: false,
2037
+ error: 'Location message must have latitude and longitude properties',
2038
+ errorCode: 'INVALID_LOCATION_PAYLOAD',
2039
+ };
2040
+ }
2041
+ break;
2012
2042
  }
2013
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXErrorHandlerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
2014
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXErrorHandlerService, providedIn: 'root' }); }
2015
- }
2016
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXErrorHandlerService, decorators: [{
2017
- type: Injectable,
2018
- args: [{
2019
- providedIn: 'root',
2020
- }]
2021
- }], ctorParameters: () => [] });
2022
-
2023
- function formatFileSize(bytes) {
2024
- if (bytes < 1024)
2025
- return `${bytes} B`;
2026
- if (bytes < 1024 * 1024)
2027
- return `${(bytes / 1024).toFixed(1)} KB`;
2028
- if (bytes < 1024 * 1024 * 1024)
2029
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
2030
- return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
2031
- }
2032
- function formatDuration(seconds) {
2033
- const mins = Math.floor(seconds / 60);
2034
- const secs = Math.floor(seconds % 60);
2035
- return `${mins}:${secs.toString().padStart(2, '0')}`;
2043
+ return { valid: true };
2036
2044
  }
2037
-
2038
- const CONVERSATION_AUDIO_CATALOG = 'conversation-audio';
2039
- const MB$4 = 1024 * 1024;
2040
- const CONVERSATION_AUDIO_PRESENTATION = {
2041
- icon: 'fa-light fa-music ax-text-amber-500',
2042
- title: 'Audio',
2043
- };
2044
- const AUDIO_UTILITY = {
2045
- preview: 'preview',
2046
- duration: 'duration',
2047
- formatSize: 'formatSize',
2048
- formatDuration: 'formatDuration',
2049
- createLocalPreviewUrl: 'createLocalPreviewUrl',
2050
- };
2051
- function blobUrl$4(ctx, blob) {
2052
- if (!isPlatformBrowser(ctx.platformId)) {
2053
- return '';
2045
+ /**
2046
+ * Validate user ID
2047
+ * @param userId - User ID to validate
2048
+ * @returns Validation result
2049
+ */
2050
+ function validateUserId(userId) {
2051
+ if (!userId || typeof userId !== 'string' || userId.trim().length === 0) {
2052
+ return {
2053
+ valid: false,
2054
+ error: 'User ID is required',
2055
+ errorCode: 'MISSING_USER_ID',
2056
+ };
2054
2057
  }
2055
- return URL.createObjectURL(blob);
2058
+ // Check for reasonable length (prevent extremely long IDs)
2059
+ if (userId.length > 255) {
2060
+ return {
2061
+ valid: false,
2062
+ error: 'User ID is too long',
2063
+ errorCode: 'INVALID_USER_ID',
2064
+ };
2065
+ }
2066
+ return { valid: true };
2056
2067
  }
2057
- function mediaDuration$2(ctx, file) {
2058
- if (!isPlatformBrowser(ctx.platformId)) {
2059
- return Promise.resolve(0);
2068
+ /**
2069
+ * Validate array of user IDs
2070
+ * @param userIds - Array of user IDs to validate
2071
+ * @param minCount - Minimum number of users required
2072
+ * @param maxCount - Maximum number of users allowed
2073
+ * @returns Validation result
2074
+ */
2075
+ function validateUserIds(userIds, minCount = 1, maxCount) {
2076
+ if (!userIds || !Array.isArray(userIds)) {
2077
+ return {
2078
+ valid: false,
2079
+ error: 'User IDs must be an array',
2080
+ errorCode: 'INVALID_USER_IDS',
2081
+ };
2060
2082
  }
2061
- return new Promise((resolve, reject) => {
2062
- const el = document.createElement('audio');
2063
- el.preload = 'metadata';
2064
- el.onloadedmetadata = () => {
2065
- URL.revokeObjectURL(el.src);
2066
- resolve(el.duration);
2083
+ if (userIds.length < minCount) {
2084
+ return {
2085
+ valid: false,
2086
+ error: `At least ${minCount} user(s) required`,
2087
+ errorCode: 'TOO_FEW_USERS',
2067
2088
  };
2068
- el.onerror = () => {
2069
- URL.revokeObjectURL(el.src);
2070
- reject(new Error('Failed to load audio metadata'));
2089
+ }
2090
+ if (maxCount && userIds.length > maxCount) {
2091
+ return {
2092
+ valid: false,
2093
+ error: `Maximum ${maxCount} user(s) allowed`,
2094
+ errorCode: 'TOO_MANY_USERS',
2071
2095
  };
2072
- el.src = URL.createObjectURL(file);
2073
- });
2096
+ }
2097
+ // Check for empty or invalid IDs
2098
+ const invalidIds = userIds.filter((id) => !id || id.trim().length === 0);
2099
+ if (invalidIds.length > 0) {
2100
+ return {
2101
+ valid: false,
2102
+ error: 'All user IDs must be non-empty strings',
2103
+ errorCode: 'INVALID_USER_ID',
2104
+ };
2105
+ }
2106
+ return { valid: true };
2074
2107
  }
2075
- function conversationAudioUtilities() {
2076
- return {
2077
- [AUDIO_UTILITY.preview]: (ctx, file) => ctx.readAsDataUrl(file),
2078
- [AUDIO_UTILITY.duration]: (ctx, file) => mediaDuration$2(ctx, file),
2079
- [AUDIO_UTILITY.formatSize]: (_ctx, bytes) => formatFileSize(bytes),
2080
- [AUDIO_UTILITY.formatDuration]: (_ctx, seconds) => formatDuration(seconds),
2081
- [AUDIO_UTILITY.createLocalPreviewUrl]: (ctx, source) => blobUrl$4(ctx, source),
2082
- };
2108
+ /**
2109
+ * Validate email address
2110
+ * @param email - Email to validate
2111
+ * @returns Validation result
2112
+ */
2113
+ function validateEmail(email) {
2114
+ if (!email || email.trim().length === 0) {
2115
+ return {
2116
+ valid: false,
2117
+ error: 'Email is required',
2118
+ errorCode: 'MISSING_EMAIL',
2119
+ };
2120
+ }
2121
+ // Trim whitespace
2122
+ const trimmedEmail = email.trim();
2123
+ // Check length constraints
2124
+ if (trimmedEmail.length > 254) {
2125
+ return {
2126
+ valid: false,
2127
+ error: 'Email is too long',
2128
+ errorCode: 'INVALID_EMAIL',
2129
+ };
2130
+ }
2131
+ // Enhanced email regex with better validation
2132
+ const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
2133
+ if (!emailRegex.test(trimmedEmail)) {
2134
+ return {
2135
+ valid: false,
2136
+ error: 'Invalid email format',
2137
+ errorCode: 'INVALID_EMAIL',
2138
+ };
2139
+ }
2140
+ return { valid: true };
2083
2141
  }
2084
- function createConversationAudioFileType() {
2085
- const presentation = CONVERSATION_AUDIO_PRESENTATION;
2086
- return {
2087
- name: CONVERSATION_AUDIO_CATALOG,
2088
- metadata: createFileTypeMetadata('conversation'),
2089
- title: presentation.title,
2090
- icon: presentation.icon,
2091
- validations: {
2092
- mimeTypes: ['audio/*'],
2093
- minSize: 1,
2094
- maxSize: 50 * MB$4,
2095
- },
2096
- extensions: [
2097
- { name: 'mp3', title: 'MP3' },
2098
- { name: 'wav', title: 'WAV', validations: { maxSize: 30 * MB$4 } },
2099
- { name: 'ogg', title: 'OGG' },
2100
- { name: 'm4a', title: 'M4A' },
2101
- ],
2102
- utilities: conversationAudioUtilities(),
2103
- copy: (payload) => {
2104
- const audio = normalizeAudioPayload(payload);
2105
- const caption = audio.caption?.trim();
2106
- const items = audio.audios.map((item) => ({
2107
- url: item.url?.trim(),
2108
- title: item.title?.trim(),
2109
- }));
2142
+ /**
2143
+ * Validate URL
2144
+ * @param url - URL to validate
2145
+ * @returns Validation result
2146
+ */
2147
+ function validateUrl(url) {
2148
+ if (!url || url.trim().length === 0) {
2149
+ return {
2150
+ valid: false,
2151
+ error: 'URL is required',
2152
+ errorCode: 'MISSING_URL',
2153
+ };
2154
+ }
2155
+ const trimmedUrl = url.trim();
2156
+ // Check for common URL issues
2157
+ if (trimmedUrl.length > 2048) {
2158
+ return {
2159
+ valid: false,
2160
+ error: 'URL is too long',
2161
+ errorCode: 'INVALID_URL',
2162
+ };
2163
+ }
2164
+ try {
2165
+ const urlObj = new URL(trimmedUrl);
2166
+ // Validate protocol
2167
+ if (!['http:', 'https:', 'ftp:', 'ftps:'].includes(urlObj.protocol)) {
2110
2168
  return {
2111
- text: caption ?? '',
2112
- meta: { kind: 'audio', caption, items, count: audio.audios.length },
2169
+ valid: false,
2170
+ error: 'Invalid URL protocol',
2171
+ errorCode: 'INVALID_URL',
2113
2172
  };
2114
- },
2115
- };
2116
- }
2117
- class AXConversationAudioFileTypeProvider extends AXFileTypeInfoProvider {
2118
- items() {
2119
- return Promise.resolve([createConversationAudioFileType()]);
2173
+ }
2174
+ return { valid: true };
2120
2175
  }
2121
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationAudioFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
2122
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationAudioFileTypeProvider, providedIn: 'root' }); }
2123
- }
2124
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationAudioFileTypeProvider, decorators: [{
2125
- type: Injectable,
2126
- args: [{ providedIn: 'root' }]
2127
- }] });
2128
-
2129
- function audioItemFromUpload(file, result, duration) {
2130
- const url = resolvePersistableMediaUrl(result.url);
2131
- const item = {
2132
- mediaId: result.mediaId,
2133
- mimeType: result.mimeType,
2134
- size: result.size,
2135
- duration,
2136
- title: file.name,
2137
- metadata: result.metadata,
2138
- };
2139
- if (url) {
2140
- item.url = url;
2176
+ catch {
2177
+ return {
2178
+ valid: false,
2179
+ error: 'Invalid URL format',
2180
+ errorCode: 'INVALID_URL',
2181
+ };
2141
2182
  }
2142
- return item;
2143
2183
  }
2144
- function mergeAudioUploadResult(payload, result) {
2145
- const base = normalizeAudioPayload(payload);
2146
- const audios = [...base.audios];
2147
- const i = audios.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
2148
- const slot = i >= 0 ? audios[i] : undefined;
2149
- const url = resolvePersistableMediaUrl(result.url);
2150
- const next = {
2151
- ...(slot ?? { duration: 0 }),
2152
- mediaId: result.mediaId,
2153
- mimeType: result.mimeType,
2154
- size: result.size,
2155
- metadata: result.metadata,
2156
- };
2157
- if (url) {
2158
- next.url = url;
2159
- }
2160
- if (i >= 0)
2161
- audios[i] = next;
2162
- else
2163
- audios.push(next);
2164
- return { ...base, type: 'audio', audios };
2184
+ // =====================
2185
+ // Helper Functions
2186
+ // =====================
2187
+ /**
2188
+ * Sanitize user input to prevent XSS
2189
+ * Note: Angular provides built-in sanitization, but this is an additional layer
2190
+ * @param input - User input to sanitize
2191
+ * @returns Sanitized input
2192
+ */
2193
+ function sanitizeInput(input) {
2194
+ if (!input)
2195
+ return '';
2196
+ return input
2197
+ .replace(/&/g, '&amp;')
2198
+ .replace(/</g, '&lt;')
2199
+ .replace(/>/g, '&gt;')
2200
+ .replace(/"/g, '&quot;')
2201
+ .replace(/'/g, '&#x27;')
2202
+ .replace(/\//g, '&#x2F;')
2203
+ .replace(/`/g, '&#x60;')
2204
+ .replace(/=/g, '&#x3D;');
2165
2205
  }
2166
- function applyAudioLocalPreview(payload, localUrl, mimeType = 'application/octet-stream') {
2167
- const base = normalizeAudioPayload(payload);
2168
- const audios = [...base.audios];
2169
- const slot = audios.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
2170
- const preview = { url: localUrl, duration: 0, mimeType };
2171
- if (slot >= 0) {
2172
- audios[slot] = { ...audios[slot], ...preview };
2206
+ /**
2207
+ * Validate latitude coordinate
2208
+ * @param latitude - Latitude to validate
2209
+ * @returns Validation result
2210
+ */
2211
+ function validateLatitude(latitude) {
2212
+ if (latitude === undefined || latitude === null || typeof latitude !== 'number' || isNaN(latitude)) {
2213
+ return {
2214
+ valid: false,
2215
+ error: 'Latitude is required',
2216
+ errorCode: 'MISSING_LATITUDE',
2217
+ };
2173
2218
  }
2174
- else if (audios.length === 0) {
2175
- audios.push(preview);
2219
+ if (latitude < -90 || latitude > 90) {
2220
+ return {
2221
+ valid: false,
2222
+ error: 'Latitude must be between -90 and 90',
2223
+ errorCode: 'INVALID_LATITUDE',
2224
+ };
2176
2225
  }
2177
- else {
2178
- audios[0] = { ...audios[0], ...preview };
2226
+ return { valid: true };
2227
+ }
2228
+ /**
2229
+ * Validate longitude coordinate
2230
+ * @param longitude - Longitude to validate
2231
+ * @returns Validation result
2232
+ */
2233
+ function validateLongitude(longitude) {
2234
+ if (longitude === undefined || longitude === null || typeof longitude !== 'number' || isNaN(longitude)) {
2235
+ return {
2236
+ valid: false,
2237
+ error: 'Longitude is required',
2238
+ errorCode: 'MISSING_LONGITUDE',
2239
+ };
2179
2240
  }
2180
- return { ...base, type: 'audio', audios };
2241
+ if (longitude < -180 || longitude > 180) {
2242
+ return {
2243
+ valid: false,
2244
+ error: 'Longitude must be between -180 and 180',
2245
+ errorCode: 'INVALID_LONGITUDE',
2246
+ };
2247
+ }
2248
+ return { valid: true };
2181
2249
  }
2182
2250
 
2183
- const CONVERSATION_IMAGE_CATALOG = 'conversation-image';
2184
- const MB$3 = 1024 * 1024;
2185
- const CONVERSATION_IMAGE_PRESENTATION = {
2186
- icon: 'fa-light fa-image ax-text-purple-500',
2187
- title: 'Image',
2188
- };
2189
- const IMAGE_UTILITY = {
2190
- preview: 'preview',
2191
- formatSize: 'formatSize',
2192
- createLocalPreviewUrl: 'createLocalPreviewUrl',
2193
- };
2194
- function blobUrl$3(ctx, blob) {
2195
- if (!isPlatformBrowser(ctx.platformId)) {
2196
- return '';
2197
- }
2198
- return URL.createObjectURL(blob);
2251
+ /**
2252
+ * In-memory conversation and message graph (signal-based).
2253
+ * Plain class — not DI-registered; instantiated by `AXConversationService`.
2254
+ */
2255
+ function withNormalizedPayload(message) {
2256
+ return { ...message, payload: normalizeMessagePayload(message.payload) };
2199
2257
  }
2200
- function conversationImageUtilities() {
2201
- return {
2202
- [IMAGE_UTILITY.preview]: (ctx, file) => ctx.readAsDataUrl(file),
2203
- [IMAGE_UTILITY.formatSize]: (_ctx, bytes) => formatFileSize(bytes),
2204
- [IMAGE_UTILITY.createLocalPreviewUrl]: (ctx, source) => {
2205
- const blob = source;
2206
- if (blob.type.startsWith('image/')) {
2207
- return ctx.readAsDataUrl(blob);
2258
+ class ConversationState {
2259
+ constructor(config) {
2260
+ this.config = config;
2261
+ this._conversations = signal(new Map(), ...(ngDevMode ? [{ debugName: "_conversations" }] : []));
2262
+ this._messages = signal(new Map(), ...(ngDevMode ? [{ debugName: "_messages" }] : []));
2263
+ this._conversationMessages = signal(new Map(), ...(ngDevMode ? [{ debugName: "_conversationMessages" }] : []));
2264
+ this.conversations = computed(() => {
2265
+ const convMap = this._conversations();
2266
+ return Array.from(convMap.values());
2267
+ }, ...(ngDevMode ? [{ debugName: "conversations", equal: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]) }] : [{
2268
+ equal: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]),
2269
+ }]));
2270
+ }
2271
+ setConversations(conversations) {
2272
+ const convMap = new Map();
2273
+ conversations.forEach((conv) => convMap.set(conv.id, conv));
2274
+ this._conversations.set(convMap);
2275
+ }
2276
+ addConversations(conversations) {
2277
+ this._conversations.update((existingConversations) => {
2278
+ const newConversations = new Map(existingConversations);
2279
+ conversations.forEach((conv) => newConversations.set(conv.id, conv));
2280
+ const maxCached = this.config.maxCachedConversations;
2281
+ if (newConversations.size > maxCached) {
2282
+ const sorted = Array.from(newConversations.values()).sort((a, b) => (b.lastMessageAt?.getTime() ?? 0) - (a.lastMessageAt?.getTime() ?? 0));
2283
+ const toKeep = sorted.slice(0, maxCached);
2284
+ const cleanedMap = new Map();
2285
+ toKeep.forEach((conv) => cleanedMap.set(conv.id, conv));
2286
+ return cleanedMap;
2208
2287
  }
2209
- return blobUrl$3(ctx, blob);
2210
- },
2211
- };
2212
- }
2213
- function createConversationImageFileType() {
2214
- const presentation = CONVERSATION_IMAGE_PRESENTATION;
2215
- return {
2216
- name: CONVERSATION_IMAGE_CATALOG,
2217
- metadata: createFileTypeMetadata('conversation'),
2218
- title: presentation.title,
2219
- icon: presentation.icon,
2220
- validations: {
2221
- mimeTypes: ['image/*'],
2222
- minSize: 1,
2223
- maxSize: 100 * MB$3,
2224
- },
2225
- extensions: [
2226
- { name: 'jpg', title: 'JPEG' },
2227
- { name: 'jpeg', title: 'JPEG', validations: { maxSize: 5 * MB$3 } },
2228
- { name: 'png', title: 'PNG' },
2229
- { name: 'gif', title: 'GIF' },
2230
- { name: 'webp', title: 'WebP' },
2231
- { name: 'svg', title: 'SVG', validations: { maxSize: 2 * MB$3 } },
2232
- ],
2233
- utilities: conversationImageUtilities(),
2234
- copy: (payload) => {
2235
- const image = normalizeImagePayload(payload);
2236
- const caption = image.caption?.trim();
2237
- const urls = image.images
2238
- .map((item) => item.url?.trim() || item.thumbnailUrl?.trim())
2239
- .filter((url) => !!url);
2240
- return {
2241
- text: caption ?? '',
2242
- meta: { kind: 'image', caption, urls, count: image.images.length },
2243
- };
2244
- },
2245
- };
2246
- }
2247
- class AXConversationImageFileTypeProvider extends AXFileTypeInfoProvider {
2248
- items() {
2249
- return Promise.resolve([createConversationImageFileType()]);
2288
+ return newConversations;
2289
+ });
2250
2290
  }
2251
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationImageFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
2252
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationImageFileTypeProvider, providedIn: 'root' }); }
2253
- }
2254
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationImageFileTypeProvider, decorators: [{
2255
- type: Injectable,
2256
- args: [{ providedIn: 'root' }]
2257
- }] });
2258
-
2259
- const CONVERSATION_VIDEO_CATALOG = 'conversation-video';
2260
- const MB$2 = 1024 * 1024;
2261
- const CONVERSATION_VIDEO_PRESENTATION = {
2262
- icon: 'fa-light fa-video ax-text-blue-500',
2263
- title: 'Video',
2264
- };
2265
- const VIDEO_UTILITY = {
2266
- preview: 'preview',
2267
- duration: 'duration',
2268
- formatSize: 'formatSize',
2269
- formatDuration: 'formatDuration',
2270
- createLocalPreviewUrl: 'createLocalPreviewUrl',
2271
- };
2272
- function blobUrl$2(ctx, blob) {
2273
- if (!isPlatformBrowser(ctx.platformId)) {
2274
- return '';
2291
+ setConversation(conversation) {
2292
+ this._conversations.update((conversations) => {
2293
+ const newConversations = new Map(conversations);
2294
+ newConversations.set(conversation.id, conversation);
2295
+ return newConversations;
2296
+ });
2275
2297
  }
2276
- return URL.createObjectURL(blob);
2277
- }
2278
- function mediaDuration$1(ctx, file, tag) {
2279
- if (!isPlatformBrowser(ctx.platformId)) {
2280
- return Promise.resolve(0);
2298
+ getConversation(conversationId) {
2299
+ return this._conversations().get(conversationId);
2281
2300
  }
2282
- return new Promise((resolve, reject) => {
2283
- const el = document.createElement(tag);
2284
- el.preload = 'metadata';
2285
- el.onloadedmetadata = () => {
2286
- URL.revokeObjectURL(el.src);
2287
- resolve(el.duration);
2288
- };
2289
- el.onerror = () => {
2290
- URL.revokeObjectURL(el.src);
2291
- reject(new Error(`Failed to load ${tag} metadata`));
2292
- };
2293
- el.src = URL.createObjectURL(file);
2294
- });
2295
- }
2296
- function conversationVideoUtilities() {
2297
- return {
2298
- [VIDEO_UTILITY.preview]: (ctx, file) => ctx.readAsDataUrl(file),
2299
- [VIDEO_UTILITY.duration]: (ctx, file) => mediaDuration$1(ctx, file, 'video'),
2300
- [VIDEO_UTILITY.formatSize]: (_ctx, bytes) => formatFileSize(bytes),
2301
- [VIDEO_UTILITY.formatDuration]: (_ctx, seconds) => formatDuration(seconds),
2302
- [VIDEO_UTILITY.createLocalPreviewUrl]: async (ctx, source) => {
2303
- const blob = source;
2304
- if (blob.type.startsWith('image/')) {
2305
- return ctx.readAsDataUrl(blob);
2306
- }
2307
- return blobUrl$2(ctx, blob);
2308
- },
2309
- };
2310
- }
2311
- function createConversationVideoFileType() {
2312
- const presentation = CONVERSATION_VIDEO_PRESENTATION;
2313
- return {
2314
- name: CONVERSATION_VIDEO_CATALOG,
2315
- metadata: createFileTypeMetadata('conversation'),
2316
- title: presentation.title,
2317
- icon: presentation.icon,
2318
- validations: {
2319
- mimeTypes: ['video/*'],
2320
- minSize: 1,
2321
- maxSize: 500 * MB$2,
2322
- },
2323
- extensions: [
2324
- { name: 'mp4', title: 'MP4' },
2325
- { name: 'webm', title: 'WebM', validations: { maxSize: 200 * MB$2 } },
2326
- { name: 'ogg', title: 'OGG' },
2327
- ],
2328
- utilities: conversationVideoUtilities(),
2329
- copy: (payload) => {
2330
- const video = normalizeVideoPayload(payload);
2331
- const caption = video.caption?.trim();
2332
- const urls = video.videos.map((item) => item.url?.trim()).filter((url) => !!url);
2333
- return {
2334
- text: caption ?? '',
2335
- meta: { kind: 'video', caption, urls, count: video.videos.length },
2336
- };
2337
- },
2338
- };
2339
- }
2340
- class AXConversationVideoFileTypeProvider extends AXFileTypeInfoProvider {
2341
- items() {
2342
- return Promise.resolve([createConversationVideoFileType()]);
2301
+ updateConversation(conversationId, updates) {
2302
+ const conversation = this._conversations().get(conversationId);
2303
+ if (!conversation)
2304
+ return;
2305
+ this.setConversation({ ...conversation, ...updates });
2343
2306
  }
2344
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationVideoFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
2345
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationVideoFileTypeProvider, providedIn: 'root' }); }
2346
- }
2347
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationVideoFileTypeProvider, decorators: [{
2348
- type: Injectable,
2349
- args: [{ providedIn: 'root' }]
2350
- }] });
2351
-
2352
- const CONVERSATION_FILE_CATALOG = 'conversation-file';
2353
- const MB$1 = 1024 * 1024;
2354
- const CONVERSATION_FILE_PRESENTATION = {
2355
- icon: 'fa-light fa-file ax-text-neutral-500',
2356
- title: 'File',
2357
- };
2358
- const CONVERSATION_FILE_ALLOWED_MIME_TYPES = [
2359
- 'image/*',
2360
- 'video/*',
2361
- 'audio/*',
2362
- 'application/pdf',
2363
- 'application/msword',
2364
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
2365
- 'application/vnd.ms-excel',
2366
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
2367
- 'application/zip',
2368
- 'application/x-zip-compressed',
2369
- 'application/x-7z-compressed',
2370
- 'application/vnd.rar',
2371
- 'application/octet-stream',
2372
- 'text/plain',
2373
- ];
2374
- const FILE_UTILITY = {
2375
- preview: 'preview',
2376
- formatSize: 'formatSize',
2377
- createLocalPreviewUrl: 'createLocalPreviewUrl',
2378
- pickerCatalog: 'pickerCatalog',
2379
- };
2380
- function blobUrl$1(ctx, blob) {
2381
- if (!isPlatformBrowser(ctx.platformId)) {
2382
- return '';
2307
+ deleteConversation(conversationId) {
2308
+ this._conversations.update((conversations) => {
2309
+ const newConversations = new Map(conversations);
2310
+ newConversations.delete(conversationId);
2311
+ return newConversations;
2312
+ });
2313
+ const messageIds = this._conversationMessages().get(conversationId) || [];
2314
+ this._messages.update((messages) => {
2315
+ const newMessages = new Map(messages);
2316
+ messageIds.forEach((id) => newMessages.delete(id));
2317
+ return newMessages;
2318
+ });
2319
+ this._conversationMessages.update((map) => {
2320
+ const newMap = new Map(map);
2321
+ newMap.delete(conversationId);
2322
+ return newMap;
2323
+ });
2383
2324
  }
2384
- return URL.createObjectURL(blob);
2385
- }
2386
- function conversationFileUtilities() {
2387
- return {
2388
- [FILE_UTILITY.preview]: async (ctx, file) => {
2389
- const f = file;
2390
- if (f.type.startsWith('image/')) {
2391
- return ctx.readAsDataUrl(f);
2325
+ updateLastMessage(message) {
2326
+ this.updateConversation(message.conversationId, {
2327
+ lastMessage: message,
2328
+ lastMessageAt: message.timestamp,
2329
+ updatedAt: message.timestamp,
2330
+ });
2331
+ }
2332
+ incrementUnreadCount(conversationId) {
2333
+ const conversation = this._conversations().get(conversationId);
2334
+ if (!conversation)
2335
+ return;
2336
+ this.updateConversation(conversationId, { unreadCount: conversation.unreadCount + 1 });
2337
+ }
2338
+ resetUnreadCount(conversationId) {
2339
+ this.updateConversation(conversationId, { unreadCount: 0 });
2340
+ }
2341
+ updateSettings(conversationId, settings) {
2342
+ const conversation = this._conversations().get(conversationId);
2343
+ if (!conversation)
2344
+ return;
2345
+ this.updateConversation(conversationId, {
2346
+ settings: { ...conversation.settings, ...settings },
2347
+ updatedAt: new Date(),
2348
+ });
2349
+ }
2350
+ updateTitle(conversationId, title) {
2351
+ this.updateConversation(conversationId, { title, updatedAt: new Date() });
2352
+ }
2353
+ updateMetadata(conversationId, metadata) {
2354
+ const conversation = this._conversations().get(conversationId);
2355
+ if (!conversation)
2356
+ return;
2357
+ this.updateConversation(conversationId, {
2358
+ metadata: { ...conversation.metadata, ...metadata },
2359
+ updatedAt: new Date(),
2360
+ });
2361
+ }
2362
+ updateTypingIndicator(conversationId, userId, isTyping) {
2363
+ const conversation = this._conversations().get(conversationId);
2364
+ if (!conversation)
2365
+ return;
2366
+ let typingUsers = [...conversation.status.typingUsers];
2367
+ if (isTyping) {
2368
+ if (!typingUsers.includes(userId)) {
2369
+ typingUsers.push(userId);
2392
2370
  }
2393
- return undefined;
2394
- },
2395
- [FILE_UTILITY.formatSize]: (_ctx, bytes) => formatFileSize(bytes),
2396
- [FILE_UTILITY.createLocalPreviewUrl]: (ctx, source) => blobUrl$1(ctx, source),
2397
- [FILE_UTILITY.pickerCatalog]: (_ctx, file) => {
2398
- const f = file;
2399
- if (f.type.startsWith('image/'))
2400
- return CONVERSATION_IMAGE_CATALOG;
2401
- if (f.type.startsWith('video/'))
2402
- return CONVERSATION_VIDEO_CATALOG;
2403
- if (f.type.startsWith('audio/'))
2404
- return CONVERSATION_AUDIO_CATALOG;
2405
- return CONVERSATION_FILE_CATALOG;
2406
- },
2407
- };
2408
- }
2409
- function createConversationFileFileType() {
2410
- const presentation = CONVERSATION_FILE_PRESENTATION;
2411
- return {
2412
- name: CONVERSATION_FILE_CATALOG,
2413
- metadata: createFileTypeMetadata('conversation'),
2414
- title: presentation.title,
2415
- icon: presentation.icon,
2416
- validations: {
2417
- mimeTypes: [...CONVERSATION_FILE_ALLOWED_MIME_TYPES],
2418
- minSize: 1,
2419
- maxSize: 100 * MB$1,
2420
- },
2421
- extensions: [
2422
- { name: 'pdf', title: 'PDF', validations: { mimeTypes: ['application/pdf'], maxSize: 25 * MB$1 } },
2423
- { name: 'doc', title: 'Word' },
2424
- { name: 'docx', title: 'Word' },
2425
- { name: 'txt', title: 'Text', validations: { mimeTypes: ['text/plain'], maxSize: 5 * MB$1 } },
2426
- {
2427
- name: 'zip',
2428
- title: 'ZIP',
2429
- validations: {
2430
- mimeTypes: ['application/zip', 'application/x-zip-compressed', 'application/octet-stream'],
2431
- maxSize: 50 * MB$1,
2432
- },
2433
- },
2434
- ],
2435
- utilities: conversationFileUtilities(),
2436
- copy: (payload) => {
2437
- const file = normalizeFilePayload(payload);
2438
- const caption = file.caption?.trim();
2439
- const items = file.files.map((item) => {
2440
- const name = item.name?.trim();
2441
- const url = item.url?.trim();
2442
- const line = name && url ? `${name} — ${url}` : url || name;
2443
- return { name, url, line };
2371
+ }
2372
+ else {
2373
+ typingUsers = typingUsers.filter((id) => id !== userId);
2374
+ }
2375
+ this.updateConversation(conversationId, {
2376
+ status: {
2377
+ ...conversation.status,
2378
+ isTyping: typingUsers.length > 0,
2379
+ typingUsers,
2380
+ },
2381
+ });
2382
+ }
2383
+ updateParticipantPresence(userId, status, lastSeen) {
2384
+ this._conversations.update((conversations) => {
2385
+ const newConversations = new Map(conversations);
2386
+ for (const [id, conv] of conversations) {
2387
+ const participant = conv.participants.find((p) => p.id === userId);
2388
+ if (participant) {
2389
+ const updatedConversation = {
2390
+ ...conv,
2391
+ participants: conv.participants.map((p) => (p.id === userId ? { ...p, status, lastSeen } : p)),
2392
+ status: conv.type === 'private' ? { ...conv.status, presence: status, lastSeen } : conv.status,
2393
+ };
2394
+ newConversations.set(id, updatedConversation);
2395
+ }
2396
+ }
2397
+ return newConversations;
2398
+ });
2399
+ }
2400
+ addMessage(message) {
2401
+ const normalized = withNormalizedPayload(message);
2402
+ this._messages.update((messages) => {
2403
+ const newMessages = new Map(messages);
2404
+ newMessages.set(normalized.id, normalized);
2405
+ return newMessages;
2406
+ });
2407
+ this._conversationMessages.update((map) => {
2408
+ const newMap = new Map(map);
2409
+ const existing = newMap.get(normalized.conversationId) || [];
2410
+ if (existing.includes(normalized.id)) {
2411
+ return newMap;
2412
+ }
2413
+ const updated = [...existing, normalized.id].sort((a, b) => {
2414
+ const msgA = this._messages().get(a);
2415
+ const msgB = this._messages().get(b);
2416
+ if (!msgA || !msgB)
2417
+ return 0;
2418
+ return msgA.timestamp.getTime() - msgB.timestamp.getTime();
2444
2419
  });
2445
- return {
2446
- text: caption ?? '',
2447
- meta: { kind: 'file', caption, items, count: file.files.length },
2448
- };
2449
- },
2450
- };
2451
- }
2452
- class AXConversationFileFileTypeProvider extends AXFileTypeInfoProvider {
2453
- items() {
2454
- return Promise.resolve([createConversationFileFileType()]);
2420
+ newMap.set(normalized.conversationId, updated);
2421
+ return newMap;
2422
+ });
2455
2423
  }
2456
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationFileFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
2457
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationFileFileTypeProvider, providedIn: 'root' }); }
2458
- }
2459
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationFileFileTypeProvider, decorators: [{
2460
- type: Injectable,
2461
- args: [{ providedIn: 'root' }]
2462
- }] });
2463
-
2464
- function fileItemFromUpload(file, result) {
2465
- const extension = file.name.includes('.') ? file.name.split('.').pop() : undefined;
2466
- const url = resolvePersistableMediaUrl(result.url);
2467
- const item = {
2468
- mediaId: result.mediaId,
2469
- mimeType: result.mimeType,
2470
- size: result.size,
2471
- name: file.name,
2472
- thumbnailUrl: resolvePersistedThumbnailUrl(result.thumbnailUrl, url),
2473
- extension,
2474
- metadata: result.metadata,
2475
- };
2476
- if (url) {
2477
- item.url = url;
2424
+ /**
2425
+ * Replace all messages for a conversation (initial page load).
2426
+ */
2427
+ setConversationMessages(conversationId, messages) {
2428
+ const sorted = [...messages]
2429
+ .map(withNormalizedPayload)
2430
+ .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
2431
+ const sortedIds = sorted.map((m) => m.id);
2432
+ this._messages.update((msgs) => {
2433
+ const newMessages = new Map(msgs);
2434
+ const previousIds = this._conversationMessages().get(conversationId) || [];
2435
+ for (const id of previousIds) {
2436
+ newMessages.delete(id);
2437
+ }
2438
+ for (const msg of sorted) {
2439
+ newMessages.set(msg.id, msg);
2440
+ }
2441
+ return newMessages;
2442
+ });
2443
+ this._conversationMessages.update((map) => {
2444
+ const newMap = new Map(map);
2445
+ newMap.set(conversationId, sortedIds);
2446
+ return newMap;
2447
+ });
2448
+ this.cleanupOldMessages();
2478
2449
  }
2479
- return item;
2480
- }
2481
- function mergeFileUploadResult(payload, result) {
2482
- const base = normalizeFilePayload(payload);
2483
- const files = [...base.files];
2484
- const i = files.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
2485
- const slot = i >= 0 ? files[i] : undefined;
2486
- const name = slot?.name ?? result.metadata?.['fileName'] ?? 'file';
2487
- const url = resolvePersistableMediaUrl(result.url);
2488
- const next = {
2489
- ...(slot ?? { name, mimeType: result.mimeType }),
2490
- mediaId: result.mediaId,
2491
- mimeType: result.mimeType,
2492
- size: result.size,
2493
- name,
2494
- thumbnailUrl: resolvePersistedThumbnailUrl(result.thumbnailUrl ?? slot?.thumbnailUrl, url),
2495
- metadata: result.metadata,
2496
- };
2497
- if (url) {
2498
- next.url = url;
2450
+ addMessages(messages) {
2451
+ if (messages.length === 0)
2452
+ return;
2453
+ const normalized = messages.map(withNormalizedPayload);
2454
+ this._messages.update((msgs) => {
2455
+ const newMessages = new Map(msgs);
2456
+ normalized.forEach((msg) => newMessages.set(msg.id, msg));
2457
+ return newMessages;
2458
+ });
2459
+ const conversationGroups = new Map();
2460
+ normalized.forEach((msg) => {
2461
+ const existing = conversationGroups.get(msg.conversationId) || [];
2462
+ existing.push(msg.id);
2463
+ conversationGroups.set(msg.conversationId, existing);
2464
+ });
2465
+ this._conversationMessages.update((map) => {
2466
+ const newMap = new Map(map);
2467
+ for (const [conversationId, newMsgIds] of conversationGroups) {
2468
+ const existing = newMap.get(conversationId) || [];
2469
+ const idSet = new Set(existing);
2470
+ const merged = [...existing];
2471
+ for (const id of newMsgIds) {
2472
+ if (!idSet.has(id)) {
2473
+ merged.push(id);
2474
+ idSet.add(id);
2475
+ }
2476
+ }
2477
+ const sorted = merged.sort((a, b) => {
2478
+ const msgA = this._messages().get(a);
2479
+ const msgB = this._messages().get(b);
2480
+ if (!msgA || !msgB)
2481
+ return 0;
2482
+ return msgA.timestamp.getTime() - msgB.timestamp.getTime();
2483
+ });
2484
+ newMap.set(conversationId, sorted);
2485
+ }
2486
+ return newMap;
2487
+ });
2488
+ this.cleanupOldMessages();
2489
+ this.cleanupConversationMessages();
2499
2490
  }
2500
- if (i >= 0)
2501
- files[i] = next;
2502
- else
2503
- files.push(next);
2504
- return { ...base, type: 'file', files };
2505
- }
2506
- function applyFileLocalPreview(payload, localUrl, mimeType = 'application/octet-stream') {
2507
- const base = normalizeFilePayload(payload);
2508
- const files = [...base.files];
2509
- const slot = files.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
2510
- const preview = { url: localUrl, name: 'upload', mimeType };
2511
- if (slot >= 0) {
2512
- files[slot] = { ...files[slot], ...preview };
2491
+ getMessage(messageId) {
2492
+ return this._messages().get(messageId);
2513
2493
  }
2514
- else if (files.length === 0) {
2515
- files.push(preview);
2494
+ getConversationMessages(conversationId) {
2495
+ const messageIds = this._conversationMessages().get(conversationId) || [];
2496
+ return messageIds.map((id) => this._messages().get(id)).filter((msg) => msg !== undefined);
2516
2497
  }
2517
- else {
2518
- files[0] = { ...files[0], ...preview };
2498
+ updateMessage(messageId, updates) {
2499
+ const message = this._messages().get(messageId);
2500
+ if (!message)
2501
+ return;
2502
+ const merged = { ...message, ...updates };
2503
+ if (updates.payload) {
2504
+ merged.payload = normalizeMessagePayload(merged.payload);
2505
+ }
2506
+ this.addMessage(merged);
2519
2507
  }
2520
- return { ...base, type: 'file', files };
2521
- }
2522
-
2523
- function videoItemFromUpload(file, result, duration) {
2524
- const url = resolvePersistableMediaUrl(result.url);
2525
- const item = {
2526
- mediaId: result.mediaId,
2527
- mimeType: result.mimeType,
2528
- size: result.size,
2529
- thumbnailUrl: resolvePersistedThumbnailUrl(result.thumbnailUrl, url),
2530
- duration,
2531
- width: 0,
2532
- height: 0,
2533
- metadata: result.metadata,
2534
- };
2535
- if (url) {
2536
- item.url = url;
2508
+ deleteMessage(messageId) {
2509
+ const message = this._messages().get(messageId);
2510
+ if (!message)
2511
+ return;
2512
+ this._messages.update((messages) => {
2513
+ const newMessages = new Map(messages);
2514
+ newMessages.delete(messageId);
2515
+ return newMessages;
2516
+ });
2517
+ this._conversationMessages.update((map) => {
2518
+ const newMap = new Map(map);
2519
+ const existing = newMap.get(message.conversationId);
2520
+ if (existing) {
2521
+ newMap.set(message.conversationId, existing.filter((id) => id !== messageId));
2522
+ }
2523
+ return newMap;
2524
+ });
2537
2525
  }
2538
- return item;
2539
- }
2540
- function mergeVideoUploadResult(payload, result) {
2541
- const base = normalizeVideoPayload(payload);
2542
- const videos = [...base.videos];
2543
- const i = videos.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
2544
- const slot = i >= 0 ? videos[i] : undefined;
2545
- const url = resolvePersistableMediaUrl(result.url);
2546
- const next = {
2547
- ...(slot ?? { duration: 0, width: 0, height: 0 }),
2548
- mediaId: result.mediaId,
2549
- mimeType: result.mimeType,
2550
- size: result.size,
2551
- thumbnailUrl: resolvePersistedThumbnailUrl(result.thumbnailUrl ?? slot?.thumbnailUrl, url),
2552
- metadata: result.metadata,
2553
- };
2554
- if (url) {
2555
- next.url = url;
2526
+ cleanupOldMessages() {
2527
+ const totalMessages = this._messages().size;
2528
+ if (totalMessages > this.config.maxTotalMessages) {
2529
+ const allMessages = Array.from(this._messages().values()).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
2530
+ const toKeep = allMessages.slice(0, this.config.maxTotalMessages);
2531
+ const toKeepIds = new Set(toKeep.map((m) => m.id));
2532
+ this._messages.update(() => {
2533
+ const newMessages = new Map();
2534
+ toKeep.forEach((msg) => newMessages.set(msg.id, msg));
2535
+ return newMessages;
2536
+ });
2537
+ this._conversationMessages.update((map) => {
2538
+ const newMap = new Map(map);
2539
+ for (const [convId, messageIds] of newMap) {
2540
+ const filteredIds = messageIds.filter((id) => toKeepIds.has(id));
2541
+ newMap.set(convId, filteredIds);
2542
+ }
2543
+ return newMap;
2544
+ });
2545
+ }
2546
+ }
2547
+ cleanupConversationMessages() {
2548
+ const maxMessages = this.config.maxMessagesPerConversation;
2549
+ const idsToRemove = [];
2550
+ this._conversationMessages.update((map) => {
2551
+ const newMap = new Map(map);
2552
+ for (const [convId, messageIds] of newMap) {
2553
+ if (messageIds.length > maxMessages) {
2554
+ idsToRemove.push(...messageIds.slice(0, messageIds.length - maxMessages));
2555
+ newMap.set(convId, messageIds.slice(-maxMessages));
2556
+ }
2557
+ }
2558
+ return newMap;
2559
+ });
2560
+ if (idsToRemove.length > 0) {
2561
+ this._messages.update((messages) => {
2562
+ const newMessages = new Map(messages);
2563
+ idsToRemove.forEach((id) => newMessages.delete(id));
2564
+ return newMessages;
2565
+ });
2566
+ }
2556
2567
  }
2557
- if (i >= 0)
2558
- videos[i] = next;
2559
- else
2560
- videos.push(next);
2561
- return { ...base, type: 'video', videos };
2562
2568
  }
2563
- function applyVideoLocalPreview(payload, localUrl, mimeType = 'application/octet-stream') {
2564
- const base = normalizeVideoPayload(payload);
2565
- const videos = [...base.videos];
2566
- const slot = videos.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
2567
- const preview = { url: localUrl, duration: 0, width: 0, height: 0, mimeType };
2568
- if (slot >= 0) {
2569
- videos[slot] = { ...videos[slot], ...preview };
2569
+
2570
+ /**
2571
+ * Error Handler Service
2572
+ * Centralized error handling and logging
2573
+ */
2574
+ /**
2575
+ * Error Handler Service
2576
+ */
2577
+ class AXErrorHandlerService {
2578
+ constructor() {
2579
+ this.injectedConfig = inject(ERROR_HANDLER_CONFIG);
2580
+ this._errors$ = new Subject();
2581
+ this._config = {
2582
+ logToConsole: true,
2583
+ showUserMessages: true,
2584
+ autoRetry: false,
2585
+ maxRetries: 3,
2586
+ };
2587
+ /** Error stream */
2588
+ this.errors$ = this._errors$.asObservable();
2589
+ this.configure(this.injectedConfig);
2570
2590
  }
2571
- else if (videos.length === 0) {
2572
- videos.push(preview);
2591
+ /**
2592
+ * Configure error handler
2593
+ */
2594
+ configure(config) {
2595
+ Object.assign(this._config, config);
2573
2596
  }
2574
- else {
2575
- videos[0] = { ...videos[0], ...preview };
2597
+ /**
2598
+ * Handle an error
2599
+ */
2600
+ handle(error, operation, context) {
2601
+ const conversationError = this.normalizeError(error, operation, context);
2602
+ this.publish(conversationError);
2603
+ return conversationError;
2576
2604
  }
2577
- return { ...base, type: 'video', videos };
2578
- }
2579
-
2580
- const CONVERSATION_VOICE_CATALOG = 'conversation-voice';
2581
- const MB = 1024 * 1024;
2582
- const CONVERSATION_VOICE_PRESENTATION = {
2583
- icon: 'fa-light fa-microphone ax-text-green-500',
2584
- title: 'Voice message',
2585
- };
2586
- const VOICE_UTILITY = {
2587
- duration: 'duration',
2588
- formatDuration: 'formatDuration',
2589
- createLocalPreviewUrl: 'createLocalPreviewUrl',
2590
- };
2591
- function blobUrl(ctx, blob) {
2592
- if (!isPlatformBrowser(ctx.platformId)) {
2593
- return '';
2605
+ /**
2606
+ * Handle API error (same pipeline as {@link handle}, for typed API failures)
2607
+ */
2608
+ handleApiError(apiError, operation, context) {
2609
+ const conversationError = this.conversationErrorFromApi(apiError, operation, context);
2610
+ this.publish(conversationError);
2611
+ return conversationError;
2594
2612
  }
2595
- return URL.createObjectURL(blob);
2596
- }
2597
- function mediaDuration(ctx, file) {
2598
- if (!isPlatformBrowser(ctx.platformId)) {
2599
- return Promise.resolve(0);
2613
+ publish(conversationError) {
2614
+ this._errors$.next(conversationError);
2615
+ if (this._config.logToConsole) {
2616
+ this.logError(conversationError);
2617
+ }
2618
+ if (this._config.customHandler) {
2619
+ this._config.customHandler(conversationError);
2620
+ }
2600
2621
  }
2601
- return new Promise((resolve, reject) => {
2602
- const el = document.createElement('audio');
2603
- el.preload = 'metadata';
2604
- el.onloadedmetadata = () => {
2605
- URL.revokeObjectURL(el.src);
2606
- resolve(el.duration);
2607
- };
2608
- el.onerror = () => {
2609
- URL.revokeObjectURL(el.src);
2610
- reject(new Error('Failed to load audio metadata'));
2622
+ /**
2623
+ * Normalize any error to conversation error format (does not publish — use {@link handle})
2624
+ */
2625
+ normalizeError(error, operation, context) {
2626
+ if (this.isApiError(error)) {
2627
+ return this.conversationErrorFromApi(error, operation, context);
2628
+ }
2629
+ const errorObj = error;
2630
+ const message = (typeof errorObj['message'] === 'string' && errorObj['message']) ||
2631
+ (error instanceof Error ? error.message : String(error)) ||
2632
+ 'An unknown error occurred';
2633
+ const code = (typeof errorObj['code'] === 'string' && errorObj['code']) || 'UNKNOWN_ERROR';
2634
+ const statusCodeRaw = errorObj['statusCode'] ?? errorObj['status'];
2635
+ const statusCode = typeof statusCodeRaw === 'number' ? statusCodeRaw : undefined;
2636
+ return {
2637
+ code,
2638
+ message,
2639
+ severity: 'error',
2640
+ operation,
2641
+ originalError: error,
2642
+ statusCode,
2643
+ context,
2644
+ timestamp: new Date(),
2645
+ handled: false,
2646
+ recoverySuggestions: this.getDefaultRecoverySuggestions(code),
2611
2647
  };
2612
- el.src = URL.createObjectURL(file);
2613
- });
2614
- }
2615
- function conversationVoiceUtilities() {
2616
- return {
2617
- [VOICE_UTILITY.duration]: (ctx, file) => mediaDuration(ctx, file),
2618
- [VOICE_UTILITY.formatDuration]: (_ctx, seconds) => formatDuration(seconds),
2619
- [VOICE_UTILITY.createLocalPreviewUrl]: (ctx, source) => blobUrl(ctx, source),
2620
- };
2621
- }
2622
- function createConversationVoiceFileType() {
2623
- const presentation = CONVERSATION_VOICE_PRESENTATION;
2624
- return {
2625
- name: CONVERSATION_VOICE_CATALOG,
2626
- metadata: createFileTypeMetadata('conversation'),
2627
- title: presentation.title,
2628
- icon: presentation.icon,
2629
- validations: {
2630
- mimeTypes: ['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/*'],
2631
- minSize: 1,
2632
- maxSize: 50 * MB,
2633
- },
2634
- utilities: conversationVoiceUtilities(),
2635
- copy: (payload) => {
2636
- const voice = payload;
2637
- const url = voice.url?.trim() ?? '';
2638
- return {
2639
- text: '',
2640
- meta: {
2641
- kind: 'voice',
2642
- url,
2643
- duration: voice.duration,
2644
- mimeType: voice.mimeType,
2645
- },
2646
- };
2647
- },
2648
- };
2649
- }
2650
- class AXConversationVoiceFileTypeProvider extends AXFileTypeInfoProvider {
2651
- items() {
2652
- return Promise.resolve([createConversationVoiceFileType()]);
2653
- }
2654
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationVoiceFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
2655
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationVoiceFileTypeProvider, providedIn: 'root' }); }
2656
- }
2657
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationVoiceFileTypeProvider, decorators: [{
2658
- type: Injectable,
2659
- args: [{ providedIn: 'root' }]
2660
- }] });
2661
-
2662
- function mergeVoiceUploadResult(payload, result) {
2663
- return {
2664
- ...payload,
2665
- type: 'voice',
2666
- url: result.url,
2667
- mediaId: result.mediaId,
2668
- mimeType: result.mimeType,
2669
- size: result.size,
2670
- metadata: result.metadata,
2671
- };
2672
- }
2673
- function applyVoiceLocalPreview(payload, localUrl) {
2674
- return { ...payload, type: 'voice', url: localUrl };
2675
- }
2676
-
2677
- const MESSAGE_TYPE_FILE_TYPE = {
2678
- image: CONVERSATION_IMAGE_CATALOG,
2679
- video: CONVERSATION_VIDEO_CATALOG,
2680
- audio: CONVERSATION_AUDIO_CATALOG,
2681
- file: CONVERSATION_FILE_CATALOG,
2682
- voice: CONVERSATION_VOICE_CATALOG,
2683
- sticker: CONVERSATION_IMAGE_CATALOG,
2684
- };
2685
- /** Resolves {@link AXMessage.fileType} from command or message type. */
2686
- function resolveMessageFileType(type, fileType) {
2687
- return fileType ?? MESSAGE_TYPE_FILE_TYPE[type];
2688
- }
2689
- function mergeImageUploadResult(payload, result) {
2690
- const base = normalizeImagePayload(payload);
2691
- const images = [...base.images];
2692
- const i = images.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
2693
- const slot = i >= 0 ? images[i] : undefined;
2694
- const url = resolvePersistableMediaUrl(result.url);
2695
- const next = {
2696
- ...(slot ?? { width: 0, height: 0 }),
2697
- mediaId: result.mediaId,
2698
- mimeType: result.mimeType,
2699
- size: result.size,
2700
- thumbnailUrl: resolvePersistedThumbnailUrl(result.thumbnailUrl ?? slot?.thumbnailUrl, url),
2701
- metadata: result.metadata,
2702
- };
2703
- if (url) {
2704
- next.url = url;
2705
2648
  }
2706
- if (i >= 0)
2707
- images[i] = next;
2708
- else
2709
- images.push(next);
2710
- return { ...base, type: 'image', images };
2711
- }
2712
- function applyImageLocalPreview(payload, localUrl, mimeType = 'application/octet-stream') {
2713
- const base = normalizeImagePayload(payload);
2714
- const images = [...base.images];
2715
- const slot = images.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
2716
- const preview = { url: localUrl, width: 0, height: 0, mimeType };
2717
- if (slot >= 0) {
2718
- images[slot] = { ...images[slot], ...preview };
2649
+ conversationErrorFromApi(apiError, operation, context) {
2650
+ return {
2651
+ code: apiError.code,
2652
+ message: apiError.message,
2653
+ severity: this.determineSeverity(apiError.statusCode),
2654
+ operation,
2655
+ originalError: apiError,
2656
+ statusCode: apiError.statusCode,
2657
+ context,
2658
+ timestamp: apiError.timestamp ?? new Date(),
2659
+ handled: false,
2660
+ recoverySuggestions: this.getRecoverySuggestions(apiError),
2661
+ };
2719
2662
  }
2720
- else if (images.length === 0) {
2721
- images.push(preview);
2663
+ isApiError(error) {
2664
+ return (typeof error === 'object' &&
2665
+ error !== null &&
2666
+ 'code' in error &&
2667
+ 'message' in error &&
2668
+ typeof error.code === 'string' &&
2669
+ typeof error.message === 'string');
2722
2670
  }
2723
- else {
2724
- images[0] = { ...images[0], ...preview };
2671
+ /**
2672
+ * Determine severity based on status code
2673
+ */
2674
+ determineSeverity(statusCode) {
2675
+ if (!statusCode)
2676
+ return 'error';
2677
+ if (statusCode >= 500)
2678
+ return 'critical';
2679
+ if (statusCode >= 400)
2680
+ return 'error';
2681
+ if (statusCode >= 300)
2682
+ return 'warning';
2683
+ return 'info';
2725
2684
  }
2726
- return { ...base, type: 'image', images };
2727
- }
2728
- function mergeUploadResult(type, payload, result) {
2729
- switch (type) {
2730
- case 'image':
2731
- return mergeImageUploadResult(payload, result);
2732
- case 'video':
2733
- return mergeVideoUploadResult(payload, result);
2734
- case 'audio':
2735
- return mergeAudioUploadResult(payload, result);
2736
- case 'file':
2737
- return mergeFileUploadResult(payload, result);
2738
- case 'voice':
2739
- return mergeVoiceUploadResult(payload, result);
2740
- case 'sticker':
2741
- return {
2742
- ...payload,
2743
- type: 'sticker',
2744
- url: result.url,
2745
- mediaId: result.mediaId,
2746
- };
2747
- default:
2748
- return payload;
2685
+ /**
2686
+ * Get recovery suggestions based on error
2687
+ */
2688
+ getRecoverySuggestions(error) {
2689
+ const suggestions = [];
2690
+ if (error.statusCode === 401 || error.code === 'UNAUTHORIZED') {
2691
+ suggestions.push('Please log in again');
2692
+ suggestions.push('Check if your session has expired');
2693
+ }
2694
+ else if (error.statusCode === 403 || error.code === 'FORBIDDEN') {
2695
+ suggestions.push('You do not have permission for this action');
2696
+ suggestions.push('Ask your administrator for access');
2697
+ }
2698
+ else if (error.statusCode === 404 || error.code === 'NOT_FOUND') {
2699
+ suggestions.push('The requested resource was not found');
2700
+ suggestions.push('It may have been deleted or moved');
2701
+ }
2702
+ else if (error.statusCode === 429 || error.code === 'RATE_LIMIT_EXCEEDED') {
2703
+ suggestions.push('Too many requests. Please wait and try again');
2704
+ }
2705
+ else if (error.statusCode && error.statusCode >= 500) {
2706
+ suggestions.push('Server error occurred');
2707
+ suggestions.push('Please try again later');
2708
+ suggestions.push('If the problem persists, reach out to support');
2709
+ }
2710
+ else if (error.code === 'NETWORK_ERROR') {
2711
+ suggestions.push('Check your internet connection');
2712
+ suggestions.push('Try refreshing the page');
2713
+ }
2714
+ return suggestions;
2749
2715
  }
2750
- }
2751
- function applyLocalPreview(type, payload, localUrl, mimeType = 'application/octet-stream') {
2752
- switch (type) {
2753
- case 'image':
2754
- return applyImageLocalPreview(payload, localUrl, mimeType);
2755
- case 'video':
2756
- return applyVideoLocalPreview(payload, localUrl, mimeType);
2757
- case 'audio':
2758
- return applyAudioLocalPreview(payload, localUrl, mimeType);
2759
- case 'file':
2760
- return applyFileLocalPreview(payload, localUrl, mimeType);
2761
- case 'voice':
2762
- return applyVoiceLocalPreview(payload, localUrl);
2763
- case 'sticker':
2764
- return { ...payload, type: 'sticker', url: localUrl };
2765
- default:
2766
- return payload;
2716
+ /**
2717
+ * Get default recovery suggestions
2718
+ */
2719
+ getDefaultRecoverySuggestions(code) {
2720
+ const suggestions = [];
2721
+ if (code.includes('NETWORK') || code.includes('CONNECTION')) {
2722
+ suggestions.push('Check your internet connection');
2723
+ suggestions.push('Try refreshing the page');
2724
+ }
2725
+ else if (code.includes('TIMEOUT')) {
2726
+ suggestions.push('The operation took too long');
2727
+ suggestions.push('Please try again');
2728
+ }
2729
+ else {
2730
+ suggestions.push('Please try again');
2731
+ suggestions.push('If the problem persists, reach out to support');
2732
+ }
2733
+ return suggestions;
2767
2734
  }
2768
- }
2769
- function toUploaderReference$1(payload) {
2770
- switch (payload.type) {
2771
- case 'image': {
2772
- const first = payload.images[0];
2773
- return { url: first?.url, mediaId: first?.mediaId, mimeType: first?.mimeType, size: first?.size };
2735
+ /**
2736
+ * Log error to console
2737
+ */
2738
+ logError(error) {
2739
+ const isError = error.severity === 'critical' || error.severity === 'error';
2740
+ const header = `[Conversation ${error.severity.toUpperCase()}] ${error.operation}:`;
2741
+ if (isError) {
2742
+ console.error(header, error.message, error.context || '');
2774
2743
  }
2775
- case 'video': {
2776
- const first = payload.videos[0];
2777
- return { url: first?.url, mediaId: first?.mediaId, mimeType: first?.mimeType, size: first?.size };
2744
+ else {
2745
+ console.warn(header, error.message, error.context || '');
2778
2746
  }
2779
- case 'audio': {
2780
- const first = payload.audios[0];
2781
- return { url: first?.url, mediaId: first?.mediaId, mimeType: first?.mimeType, size: first?.size };
2747
+ if (error.originalError && error.severity !== 'info') {
2748
+ console.error('Original error:', error.originalError);
2782
2749
  }
2783
- case 'file': {
2784
- const first = payload.files[0];
2785
- return { url: first?.url, mediaId: first?.mediaId, mimeType: first?.mimeType, size: first?.size };
2750
+ if (error.recoverySuggestions && error.recoverySuggestions.length > 0) {
2751
+ console.info('Recovery suggestions:', error.recoverySuggestions);
2786
2752
  }
2787
- case 'voice':
2788
- case 'sticker':
2789
- return {
2790
- url: payload.url,
2791
- mediaId: payload.mediaId,
2792
- mimeType: payload.mimeType,
2793
- size: payload.size,
2794
- };
2795
- default:
2796
- return {};
2797
2753
  }
2798
- }
2799
- function createObjectUrl(platformId, blob) {
2800
- if (!isPlatformBrowser(platformId)) {
2801
- return '';
2754
+ /**
2755
+ * Get user-friendly error message
2756
+ */
2757
+ getUserFriendlyMessage(error) {
2758
+ // Map technical errors to user-friendly messages
2759
+ const messageMap = {
2760
+ NETWORK_ERROR: 'Unable to connect. Please check your internet connection.',
2761
+ UNAUTHORIZED: 'You are not authorized. Please log in again.',
2762
+ FORBIDDEN: 'You do not have permission to perform this action.',
2763
+ NOT_FOUND: 'The requested item could not be found.',
2764
+ RATE_LIMIT_EXCEEDED: 'Too many requests. Please slow down.',
2765
+ VALIDATION_ERROR: 'The provided data is invalid.',
2766
+ SERVER_ERROR: 'A server error occurred. Please try again later.',
2767
+ TIMEOUT: 'The operation timed out. Please try again.',
2768
+ };
2769
+ return messageMap[error.code] || error.message || 'An unexpected error occurred.';
2802
2770
  }
2803
- return URL.createObjectURL(blob);
2804
- }
2805
- function revokeObjectUrl(platformId, url) {
2806
- if (isPlatformBrowser(platformId) && url.startsWith('blob:')) {
2807
- URL.revokeObjectURL(url);
2771
+ /**
2772
+ * Check if error is retryable
2773
+ */
2774
+ isRetryable(error) {
2775
+ const retryableCodes = ['NETWORK_ERROR', 'TIMEOUT', 'RATE_LIMIT_EXCEEDED', 'SERVER_ERROR'];
2776
+ const retryableStatusCodes = [408, 429, 500, 502, 503, 504];
2777
+ return (retryableCodes.includes(error.code) ||
2778
+ (error.statusCode !== undefined && retryableStatusCodes.includes(error.statusCode)));
2808
2779
  }
2809
- }
2810
- async function createLocalPreviewUrl(fileService, platformId, source, messageType) {
2811
- const catalog = MESSAGE_TYPE_FILE_TYPE[messageType];
2812
- if (!catalog) {
2813
- return undefined;
2780
+ /**
2781
+ * Execute an operation with automatic retry logic
2782
+ * @param operation - The async operation to execute
2783
+ * @param operationName - Name of the operation for error tracking
2784
+ * @param context - Additional context for error handling
2785
+ * @returns Promise resolving to the operation result
2786
+ * @throws {AXConversationError} If all retries fail
2787
+ */
2788
+ async executeWithRetry(operation, operationName, context) {
2789
+ if (!this._config.autoRetry) {
2790
+ // If auto-retry is disabled, just execute once
2791
+ return operation();
2792
+ }
2793
+ const maxRetries = this._config.maxRetries ?? 3;
2794
+ let lastError;
2795
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
2796
+ try {
2797
+ return await operation();
2798
+ }
2799
+ catch (error) {
2800
+ lastError = error;
2801
+ const conversationError = this.handle(error, operationName, { ...context, attempt });
2802
+ // Don't retry if error is not retryable or if this was the last attempt
2803
+ if (!this.isRetryable(conversationError) || attempt >= maxRetries) {
2804
+ throw conversationError;
2805
+ }
2806
+ // Exponential backoff: 1s, 2s, 4s, 8s...
2807
+ const delayMs = Math.pow(2, attempt) * 1000;
2808
+ await this.delay(delayMs);
2809
+ }
2810
+ }
2811
+ throw this.handle(lastError, operationName, context);
2814
2812
  }
2815
- const fileType = await fileService.getFileType(catalog);
2816
- if (!fileType) {
2817
- return undefined;
2813
+ /**
2814
+ * Delay helper for retry backoff
2815
+ * @param ms - Milliseconds to delay
2816
+ */
2817
+ delay(ms) {
2818
+ return new Promise((resolve) => setTimeout(resolve, ms));
2818
2819
  }
2819
- const ctx = { readAsDataUrl: (f) => fileService.blobToBase64(f), platformId };
2820
- const extension = resolveFileTypeExtension(fileType, {
2821
- file: source instanceof File ? source : undefined,
2822
- mimeType: source.type,
2823
- });
2824
- const result = await runFileTypeUtility(fileType, ctx, extension, 'createLocalPreviewUrl', source);
2825
- return typeof result === 'string' ? result : undefined;
2820
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXErrorHandlerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
2821
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXErrorHandlerService, providedIn: 'root' }); }
2826
2822
  }
2823
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXErrorHandlerService, decorators: [{
2824
+ type: Injectable,
2825
+ args: [{
2826
+ providedIn: 'root',
2827
+ }]
2828
+ }], ctorParameters: () => [] });
2827
2829
 
2828
2830
  /**
2829
2831
  * AXComposerService
@@ -9097,7 +9099,7 @@ class AXConversationIndexedDbRealtimeApi extends AXRealtimeApi {
9097
9099
  // Message Events
9098
9100
  // =====================
9099
9101
  subscribeToMessages(conversationId) {
9100
- return conversationSharedStorage.messageStream$.pipe(filter((msg) => msg.conversationId === conversationId));
9102
+ return conversationSharedStorage.messageStream$.pipe(filter((msg) => !conversationId || msg.conversationId === conversationId));
9101
9103
  }
9102
9104
  subscribeToMessageUpdates(_conversationId) {
9103
9105
  return conversationSharedStorage.messageUpdates$;
@@ -15064,9 +15066,7 @@ class AXConversationService {
15064
15066
  return null;
15065
15067
  }
15066
15068
  const conversation = this.state.getConversation(id);
15067
- return conversation
15068
- ? resolveConversationForViewer(conversation, this._currentUser()?.id)
15069
- : null;
15069
+ return conversation ? resolveConversationForViewer(conversation, this._currentUser()?.id) : null;
15070
15070
  }, ...(ngDevMode ? [{ debugName: "activeConversation" }] : []));
15071
15071
  /** Messages for active conversation */
15072
15072
  this.activeMessages = computed(() => {
@@ -15870,12 +15870,8 @@ class AXConversationService {
15870
15870
  creatorId,
15871
15871
  title: isPrivate ? undefined : metadata?.['title'],
15872
15872
  description: metadata?.['description'],
15873
- avatar: isPrivate
15874
- ? undefined
15875
- : AXConversationService.normalizeOptionalString(metadata?.['avatar']),
15876
- icon: isPrivate
15877
- ? undefined
15878
- : AXConversationService.normalizeOptionalString(metadata?.['icon']),
15873
+ avatar: isPrivate ? undefined : AXConversationService.normalizeOptionalString(metadata?.['avatar']),
15874
+ icon: isPrivate ? undefined : AXConversationService.normalizeOptionalString(metadata?.['icon']),
15879
15875
  metadata: isPrivate ? undefined : metadata,
15880
15876
  });
15881
15877
  this.state.setConversation(conversation);
@@ -15924,7 +15920,9 @@ class AXConversationService {
15924
15920
  const conversation = this.state.getConversation(conversationId);
15925
15921
  const conversationTitle = conversation?.title || this.translation.translateSync('@acorex:chat.fallbacks.this-conversation');
15926
15922
  // Show confirmation dialog
15927
- const result = await this.dialogService.confirm(this.translation.translateSync('@acorex:chat.dialog.delete-conversation.title'), this.translation.translateSync('@acorex:chat.dialog.delete-conversation.message', { params: { conversationTitle } }), 'danger', 'vertical', false);
15923
+ const result = await this.dialogService.confirm(this.translation.translateSync('@acorex:chat.dialog.delete-conversation.title'), this.translation.translateSync('@acorex:chat.dialog.delete-conversation.message', {
15924
+ params: { conversationTitle },
15925
+ }), 'danger', 'vertical', false);
15928
15926
  // User cancelled
15929
15927
  if (!result.result) {
15930
15928
  return false;
@@ -20010,11 +20008,12 @@ class AXMessageListComponent {
20010
20008
  }
20011
20009
  }
20012
20010
  /**
20013
- * TrackBy function for message groups
20014
- * Tracks by date only to prevent re-rendering when messages are added
20011
+ * TrackBy function for message groups.
20012
+ * Include the latest message id so new messages in the same date group re-render.
20015
20013
  */
20016
20014
  trackMessageGroup(index, group) {
20017
- return group.date;
20015
+ const lastId = group.messages.at(-1)?.id ?? `empty-${index}`;
20016
+ return `${group.date}:${lastId}`;
20018
20017
  }
20019
20018
  /**
20020
20019
  * TrackBy function for messages