@acorex/components 21.0.3-next.21 → 21.0.3-next.22

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 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: "21.2.9", ngImport: i0, type: AXConversationAudioFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
504
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXConversationAudioFileTypeProvider, providedIn: 'root' }); }
505
+ }
506
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", 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 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: "21.2.9", ngImport: i0, type: AXConversationImageFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
680
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXConversationImageFileTypeProvider, providedIn: 'root' }); }
681
+ }
682
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", 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,2173 +734,2097 @@ 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: "21.2.9", ngImport: i0, type: AXConversationVideoFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
823
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", 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: "21.2.9", 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 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: "21.2.9", ngImport: i0, type: AXConversationFileFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
935
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXConversationFileFileTypeProvider, providedIn: 'root' }); }
936
+ }
937
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", 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 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: "21.2.9", ngImport: i0, type: AXConversationVoiceFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
1133
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXConversationVoiceFileTypeProvider, providedIn: 'root' }); }
1040
1134
  }
1135
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", 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" }] : /* istanbul ignore next */ []));
1457
- this._messages = signal(new Map(), ...(ngDevMode ? [{ debugName: "_messages" }] : /* istanbul ignore next */ []));
1458
- this._conversationMessages = signal(new Map(), ...(ngDevMode ? [{ debugName: "_conversationMessages" }] : /* istanbul ignore next */ []));
1459
- this.conversations = computed(() => {
1460
- const convMap = this._conversations();
1461
- return Array.from(convMap.values());
1462
- }, { ...(ngDevMode ? { debugName: "conversations" } : /* istanbul ignore next */ {}), equal: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]) });
1463
- }
1464
- setConversations(conversations) {
1465
- const convMap = new Map();
1466
- conversations.forEach((conv) => convMap.set(conv.id, conv));
1467
- this._conversations.set(convMap);
1468
- }
1469
- addConversations(conversations) {
1470
- this._conversations.update((existingConversations) => {
1471
- const newConversations = new Map(existingConversations);
1472
- conversations.forEach((conv) => newConversations.set(conv.id, conv));
1473
- const maxCached = this.config.maxCachedConversations;
1474
- if (newConversations.size > maxCached) {
1475
- const sorted = Array.from(newConversations.values()).sort((a, b) => (b.lastMessageAt?.getTime() ?? 0) - (a.lastMessageAt?.getTime() ?? 0));
1476
- const toKeep = sorted.slice(0, maxCached);
1477
- const cleanedMap = new Map();
1478
- toKeep.forEach((conv) => cleanedMap.set(conv.id, conv));
1479
- return cleanedMap;
1480
- }
1481
- return newConversations;
1482
- });
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;
1483
1489
  }
1484
- setConversation(conversation) {
1485
- this._conversations.update((conversations) => {
1486
- const newConversations = new Map(conversations);
1487
- newConversations.set(conversation.id, conversation);
1488
- return newConversations;
1489
- });
1490
+ /**
1491
+ * Get conversation avatar image URL.
1492
+ */
1493
+ static getConversationAvatar(conversation) {
1494
+ return AXConversationMessageUtilsService.normalizeOptionalMediaValue(conversation.avatar);
1490
1495
  }
1491
- getConversation(conversationId) {
1492
- return this._conversations().get(conversationId);
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);
1493
1504
  }
1494
- updateConversation(conversationId, updates) {
1495
- const conversation = this._conversations().get(conversationId);
1496
- if (!conversation)
1497
- return;
1498
- this.setConversation({ ...conversation, ...updates });
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');
1499
1511
  }
1500
- deleteConversation(conversationId) {
1501
- this._conversations.update((conversations) => {
1502
- const newConversations = new Map(conversations);
1503
- newConversations.delete(conversationId);
1504
- return newConversations;
1505
- });
1506
- const messageIds = this._conversationMessages().get(conversationId) || [];
1507
- this._messages.update((messages) => {
1508
- const newMessages = new Map(messages);
1509
- messageIds.forEach((id) => newMessages.delete(id));
1510
- return newMessages;
1511
- });
1512
- this._conversationMessages.update((map) => {
1513
- const newMap = new Map(map);
1514
- newMap.delete(conversationId);
1515
- return newMap;
1516
- });
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);
1517
1518
  }
1518
- updateLastMessage(message) {
1519
- this.updateConversation(message.conversationId, {
1520
- lastMessage: message,
1521
- lastMessageAt: message.timestamp,
1522
- updatedAt: message.timestamp,
1523
- });
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));
1524
1530
  }
1525
- incrementUnreadCount(conversationId) {
1526
- const conversation = this._conversations().get(conversationId);
1527
- if (!conversation)
1528
- return;
1529
- this.updateConversation(conversationId, { unreadCount: conversation.unreadCount + 1 });
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);
1530
1543
  }
1531
- resetUnreadCount(conversationId) {
1532
- this.updateConversation(conversationId, { unreadCount: 0 });
1544
+ /**
1545
+ * Type guard for text payload
1546
+ */
1547
+ static isTextPayload(payload) {
1548
+ return 'text' in payload && typeof payload.text === 'string';
1533
1549
  }
1534
- updateSettings(conversationId, settings) {
1535
- const conversation = this._conversations().get(conversationId);
1536
- if (!conversation)
1537
- return;
1538
- this.updateConversation(conversationId, {
1539
- settings: { ...conversation.settings, ...settings },
1540
- updatedAt: new Date(),
1541
- });
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}]`;
1542
1558
  }
1543
- updateTitle(conversationId, title) {
1544
- this.updateConversation(conversationId, { title, updatedAt: new Date() });
1545
- }
1546
- updateMetadata(conversationId, metadata) {
1547
- const conversation = this._conversations().get(conversationId);
1548
- if (!conversation)
1549
- return;
1550
- this.updateConversation(conversationId, {
1551
- metadata: { ...conversation.metadata, ...metadata },
1552
- updatedAt: new Date(),
1553
- });
1554
- }
1555
- updateTypingIndicator(conversationId, userId, isTyping) {
1556
- const conversation = this._conversations().get(conversationId);
1557
- if (!conversation)
1558
- return;
1559
- let typingUsers = [...conversation.status.typingUsers];
1560
- if (isTyping) {
1561
- if (!typingUsers.includes(userId)) {
1562
- typingUsers.push(userId);
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
+ };
1563
1574
  }
1564
- }
1565
- else {
1566
- typingUsers = typingUsers.filter((id) => id !== userId);
1567
- }
1568
- this.updateConversation(conversationId, {
1569
- status: {
1570
- ...conversation.status,
1571
- isTyping: typingUsers.length > 0,
1572
- typingUsers,
1573
- },
1574
- });
1575
- }
1576
- updateParticipantPresence(userId, status, lastSeen) {
1577
- this._conversations.update((conversations) => {
1578
- const newConversations = new Map(conversations);
1579
- for (const [id, conv] of conversations) {
1580
- const participant = conv.participants.find((p) => p.id === userId);
1581
- if (participant) {
1582
- const updatedConversation = {
1583
- ...conv,
1584
- participants: conv.participants.map((p) => (p.id === userId ? { ...p, status, lastSeen } : p)),
1585
- status: conv.type === 'private' ? { ...conv.status, presence: status, lastSeen } : conv.status,
1586
- };
1587
- newConversations.set(id, updatedConversation);
1588
- }
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
+ };
1589
1587
  }
1590
- return newConversations;
1591
- });
1592
- }
1593
- addMessage(message) {
1594
- const normalized = withNormalizedPayload(message);
1595
- this._messages.update((messages) => {
1596
- const newMessages = new Map(messages);
1597
- newMessages.set(normalized.id, normalized);
1598
- return newMessages;
1599
- });
1600
- this._conversationMessages.update((map) => {
1601
- const newMap = new Map(map);
1602
- const existing = newMap.get(normalized.conversationId) || [];
1603
- if (existing.includes(normalized.id)) {
1604
- return newMap;
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
+ };
1605
1598
  }
1606
- const updated = [...existing, normalized.id].sort((a, b) => {
1607
- const msgA = this._messages().get(a);
1608
- const msgB = this._messages().get(b);
1609
- if (!msgA || !msgB)
1610
- return 0;
1611
- return msgA.timestamp.getTime() - msgB.timestamp.getTime();
1612
- });
1613
- newMap.set(normalized.conversationId, updated);
1614
- return newMap;
1615
- });
1616
- }
1617
- /**
1618
- * Replace all messages for a conversation (initial page load).
1619
- */
1620
- setConversationMessages(conversationId, messages) {
1621
- const sorted = [...messages].map(withNormalizedPayload).sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
1622
- const sortedIds = sorted.map((m) => m.id);
1623
- this._messages.update((msgs) => {
1624
- const newMessages = new Map(msgs);
1625
- const previousIds = this._conversationMessages().get(conversationId) || [];
1626
- for (const id of previousIds) {
1627
- newMessages.delete(id);
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
+ };
1628
1610
  }
1629
- for (const msg of sorted) {
1630
- newMessages.set(msg.id, msg);
1611
+ case 'voice': {
1612
+ const voicePayload = message.payload;
1613
+ return {
1614
+ value: voicePayload.url || '',
1615
+ type: 'voice',
1616
+ icon: 'fa-light fa-microphone',
1617
+ };
1631
1618
  }
1632
- return newMessages;
1633
- });
1634
- this._conversationMessages.update((map) => {
1635
- const newMap = new Map(map);
1636
- newMap.set(conversationId, sortedIds);
1637
- return newMap;
1638
- });
1639
- this.cleanupOldMessages();
1640
- }
1641
- addMessages(messages) {
1642
- if (messages.length === 0)
1643
- return;
1644
- const normalized = messages.map(withNormalizedPayload);
1645
- this._messages.update((msgs) => {
1646
- const newMessages = new Map(msgs);
1647
- normalized.forEach((msg) => newMessages.set(msg.id, msg));
1648
- return newMessages;
1649
- });
1650
- const conversationGroups = new Map();
1651
- normalized.forEach((msg) => {
1652
- const existing = conversationGroups.get(msg.conversationId) || [];
1653
- existing.push(msg.id);
1654
- conversationGroups.set(msg.conversationId, existing);
1655
- });
1656
- this._conversationMessages.update((map) => {
1657
- const newMap = new Map(map);
1658
- for (const [conversationId, newMsgIds] of conversationGroups) {
1659
- const existing = newMap.get(conversationId) || [];
1660
- const idSet = new Set(existing);
1661
- const merged = [...existing];
1662
- for (const id of newMsgIds) {
1663
- if (!idSet.has(id)) {
1664
- merged.push(id);
1665
- idSet.add(id);
1666
- }
1667
- }
1668
- const sorted = merged.sort((a, b) => {
1669
- const msgA = this._messages().get(a);
1670
- const msgB = this._messages().get(b);
1671
- if (!msgA || !msgB)
1672
- return 0;
1673
- return msgA.timestamp.getTime() - msgB.timestamp.getTime();
1674
- });
1675
- newMap.set(conversationId, sorted);
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
+ };
1676
1629
  }
1677
- return newMap;
1678
- });
1679
- this.cleanupOldMessages();
1680
- this.cleanupConversationMessages();
1681
- }
1682
- getMessage(messageId) {
1683
- return this._messages().get(messageId);
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
+ }
1684
1655
  }
1685
- getConversationMessages(conversationId) {
1686
- const messageIds = this._conversationMessages().get(conversationId) || [];
1687
- return messageIds.map((id) => this._messages().get(id)).filter((msg) => msg !== undefined);
1656
+ /**
1657
+ * Check if message is from current user
1658
+ */
1659
+ static isOwnMessage(message, currentUserId) {
1660
+ return message.senderId === currentUserId;
1688
1661
  }
1689
- updateMessage(messageId, updates) {
1690
- const message = this._messages().get(messageId);
1691
- if (!message)
1692
- return;
1693
- const merged = { ...message, ...updates };
1694
- if (updates.payload) {
1695
- merged.payload = normalizeMessagePayload(merged.payload);
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';
1696
1679
  }
1697
- this.addMessage(merged);
1698
- }
1699
- deleteMessage(messageId) {
1700
- const message = this._messages().get(messageId);
1701
- if (!message)
1702
- return;
1703
- this._messages.update((messages) => {
1704
- const newMessages = new Map(messages);
1705
- newMessages.delete(messageId);
1706
- return newMessages;
1707
- });
1708
- this._conversationMessages.update((map) => {
1709
- const newMap = new Map(map);
1710
- const existing = newMap.get(message.conversationId);
1711
- if (existing) {
1712
- newMap.set(message.conversationId, existing.filter((id) => id !== messageId));
1713
- }
1714
- return newMap;
1715
- });
1716
- }
1717
- cleanupOldMessages() {
1718
- const totalMessages = this._messages().size;
1719
- if (totalMessages > this.config.maxTotalMessages) {
1720
- const allMessages = Array.from(this._messages().values()).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
1721
- const toKeep = allMessages.slice(0, this.config.maxTotalMessages);
1722
- const toKeepIds = new Set(toKeep.map((m) => m.id));
1723
- this._messages.update(() => {
1724
- const newMessages = new Map();
1725
- toKeep.forEach((msg) => newMessages.set(msg.id, msg));
1726
- return newMessages;
1727
- });
1728
- this._conversationMessages.update((map) => {
1729
- const newMap = new Map(map);
1730
- for (const [convId, messageIds] of newMap) {
1731
- const filteredIds = messageIds.filter((id) => toKeepIds.has(id));
1732
- newMap.set(convId, filteredIds);
1733
- }
1734
- return newMap;
1735
- });
1736
- }
1737
- }
1738
- cleanupConversationMessages() {
1739
- const maxMessages = this.config.maxMessagesPerConversation;
1740
- const idsToRemove = [];
1741
- this._conversationMessages.update((map) => {
1742
- const newMap = new Map(map);
1743
- for (const [convId, messageIds] of newMap) {
1744
- if (messageIds.length > maxMessages) {
1745
- idsToRemove.push(...messageIds.slice(0, messageIds.length - maxMessages));
1746
- newMap.set(convId, messageIds.slice(-maxMessages));
1747
- }
1748
- }
1749
- return newMap;
1750
- });
1751
- if (idsToRemove.length > 0) {
1752
- this._messages.update((messages) => {
1753
- const newMessages = new Map(messages);
1754
- idsToRemove.forEach((id) => newMessages.delete(id));
1755
- return newMessages;
1756
- });
1757
- }
1758
- }
1759
- }
1760
-
1761
- /**
1762
- * Error Handler Service
1763
- * Centralized error handling and logging
1764
- */
1765
- /**
1766
- * Error Handler Service
1767
- */
1768
- class AXErrorHandlerService {
1769
- constructor() {
1770
- this.injectedConfig = inject(ERROR_HANDLER_CONFIG);
1771
- this._errors$ = new Subject();
1772
- this._config = {
1773
- logToConsole: true,
1774
- showUserMessages: true,
1775
- autoRetry: false,
1776
- maxRetries: 3,
1777
- };
1778
- /** Error stream */
1779
- this.errors$ = this._errors$.asObservable();
1780
- this.configure(this.injectedConfig);
1781
1680
  }
1782
1681
  /**
1783
- * Configure error handler
1682
+ * Check if message should show avatar
1784
1683
  */
1785
- configure(config) {
1786
- Object.assign(this._config, config);
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
1691
+ }
1692
+ return true;
1693
+ }
1694
+ return false;
1787
1695
  }
1788
1696
  /**
1789
- * Handle an error
1697
+ * Group messages by sender for consecutive messages
1790
1698
  */
1791
- handle(error, operation, context) {
1792
- const conversationError = this.normalizeError(error, operation, context);
1793
- this.publish(conversationError);
1794
- return conversationError;
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;
1795
1708
  }
1796
1709
  /**
1797
- * Handle API error (same pipeline as {@link handle}, for typed API failures)
1710
+ * Get conversation status for avatar (private conversations only)
1798
1711
  */
1799
- handleApiError(apiError, operation, context) {
1800
- const conversationError = this.conversationErrorFromApi(apiError, operation, context);
1801
- this.publish(conversationError);
1802
- return conversationError;
1803
- }
1804
- publish(conversationError) {
1805
- this._errors$.next(conversationError);
1806
- if (this._config.logToConsole) {
1807
- this.logError(conversationError);
1808
- }
1809
- if (this._config.customHandler) {
1810
- this._config.customHandler(conversationError);
1712
+ static getConversationStatus(conversation) {
1713
+ if (conversation.type === 'private') {
1714
+ return conversation.status.presence;
1811
1715
  }
1716
+ return undefined;
1812
1717
  }
1813
1718
  /**
1814
- * Normalize any error to conversation error format (does not publish — use {@link handle})
1719
+ * Get typing indicator text for conversation
1815
1720
  */
1816
- normalizeError(error, operation, context) {
1817
- if (this.isApiError(error)) {
1818
- return this.conversationErrorFromApi(error, operation, context);
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');
1819
1727
  }
1820
- const errorObj = error;
1821
- const message = (typeof errorObj['message'] === 'string' && errorObj['message']) ||
1822
- (error instanceof Error ? error.message : String(error)) ||
1823
- 'An unknown error occurred';
1824
- const code = (typeof errorObj['code'] === 'string' && errorObj['code']) || 'UNKNOWN_ERROR';
1825
- const statusCodeRaw = errorObj['statusCode'] ?? errorObj['status'];
1826
- const statusCode = typeof statusCodeRaw === 'number' ? statusCodeRaw : undefined;
1827
- return {
1828
- code,
1829
- message,
1830
- severity: 'error',
1831
- operation,
1832
- originalError: error,
1833
- statusCode,
1834
- context,
1835
- timestamp: new Date(),
1836
- handled: false,
1837
- recoverySuggestions: this.getDefaultRecoverySuggestions(code),
1838
- };
1839
- }
1840
- conversationErrorFromApi(apiError, operation, context) {
1841
- return {
1842
- code: apiError.code,
1843
- message: apiError.message,
1844
- severity: this.determineSeverity(apiError.statusCode),
1845
- operation,
1846
- originalError: apiError,
1847
- statusCode: apiError.statusCode,
1848
- context,
1849
- timestamp: apiError.timestamp ?? new Date(),
1850
- handled: false,
1851
- recoverySuggestions: this.getRecoverySuggestions(apiError),
1852
- };
1853
- }
1854
- isApiError(error) {
1855
- return (typeof error === 'object' &&
1856
- error !== null &&
1857
- 'code' in error &&
1858
- 'message' in error &&
1859
- typeof error.code === 'string' &&
1860
- typeof error.message === 'string');
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 } });
1861
1735
  }
1862
1736
  /**
1863
- * Determine severity based on status code
1737
+ * Format last seen time
1864
1738
  */
1865
- determineSeverity(statusCode) {
1866
- if (!statusCode)
1867
- return 'error';
1868
- if (statusCode >= 500)
1869
- return 'critical';
1870
- if (statusCode >= 400)
1871
- return 'error';
1872
- if (statusCode >= 300)
1873
- return 'warning';
1874
- return 'info';
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();
1875
1752
  }
1876
1753
  /**
1877
- * Get recovery suggestions based on error
1754
+ * Get conversation subtitle (status or member count)
1878
1755
  */
1879
- getRecoverySuggestions(error) {
1880
- const suggestions = [];
1881
- if (error.statusCode === 401 || error.code === 'UNAUTHORIZED') {
1882
- suggestions.push('Please log in again');
1883
- suggestions.push('Check if your session has expired');
1884
- }
1885
- else if (error.statusCode === 403 || error.code === 'FORBIDDEN') {
1886
- suggestions.push('You do not have permission for this action');
1887
- suggestions.push('Ask your administrator for access');
1888
- }
1889
- else if (error.statusCode === 404 || error.code === 'NOT_FOUND') {
1890
- suggestions.push('The requested resource was not found');
1891
- suggestions.push('It may have been deleted or moved');
1756
+ static getConversationSubtitle(conversation) {
1757
+ if (conversation.status.isTyping) {
1758
+ return AXConversationMessageUtilsService.getTypingText(conversation);
1892
1759
  }
1893
- else if (error.statusCode === 429 || error.code === 'RATE_LIMIT_EXCEEDED') {
1894
- suggestions.push('Too many requests. Please wait and try again');
1760
+ switch (conversation.type) {
1761
+ case 'private':
1762
+ if (conversation.status.presence === 'online') {
1763
+ return translateSync('@acorex:chat.status.online');
1764
+ }
1765
+ if (conversation.status.lastSeen) {
1766
+ return translateSync('@acorex:chat.status.last-seen', {
1767
+ params: { value: AXConversationMessageUtilsService.formatLastSeen(conversation.status.lastSeen) },
1768
+ });
1769
+ }
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 '';
1895
1779
  }
1896
- else if (error.statusCode && error.statusCode >= 500) {
1897
- suggestions.push('Server error occurred');
1898
- suggestions.push('Please try again later');
1899
- suggestions.push('If the problem persists, reach out to support');
1900
- }
1901
- else if (error.code === 'NETWORK_ERROR') {
1902
- suggestions.push('Check your internet connection');
1903
- suggestions.push('Try refreshing the page');
1904
- }
1905
- return suggestions;
1906
- }
1907
- /**
1908
- * Get default recovery suggestions
1909
- */
1910
- getDefaultRecoverySuggestions(code) {
1911
- const suggestions = [];
1912
- if (code.includes('NETWORK') || code.includes('CONNECTION')) {
1913
- suggestions.push('Check your internet connection');
1914
- suggestions.push('Try refreshing the page');
1915
- }
1916
- else if (code.includes('TIMEOUT')) {
1917
- suggestions.push('The operation took too long');
1918
- suggestions.push('Please try again');
1919
- }
1920
- else {
1921
- suggestions.push('Please try again');
1922
- suggestions.push('If the problem persists, reach out to support');
1923
- }
1924
- return suggestions;
1925
- }
1926
- /**
1927
- * Log error to console
1928
- */
1929
- logError(error) {
1930
- const isError = error.severity === 'critical' || error.severity === 'error';
1931
- const header = `[Conversation ${error.severity.toUpperCase()}] ${error.operation}:`;
1932
- if (isError) {
1933
- console.error(header, error.message, error.context || '');
1934
- }
1935
- else {
1936
- console.warn(header, error.message, error.context || '');
1937
- }
1938
- if (error.originalError && error.severity !== 'info') {
1939
- console.error('Original error:', error.originalError);
1940
- }
1941
- if (error.recoverySuggestions && error.recoverySuggestions.length > 0) {
1942
- console.info('Recovery suggestions:', error.recoverySuggestions);
1943
- }
1944
- }
1945
- /**
1946
- * Get user-friendly error message
1947
- */
1948
- getUserFriendlyMessage(error) {
1949
- // Map technical errors to user-friendly messages
1950
- const messageMap = {
1951
- NETWORK_ERROR: 'Unable to connect. Please check your internet connection.',
1952
- UNAUTHORIZED: 'You are not authorized. Please log in again.',
1953
- FORBIDDEN: 'You do not have permission to perform this action.',
1954
- NOT_FOUND: 'The requested item could not be found.',
1955
- RATE_LIMIT_EXCEEDED: 'Too many requests. Please slow down.',
1956
- VALIDATION_ERROR: 'The provided data is invalid.',
1957
- SERVER_ERROR: 'A server error occurred. Please try again later.',
1958
- TIMEOUT: 'The operation timed out. Please try again.',
1959
- };
1960
- return messageMap[error.code] || error.message || 'An unexpected error occurred.';
1961
- }
1962
- /**
1963
- * Check if error is retryable
1964
- */
1965
- isRetryable(error) {
1966
- const retryableCodes = ['NETWORK_ERROR', 'TIMEOUT', 'RATE_LIMIT_EXCEEDED', 'SERVER_ERROR'];
1967
- const retryableStatusCodes = [408, 429, 500, 502, 503, 504];
1968
- return (retryableCodes.includes(error.code) ||
1969
- (error.statusCode !== undefined && retryableStatusCodes.includes(error.statusCode)));
1970
- }
1971
- /**
1972
- * Execute an operation with automatic retry logic
1973
- * @param operation - The async operation to execute
1974
- * @param operationName - Name of the operation for error tracking
1975
- * @param context - Additional context for error handling
1976
- * @returns Promise resolving to the operation result
1977
- * @throws {AXConversationError} If all retries fail
1978
- */
1979
- async executeWithRetry(operation, operationName, context) {
1980
- if (!this._config.autoRetry) {
1981
- // If auto-retry is disabled, just execute once
1982
- return operation();
1983
- }
1984
- const maxRetries = this._config.maxRetries ?? 3;
1985
- let lastError;
1986
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
1987
- try {
1988
- return await operation();
1989
- }
1990
- catch (error) {
1991
- lastError = error;
1992
- const conversationError = this.handle(error, operationName, { ...context, attempt });
1993
- // Don't retry if error is not retryable or if this was the last attempt
1994
- if (!this.isRetryable(conversationError) || attempt >= maxRetries) {
1995
- throw conversationError;
1996
- }
1997
- // Exponential backoff: 1s, 2s, 4s, 8s...
1998
- const delayMs = Math.pow(2, attempt) * 1000;
1999
- await this.delay(delayMs);
2000
- }
2001
- }
2002
- throw this.handle(lastError, operationName, context);
2003
- }
2004
- /**
2005
- * Delay helper for retry backoff
2006
- * @param ms - Milliseconds to delay
2007
- */
2008
- delay(ms) {
2009
- return new Promise((resolve) => setTimeout(resolve, ms));
2010
1780
  }
2011
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXErrorHandlerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
2012
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXErrorHandlerService, providedIn: 'root' }); }
2013
- }
2014
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXErrorHandlerService, decorators: [{
2015
- type: Injectable,
2016
- args: [{
2017
- providedIn: 'root',
2018
- }]
2019
- }], ctorParameters: () => [] });
2020
-
2021
- function formatFileSize(bytes) {
2022
- if (bytes < 1024)
2023
- return `${bytes} B`;
2024
- if (bytes < 1024 * 1024)
2025
- return `${(bytes / 1024).toFixed(1)} KB`;
2026
- if (bytes < 1024 * 1024 * 1024)
2027
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
2028
- return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
2029
- }
2030
- function formatDuration(seconds) {
2031
- const mins = Math.floor(seconds / 60);
2032
- const secs = Math.floor(seconds % 60);
2033
- return `${mins}:${secs.toString().padStart(2, '0')}`;
2034
1781
  }
2035
1782
 
2036
- const CONVERSATION_AUDIO_CATALOG = 'conversation-audio';
2037
- const MB$4 = 1024 * 1024;
2038
- const CONVERSATION_AUDIO_PRESENTATION = {
2039
- icon: 'fa-light fa-music text-amber-500',
2040
- title: 'Audio',
2041
- };
2042
- const AUDIO_UTILITY = {
2043
- preview: 'preview',
2044
- duration: 'duration',
2045
- formatSize: 'formatSize',
2046
- formatDuration: 'formatDuration',
2047
- createLocalPreviewUrl: 'createLocalPreviewUrl',
2048
- };
2049
- function blobUrl$4(ctx, blob) {
2050
- if (!isPlatformBrowser(ctx.platformId)) {
2051
- return '';
1783
+ /** Other participant in a private chat (excludes the current user). */
1784
+ function resolvePrivatePeerUserId(conversation, currentUserId) {
1785
+ if (conversation.type !== 'private') {
1786
+ return undefined;
2052
1787
  }
2053
- return URL.createObjectURL(blob);
1788
+ const currentId = currentUserId ?? 'current-user';
1789
+ return conversation.participants.find((participant) => participant.id !== currentId)?.id;
2054
1790
  }
2055
- function mediaDuration$2(ctx, file) {
2056
- if (!isPlatformBrowser(ctx.platformId)) {
2057
- return Promise.resolve(0);
2058
- }
2059
- return new Promise((resolve, reject) => {
2060
- const el = document.createElement('audio');
2061
- el.preload = 'metadata';
2062
- el.onloadedmetadata = () => {
2063
- URL.revokeObjectURL(el.src);
2064
- resolve(el.duration);
2065
- };
2066
- el.onerror = () => {
2067
- URL.revokeObjectURL(el.src);
2068
- reject(new Error('Failed to load audio metadata'));
2069
- };
2070
- el.src = URL.createObjectURL(file);
2071
- });
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);
2072
1794
  }
2073
- function conversationAudioUtilities() {
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),
1801
+ };
1802
+ }
1803
+ const participant = conversation?.participants.find((p) => p.id === userId);
2074
1804
  return {
2075
- [AUDIO_UTILITY.preview]: (ctx, file) => ctx.readAsDataUrl(file),
2076
- [AUDIO_UTILITY.duration]: (ctx, file) => mediaDuration$2(ctx, file),
2077
- [AUDIO_UTILITY.formatSize]: (_ctx, bytes) => formatFileSize(bytes),
2078
- [AUDIO_UTILITY.formatDuration]: (_ctx, seconds) => formatDuration(seconds),
2079
- [AUDIO_UTILITY.createLocalPreviewUrl]: (ctx, source) => blobUrl$4(ctx, source),
1805
+ name: participant?.name ?? userId,
1806
+ avatar: participant?.avatar,
1807
+ icon: participant?.icon,
2080
1808
  };
2081
1809
  }
2082
- function createConversationAudioFileType() {
2083
- const presentation = CONVERSATION_AUDIO_PRESENTATION;
1810
+ function resolveConversationAvatarDisplay(conversation, currentUserId) {
1811
+ const title = currentUserId !== undefined
1812
+ ? resolveConversationTitleForViewer(conversation, currentUserId)
1813
+ : conversation.title;
2084
1814
  return {
2085
- name: CONVERSATION_AUDIO_CATALOG,
2086
- metadata: createFileTypeMetadata('conversation'),
2087
- title: presentation.title,
2088
- icon: presentation.icon,
2089
- validations: {
2090
- mimeTypes: ['audio/*'],
2091
- minSize: 1,
2092
- maxSize: 50 * MB$4,
2093
- },
2094
- extensions: [
2095
- { name: 'mp3', title: 'MP3' },
2096
- { name: 'wav', title: 'WAV', validations: { maxSize: 30 * MB$4 } },
2097
- { name: 'ogg', title: 'OGG' },
2098
- { name: 'm4a', title: 'M4A' },
2099
- ],
2100
- utilities: conversationAudioUtilities(),
2101
- copy: (payload) => {
2102
- const audio = normalizeAudioPayload(payload);
2103
- const caption = audio.caption?.trim();
2104
- const items = audio.audios.map((item) => ({
2105
- url: item.url?.trim(),
2106
- title: item.title?.trim(),
2107
- }));
2108
- return {
2109
- text: caption ?? '',
2110
- meta: { kind: 'audio', caption, items, count: audio.audios.length },
2111
- };
2112
- },
1815
+ name: title,
1816
+ avatar: AXConversationMessageUtilsService.getConversationAvatar(conversation),
1817
+ icon: AXConversationMessageUtilsService.getConversationAvatarIcon(conversation),
2113
1818
  };
2114
1819
  }
2115
- class AXConversationAudioFileTypeProvider extends AXFileTypeInfoProvider {
2116
- items() {
2117
- return Promise.resolve([createConversationAudioFileType()]);
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;
2118
1830
  }
2119
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXConversationAudioFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
2120
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXConversationAudioFileTypeProvider, providedIn: 'root' }); }
1831
+ const peerId = resolvePrivatePeerUserId(conversation, currentUserId);
1832
+ if (!peerId) {
1833
+ return undefined;
1834
+ }
1835
+ return conversation.participants.find((participant) => participant.id === peerId);
2121
1836
  }
2122
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXConversationAudioFileTypeProvider, decorators: [{
2123
- type: Injectable,
2124
- args: [{ providedIn: 'root' }]
2125
- }] });
2126
-
2127
- function audioItemFromUpload(file, result, duration) {
2128
- const url = resolvePersistableMediaUrl(result.url);
2129
- const item = {
2130
- mediaId: result.mediaId,
2131
- mimeType: result.mimeType,
2132
- size: result.size,
2133
- duration,
2134
- title: file.name,
2135
- metadata: result.metadata,
2136
- };
2137
- if (url) {
2138
- item.url = url;
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;
2139
1844
  }
2140
- return item;
2141
- }
2142
- function mergeAudioUploadResult(payload, result) {
2143
- const base = normalizeAudioPayload(payload);
2144
- const audios = [...base.audios];
2145
- const i = audios.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
2146
- const slot = i >= 0 ? audios[i] : undefined;
2147
- const url = resolvePersistableMediaUrl(result.url);
2148
- const next = {
2149
- ...(slot ?? { duration: 0 }),
2150
- mediaId: result.mediaId,
2151
- mimeType: result.mimeType,
2152
- size: result.size,
2153
- metadata: result.metadata,
2154
- };
2155
- if (url) {
2156
- next.url = url;
1845
+ const peer = resolvePrivatePeerParticipant(conversation, currentUserId);
1846
+ if (peer?.name) {
1847
+ return peer.name;
2157
1848
  }
2158
- if (i >= 0)
2159
- audios[i] = next;
2160
- else
2161
- audios.push(next);
2162
- return { ...base, type: 'audio', audios };
2163
- }
2164
- function applyAudioLocalPreview(payload, localUrl, mimeType = 'application/octet-stream') {
2165
- const base = normalizeAudioPayload(payload);
2166
- const audios = [...base.audios];
2167
- const slot = audios.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
2168
- const preview = { url: localUrl, duration: 0, mimeType };
2169
- if (slot >= 0) {
2170
- audios[slot] = { ...audios[slot], ...preview };
1849
+ if (isGenericPrivateConversationTitle(conversation.title)) {
1850
+ return conversation.title || 'New Chat';
2171
1851
  }
2172
- else if (audios.length === 0) {
2173
- audios.push(preview);
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;
2174
1861
  }
2175
- else {
2176
- audios[0] = { ...audios[0], ...preview };
1862
+ const peer = resolvePrivatePeerParticipant(conversation, currentUserId);
1863
+ if (!peer) {
1864
+ return conversation;
2177
1865
  }
2178
- return { ...base, type: 'audio', audios };
2179
- }
2180
-
2181
- const CONVERSATION_IMAGE_CATALOG = 'conversation-image';
2182
- const MB$3 = 1024 * 1024;
2183
- const CONVERSATION_IMAGE_PRESENTATION = {
2184
- icon: 'fa-light fa-image text-purple-500',
2185
- title: 'Image',
2186
- };
2187
- const IMAGE_UTILITY = {
2188
- preview: 'preview',
2189
- formatSize: 'formatSize',
2190
- createLocalPreviewUrl: 'createLocalPreviewUrl',
2191
- };
2192
- function blobUrl$3(ctx, blob) {
2193
- if (!isPlatformBrowser(ctx.platformId)) {
2194
- return '';
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;
2195
1873
  }
2196
- return URL.createObjectURL(blob);
2197
- }
2198
- function conversationImageUtilities() {
2199
- return {
2200
- [IMAGE_UTILITY.preview]: (ctx, file) => ctx.readAsDataUrl(file),
2201
- [IMAGE_UTILITY.formatSize]: (_ctx, bytes) => formatFileSize(bytes),
2202
- [IMAGE_UTILITY.createLocalPreviewUrl]: (ctx, source) => {
2203
- const blob = source;
2204
- if (blob.type.startsWith('image/')) {
2205
- return ctx.readAsDataUrl(blob);
2206
- }
2207
- return blobUrl$3(ctx, blob);
2208
- },
2209
- };
2210
- }
2211
- function createConversationImageFileType() {
2212
- const presentation = CONVERSATION_IMAGE_PRESENTATION;
2213
1874
  return {
2214
- name: CONVERSATION_IMAGE_CATALOG,
2215
- metadata: createFileTypeMetadata('conversation'),
2216
- title: presentation.title,
2217
- icon: presentation.icon,
2218
- validations: {
2219
- mimeTypes: ['image/*'],
2220
- minSize: 1,
2221
- maxSize: 100 * MB$3,
2222
- },
2223
- extensions: [
2224
- { name: 'jpg', title: 'JPEG' },
2225
- { name: 'jpeg', title: 'JPEG', validations: { maxSize: 5 * MB$3 } },
2226
- { name: 'png', title: 'PNG' },
2227
- { name: 'gif', title: 'GIF' },
2228
- { name: 'webp', title: 'WebP' },
2229
- { name: 'svg', title: 'SVG', validations: { maxSize: 2 * MB$3 } },
2230
- ],
2231
- utilities: conversationImageUtilities(),
2232
- copy: (payload) => {
2233
- const image = normalizeImagePayload(payload);
2234
- const caption = image.caption?.trim();
2235
- const urls = image.images
2236
- .map((item) => item.url?.trim() || item.thumbnailUrl?.trim())
2237
- .filter((url) => !!url);
2238
- return {
2239
- text: caption ?? '',
2240
- meta: { kind: 'image', caption, urls, count: image.images.length },
2241
- };
2242
- },
1875
+ ...conversation,
1876
+ title,
1877
+ avatar,
1878
+ icon,
2243
1879
  };
2244
1880
  }
2245
- class AXConversationImageFileTypeProvider extends AXFileTypeInfoProvider {
2246
- items() {
2247
- return Promise.resolve([createConversationImageFileType()]);
2248
- }
2249
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXConversationImageFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
2250
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXConversationImageFileTypeProvider, providedIn: 'root' }); }
2251
- }
2252
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXConversationImageFileTypeProvider, decorators: [{
2253
- type: Injectable,
2254
- args: [{ providedIn: 'root' }]
2255
- }] });
2256
1881
 
2257
- const CONVERSATION_VIDEO_CATALOG = 'conversation-video';
2258
- const MB$2 = 1024 * 1024;
2259
- const CONVERSATION_VIDEO_PRESENTATION = {
2260
- icon: 'fa-light fa-video ax-text-blue-500',
2261
- title: 'Video',
2262
- };
2263
- const VIDEO_UTILITY = {
2264
- preview: 'preview',
2265
- duration: 'duration',
2266
- formatSize: 'formatSize',
2267
- formatDuration: 'formatDuration',
2268
- createLocalPreviewUrl: 'createLocalPreviewUrl',
2269
- };
2270
- function blobUrl$2(ctx, blob) {
2271
- if (!isPlatformBrowser(ctx.platformId)) {
2272
- return '';
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) {
1895
+ return {
1896
+ valid: false,
1897
+ error: 'Message text cannot be empty',
1898
+ errorCode: 'EMPTY_MESSAGE',
1899
+ };
2273
1900
  }
2274
- return URL.createObjectURL(blob);
2275
- }
2276
- function mediaDuration$1(ctx, file, tag) {
2277
- if (!isPlatformBrowser(ctx.platformId)) {
2278
- return Promise.resolve(0);
1901
+ // Check minimum length
1902
+ const minLength = config.minMessageLength ?? 1;
1903
+ if (text.trim().length < minLength) {
1904
+ return {
1905
+ valid: false,
1906
+ error: `Message must be at least ${minLength} character(s)`,
1907
+ errorCode: 'MESSAGE_TOO_SHORT',
1908
+ };
2279
1909
  }
2280
- return new Promise((resolve, reject) => {
2281
- const el = document.createElement(tag);
2282
- el.preload = 'metadata';
2283
- el.onloadedmetadata = () => {
2284
- URL.revokeObjectURL(el.src);
2285
- resolve(el.duration);
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',
2286
1917
  };
2287
- el.onerror = () => {
2288
- URL.revokeObjectURL(el.src);
2289
- reject(new Error(`Failed to load ${tag} metadata`));
1918
+ }
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',
2290
1932
  };
2291
- el.src = URL.createObjectURL(file);
2292
- });
1933
+ }
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
+ };
1941
+ }
1942
+ return { valid: true };
2293
1943
  }
2294
- function conversationVideoUtilities() {
2295
- return {
2296
- [VIDEO_UTILITY.preview]: (ctx, file) => ctx.readAsDataUrl(file),
2297
- [VIDEO_UTILITY.duration]: (ctx, file) => mediaDuration$1(ctx, file, 'video'),
2298
- [VIDEO_UTILITY.formatSize]: (_ctx, bytes) => formatFileSize(bytes),
2299
- [VIDEO_UTILITY.formatDuration]: (_ctx, seconds) => formatDuration(seconds),
2300
- [VIDEO_UTILITY.createLocalPreviewUrl]: async (ctx, source) => {
2301
- const blob = source;
2302
- if (blob.type.startsWith('image/')) {
2303
- return ctx.readAsDataUrl(blob);
2304
- }
2305
- return blobUrl$2(ctx, blob);
2306
- },
2307
- };
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',
1955
+ };
1956
+ }
1957
+ return { valid: true };
2308
1958
  }
2309
- function createConversationVideoFileType() {
2310
- const presentation = CONVERSATION_VIDEO_PRESENTATION;
2311
- return {
2312
- name: CONVERSATION_VIDEO_CATALOG,
2313
- metadata: createFileTypeMetadata('conversation'),
2314
- title: presentation.title,
2315
- icon: presentation.icon,
2316
- validations: {
2317
- mimeTypes: ['video/*'],
2318
- minSize: 1,
2319
- maxSize: 500 * MB$2,
2320
- },
2321
- extensions: [
2322
- { name: 'mp4', title: 'MP4' },
2323
- { name: 'webm', title: 'WebM', validations: { maxSize: 200 * MB$2 } },
2324
- { name: 'ogg', title: 'OGG' },
2325
- ],
2326
- utilities: conversationVideoUtilities(),
2327
- copy: (payload) => {
2328
- const video = normalizeVideoPayload(payload);
2329
- const caption = video.caption?.trim();
2330
- const urls = video.videos.map((item) => item.url?.trim()).filter((url) => !!url);
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
+ };
1966
+ }
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) {
2331
1971
  return {
2332
- text: caption ?? '',
2333
- meta: { kind: 'video', caption, urls, count: video.videos.length },
1972
+ valid: false,
1973
+ error: `Each ${label.toLowerCase()} attachment must have a url or mediaId`,
1974
+ errorCode: 'INVALID_MEDIA_PAYLOAD',
2334
1975
  };
2335
- },
2336
- };
2337
- }
2338
- class AXConversationVideoFileTypeProvider extends AXFileTypeInfoProvider {
2339
- items() {
2340
- return Promise.resolve([createConversationVideoFileType()]);
1976
+ }
2341
1977
  }
2342
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXConversationVideoFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
2343
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXConversationVideoFileTypeProvider, providedIn: 'root' }); }
1978
+ return { valid: true };
2344
1979
  }
2345
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXConversationVideoFileTypeProvider, decorators: [{
2346
- type: Injectable,
2347
- args: [{ providedIn: 'root' }]
2348
- }] });
2349
-
2350
- const CONVERSATION_FILE_CATALOG = 'conversation-file';
2351
- const MB$1 = 1024 * 1024;
2352
- const CONVERSATION_FILE_PRESENTATION = {
2353
- icon: 'fa-light fa-file text-neutral-500',
2354
- title: 'File',
2355
- };
2356
- const CONVERSATION_FILE_ALLOWED_MIME_TYPES = [
2357
- 'image/*',
2358
- 'video/*',
2359
- 'audio/*',
2360
- 'application/pdf',
2361
- 'application/msword',
2362
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
2363
- 'application/vnd.ms-excel',
2364
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
2365
- 'application/zip',
2366
- 'application/x-zip-compressed',
2367
- 'application/x-7z-compressed',
2368
- 'application/vnd.rar',
2369
- 'application/octet-stream',
2370
- 'text/plain',
2371
- ];
2372
- const FILE_UTILITY = {
2373
- preview: 'preview',
2374
- formatSize: 'formatSize',
2375
- createLocalPreviewUrl: 'createLocalPreviewUrl',
2376
- pickerCatalog: 'pickerCatalog',
2377
- };
2378
- function blobUrl$1(ctx, blob) {
2379
- if (!isPlatformBrowser(ctx.platformId)) {
2380
- return '';
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
+ };
2381
1993
  }
2382
- return URL.createObjectURL(blob);
2383
- }
2384
- function conversationFileUtilities() {
2385
- return {
2386
- [FILE_UTILITY.preview]: async (ctx, file) => {
2387
- const f = file;
2388
- if (f.type.startsWith('image/')) {
2389
- return ctx.readAsDataUrl(f);
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
+ };
2390
2006
  }
2391
- return undefined;
2392
- },
2393
- [FILE_UTILITY.formatSize]: (_ctx, bytes) => formatFileSize(bytes),
2394
- [FILE_UTILITY.createLocalPreviewUrl]: (ctx, source) => blobUrl$1(ctx, source),
2395
- [FILE_UTILITY.pickerCatalog]: (_ctx, file) => {
2396
- const f = file;
2397
- if (f.type.startsWith('image/'))
2398
- return CONVERSATION_IMAGE_CATALOG;
2399
- if (f.type.startsWith('video/'))
2400
- return CONVERSATION_VIDEO_CATALOG;
2401
- if (f.type.startsWith('audio/'))
2402
- return CONVERSATION_AUDIO_CATALOG;
2403
- return CONVERSATION_FILE_CATALOG;
2404
- },
2405
- };
2406
- }
2407
- function createConversationFileFileType() {
2408
- const presentation = CONVERSATION_FILE_PRESENTATION;
2409
- return {
2410
- name: CONVERSATION_FILE_CATALOG,
2411
- metadata: createFileTypeMetadata('conversation'),
2412
- title: presentation.title,
2413
- icon: presentation.icon,
2414
- validations: {
2415
- mimeTypes: [...CONVERSATION_FILE_ALLOWED_MIME_TYPES],
2416
- minSize: 1,
2417
- maxSize: 100 * MB$1,
2418
- },
2419
- extensions: [
2420
- { name: 'pdf', title: 'PDF', validations: { mimeTypes: ['application/pdf'], maxSize: 25 * MB$1 } },
2421
- { name: 'doc', title: 'Word' },
2422
- { name: 'docx', title: 'Word' },
2423
- { name: 'txt', title: 'Text', validations: { mimeTypes: ['text/plain'], maxSize: 5 * MB$1 } },
2424
- {
2425
- name: 'zip',
2426
- title: 'ZIP',
2427
- validations: {
2428
- mimeTypes: ['application/zip', 'application/x-zip-compressed', 'application/octet-stream'],
2429
- maxSize: 50 * MB$1,
2430
- },
2431
- },
2432
- ],
2433
- utilities: conversationFileUtilities(),
2434
- copy: (payload) => {
2435
- const file = normalizeFilePayload(payload);
2436
- const caption = file.caption?.trim();
2437
- const items = file.files.map((item) => {
2438
- const name = item.name?.trim();
2439
- const url = item.url?.trim();
2440
- const line = name && url ? `${name} — ${url}` : url || name;
2441
- return { name, url, line };
2442
- });
2443
- return {
2444
- text: caption ?? '',
2445
- meta: { kind: 'file', caption, items, count: file.files.length },
2446
- };
2447
- },
2448
- };
2449
- }
2450
- class AXConversationFileFileTypeProvider extends AXFileTypeInfoProvider {
2451
- items() {
2452
- return Promise.resolve([createConversationFileFileType()]);
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
+ };
2027
+ }
2028
+ break;
2029
+ }
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;
2453
2042
  }
2454
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXConversationFileFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
2455
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXConversationFileFileTypeProvider, providedIn: 'root' }); }
2043
+ return { valid: true };
2456
2044
  }
2457
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXConversationFileFileTypeProvider, decorators: [{
2458
- type: Injectable,
2459
- args: [{ providedIn: 'root' }]
2460
- }] });
2461
-
2462
- function fileItemFromUpload(file, result) {
2463
- const extension = file.name.includes('.') ? file.name.split('.').pop() : undefined;
2464
- const url = resolvePersistableMediaUrl(result.url);
2465
- const item = {
2466
- mediaId: result.mediaId,
2467
- mimeType: result.mimeType,
2468
- size: result.size,
2469
- name: file.name,
2470
- thumbnailUrl: resolvePersistedThumbnailUrl(result.thumbnailUrl, url),
2471
- extension,
2472
- metadata: result.metadata,
2473
- };
2474
- if (url) {
2475
- item.url = url;
2476
- }
2477
- return item;
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
+ };
2057
+ }
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 };
2478
2067
  }
2479
- function mergeFileUploadResult(payload, result) {
2480
- const base = normalizeFilePayload(payload);
2481
- const files = [...base.files];
2482
- const i = files.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
2483
- const slot = i >= 0 ? files[i] : undefined;
2484
- const name = slot?.name ?? result.metadata?.['fileName'] ?? 'file';
2485
- const url = resolvePersistableMediaUrl(result.url);
2486
- const next = {
2487
- ...(slot ?? { name, mimeType: result.mimeType }),
2488
- mediaId: result.mediaId,
2489
- mimeType: result.mimeType,
2490
- size: result.size,
2491
- name,
2492
- thumbnailUrl: resolvePersistedThumbnailUrl(result.thumbnailUrl ?? slot?.thumbnailUrl, url),
2493
- metadata: result.metadata,
2494
- };
2495
- if (url) {
2496
- next.url = url;
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
+ };
2497
2082
  }
2498
- if (i >= 0)
2499
- files[i] = next;
2500
- else
2501
- files.push(next);
2502
- return { ...base, type: 'file', files };
2083
+ if (userIds.length < minCount) {
2084
+ return {
2085
+ valid: false,
2086
+ error: `At least ${minCount} user(s) required`,
2087
+ errorCode: 'TOO_FEW_USERS',
2088
+ };
2089
+ }
2090
+ if (maxCount && userIds.length > maxCount) {
2091
+ return {
2092
+ valid: false,
2093
+ error: `Maximum ${maxCount} user(s) allowed`,
2094
+ errorCode: 'TOO_MANY_USERS',
2095
+ };
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 };
2503
2107
  }
2504
- function applyFileLocalPreview(payload, localUrl, mimeType = 'application/octet-stream') {
2505
- const base = normalizeFilePayload(payload);
2506
- const files = [...base.files];
2507
- const slot = files.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
2508
- const preview = { url: localUrl, name: 'upload', mimeType };
2509
- if (slot >= 0) {
2510
- files[slot] = { ...files[slot], ...preview };
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
+ };
2511
2120
  }
2512
- else if (files.length === 0) {
2513
- files.push(preview);
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
+ };
2514
2130
  }
2515
- else {
2516
- files[0] = { ...files[0], ...preview };
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
+ };
2517
2139
  }
2518
- return { ...base, type: 'file', files };
2140
+ return { valid: true };
2519
2141
  }
2520
-
2521
- function videoItemFromUpload(file, result, duration) {
2522
- const url = resolvePersistableMediaUrl(result.url);
2523
- const item = {
2524
- mediaId: result.mediaId,
2525
- mimeType: result.mimeType,
2526
- size: result.size,
2527
- thumbnailUrl: resolvePersistedThumbnailUrl(result.thumbnailUrl, url),
2528
- duration,
2529
- width: 0,
2530
- height: 0,
2531
- metadata: result.metadata,
2532
- };
2533
- if (url) {
2534
- item.url = url;
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)) {
2168
+ return {
2169
+ valid: false,
2170
+ error: 'Invalid URL protocol',
2171
+ errorCode: 'INVALID_URL',
2172
+ };
2173
+ }
2174
+ return { valid: true };
2175
+ }
2176
+ catch {
2177
+ return {
2178
+ valid: false,
2179
+ error: 'Invalid URL format',
2180
+ errorCode: 'INVALID_URL',
2181
+ };
2535
2182
  }
2536
- return item;
2537
2183
  }
2538
- function mergeVideoUploadResult(payload, result) {
2539
- const base = normalizeVideoPayload(payload);
2540
- const videos = [...base.videos];
2541
- const i = videos.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
2542
- const slot = i >= 0 ? videos[i] : undefined;
2543
- const url = resolvePersistableMediaUrl(result.url);
2544
- const next = {
2545
- ...(slot ?? { duration: 0, width: 0, height: 0 }),
2546
- mediaId: result.mediaId,
2547
- mimeType: result.mimeType,
2548
- size: result.size,
2549
- thumbnailUrl: resolvePersistedThumbnailUrl(result.thumbnailUrl ?? slot?.thumbnailUrl, url),
2550
- metadata: result.metadata,
2551
- };
2552
- if (url) {
2553
- next.url = url;
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;');
2205
+ }
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
+ };
2218
+ }
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
+ };
2225
+ }
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
+ };
2240
+ }
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 };
2249
+ }
2250
+
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
+ const timestamp = message.timestamp instanceof Date ? message.timestamp : new Date(message.timestamp);
2257
+ return { ...message, timestamp, payload: normalizeMessagePayload(message.payload) };
2258
+ }
2259
+ class ConversationState {
2260
+ constructor(config) {
2261
+ this.config = config;
2262
+ this._conversations = signal(new Map(), ...(ngDevMode ? [{ debugName: "_conversations" }] : /* istanbul ignore next */ []));
2263
+ this._messages = signal(new Map(), ...(ngDevMode ? [{ debugName: "_messages" }] : /* istanbul ignore next */ []));
2264
+ this._conversationMessages = signal(new Map(), ...(ngDevMode ? [{ debugName: "_conversationMessages" }] : /* istanbul ignore next */ []));
2265
+ this.conversations = computed(() => {
2266
+ const convMap = this._conversations();
2267
+ return Array.from(convMap.values());
2268
+ }, { ...(ngDevMode ? { debugName: "conversations" } : /* istanbul ignore next */ {}), equal: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]) });
2269
+ }
2270
+ setConversations(conversations) {
2271
+ const convMap = new Map();
2272
+ conversations.forEach((conv) => convMap.set(conv.id, conv));
2273
+ this._conversations.set(convMap);
2274
+ }
2275
+ addConversations(conversations) {
2276
+ this._conversations.update((existingConversations) => {
2277
+ const newConversations = new Map(existingConversations);
2278
+ conversations.forEach((conv) => newConversations.set(conv.id, conv));
2279
+ const maxCached = this.config.maxCachedConversations;
2280
+ if (newConversations.size > maxCached) {
2281
+ const sorted = Array.from(newConversations.values()).sort((a, b) => (b.lastMessageAt?.getTime() ?? 0) - (a.lastMessageAt?.getTime() ?? 0));
2282
+ const toKeep = sorted.slice(0, maxCached);
2283
+ const cleanedMap = new Map();
2284
+ toKeep.forEach((conv) => cleanedMap.set(conv.id, conv));
2285
+ return cleanedMap;
2286
+ }
2287
+ return newConversations;
2288
+ });
2289
+ }
2290
+ setConversation(conversation) {
2291
+ this._conversations.update((conversations) => {
2292
+ const newConversations = new Map(conversations);
2293
+ newConversations.set(conversation.id, conversation);
2294
+ return newConversations;
2295
+ });
2296
+ }
2297
+ getConversation(conversationId) {
2298
+ return this._conversations().get(conversationId);
2299
+ }
2300
+ updateConversation(conversationId, updates) {
2301
+ const conversation = this._conversations().get(conversationId);
2302
+ if (!conversation)
2303
+ return;
2304
+ this.setConversation({ ...conversation, ...updates });
2305
+ }
2306
+ deleteConversation(conversationId) {
2307
+ this._conversations.update((conversations) => {
2308
+ const newConversations = new Map(conversations);
2309
+ newConversations.delete(conversationId);
2310
+ return newConversations;
2311
+ });
2312
+ const messageIds = this._conversationMessages().get(conversationId) || [];
2313
+ this._messages.update((messages) => {
2314
+ const newMessages = new Map(messages);
2315
+ messageIds.forEach((id) => newMessages.delete(id));
2316
+ return newMessages;
2317
+ });
2318
+ this._conversationMessages.update((map) => {
2319
+ const newMap = new Map(map);
2320
+ newMap.delete(conversationId);
2321
+ return newMap;
2322
+ });
2323
+ }
2324
+ updateLastMessage(message) {
2325
+ this.updateConversation(message.conversationId, {
2326
+ lastMessage: message,
2327
+ lastMessageAt: message.timestamp,
2328
+ updatedAt: message.timestamp,
2329
+ });
2330
+ }
2331
+ incrementUnreadCount(conversationId) {
2332
+ const conversation = this._conversations().get(conversationId);
2333
+ if (!conversation)
2334
+ return;
2335
+ this.updateConversation(conversationId, { unreadCount: conversation.unreadCount + 1 });
2336
+ }
2337
+ resetUnreadCount(conversationId) {
2338
+ this.updateConversation(conversationId, { unreadCount: 0 });
2339
+ }
2340
+ updateSettings(conversationId, settings) {
2341
+ const conversation = this._conversations().get(conversationId);
2342
+ if (!conversation)
2343
+ return;
2344
+ this.updateConversation(conversationId, {
2345
+ settings: { ...conversation.settings, ...settings },
2346
+ updatedAt: new Date(),
2347
+ });
2348
+ }
2349
+ updateTitle(conversationId, title) {
2350
+ this.updateConversation(conversationId, { title, updatedAt: new Date() });
2351
+ }
2352
+ updateMetadata(conversationId, metadata) {
2353
+ const conversation = this._conversations().get(conversationId);
2354
+ if (!conversation)
2355
+ return;
2356
+ this.updateConversation(conversationId, {
2357
+ metadata: { ...conversation.metadata, ...metadata },
2358
+ updatedAt: new Date(),
2359
+ });
2360
+ }
2361
+ updateTypingIndicator(conversationId, userId, isTyping) {
2362
+ const conversation = this._conversations().get(conversationId);
2363
+ if (!conversation)
2364
+ return;
2365
+ let typingUsers = [...conversation.status.typingUsers];
2366
+ if (isTyping) {
2367
+ if (!typingUsers.includes(userId)) {
2368
+ typingUsers.push(userId);
2369
+ }
2370
+ }
2371
+ else {
2372
+ typingUsers = typingUsers.filter((id) => id !== userId);
2373
+ }
2374
+ this.updateConversation(conversationId, {
2375
+ status: {
2376
+ ...conversation.status,
2377
+ isTyping: typingUsers.length > 0,
2378
+ typingUsers,
2379
+ },
2380
+ });
2381
+ }
2382
+ updateParticipantPresence(userId, status, lastSeen) {
2383
+ this._conversations.update((conversations) => {
2384
+ const newConversations = new Map(conversations);
2385
+ for (const [id, conv] of conversations) {
2386
+ const participant = conv.participants.find((p) => p.id === userId);
2387
+ if (participant) {
2388
+ const updatedConversation = {
2389
+ ...conv,
2390
+ participants: conv.participants.map((p) => (p.id === userId ? { ...p, status, lastSeen } : p)),
2391
+ status: conv.type === 'private' ? { ...conv.status, presence: status, lastSeen } : conv.status,
2392
+ };
2393
+ newConversations.set(id, updatedConversation);
2394
+ }
2395
+ }
2396
+ return newConversations;
2397
+ });
2398
+ }
2399
+ addMessage(message) {
2400
+ const normalized = withNormalizedPayload(message);
2401
+ this._messages.update((messages) => {
2402
+ const newMessages = new Map(messages);
2403
+ newMessages.set(normalized.id, normalized);
2404
+ return newMessages;
2405
+ });
2406
+ this._conversationMessages.update((map) => {
2407
+ const newMap = new Map(map);
2408
+ const existing = newMap.get(normalized.conversationId) || [];
2409
+ if (existing.includes(normalized.id)) {
2410
+ return newMap;
2411
+ }
2412
+ const updated = [...existing, normalized.id].sort((a, b) => {
2413
+ const msgA = this._messages().get(a);
2414
+ const msgB = this._messages().get(b);
2415
+ if (!msgA || !msgB)
2416
+ return 0;
2417
+ return msgA.timestamp.getTime() - msgB.timestamp.getTime();
2418
+ });
2419
+ newMap.set(normalized.conversationId, updated);
2420
+ return newMap;
2421
+ });
2422
+ }
2423
+ /**
2424
+ * Replace all messages for a conversation (initial page load).
2425
+ */
2426
+ setConversationMessages(conversationId, messages) {
2427
+ const sorted = [...messages]
2428
+ .map(withNormalizedPayload)
2429
+ .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
2430
+ const sortedIds = sorted.map((m) => m.id);
2431
+ this._messages.update((msgs) => {
2432
+ const newMessages = new Map(msgs);
2433
+ const previousIds = this._conversationMessages().get(conversationId) || [];
2434
+ for (const id of previousIds) {
2435
+ newMessages.delete(id);
2436
+ }
2437
+ for (const msg of sorted) {
2438
+ newMessages.set(msg.id, msg);
2439
+ }
2440
+ return newMessages;
2441
+ });
2442
+ this._conversationMessages.update((map) => {
2443
+ const newMap = new Map(map);
2444
+ newMap.set(conversationId, sortedIds);
2445
+ return newMap;
2446
+ });
2447
+ this.cleanupOldMessages();
2448
+ }
2449
+ addMessages(messages) {
2450
+ if (messages.length === 0)
2451
+ return;
2452
+ const normalized = messages.map(withNormalizedPayload);
2453
+ this._messages.update((msgs) => {
2454
+ const newMessages = new Map(msgs);
2455
+ normalized.forEach((msg) => newMessages.set(msg.id, msg));
2456
+ return newMessages;
2457
+ });
2458
+ const conversationGroups = new Map();
2459
+ normalized.forEach((msg) => {
2460
+ const existing = conversationGroups.get(msg.conversationId) || [];
2461
+ existing.push(msg.id);
2462
+ conversationGroups.set(msg.conversationId, existing);
2463
+ });
2464
+ this._conversationMessages.update((map) => {
2465
+ const newMap = new Map(map);
2466
+ for (const [conversationId, newMsgIds] of conversationGroups) {
2467
+ const existing = newMap.get(conversationId) || [];
2468
+ const idSet = new Set(existing);
2469
+ const merged = [...existing];
2470
+ for (const id of newMsgIds) {
2471
+ if (!idSet.has(id)) {
2472
+ merged.push(id);
2473
+ idSet.add(id);
2474
+ }
2475
+ }
2476
+ const sorted = merged.sort((a, b) => {
2477
+ const msgA = this._messages().get(a);
2478
+ const msgB = this._messages().get(b);
2479
+ if (!msgA || !msgB)
2480
+ return 0;
2481
+ return msgA.timestamp.getTime() - msgB.timestamp.getTime();
2482
+ });
2483
+ newMap.set(conversationId, sorted);
2484
+ }
2485
+ return newMap;
2486
+ });
2487
+ this.cleanupOldMessages();
2488
+ this.cleanupConversationMessages();
2489
+ }
2490
+ getMessage(messageId) {
2491
+ return this._messages().get(messageId);
2492
+ }
2493
+ getConversationMessages(conversationId) {
2494
+ const messageIds = this._conversationMessages().get(conversationId) || [];
2495
+ return messageIds.map((id) => this._messages().get(id)).filter((msg) => msg !== undefined);
2496
+ }
2497
+ updateMessage(messageId, updates) {
2498
+ const message = this._messages().get(messageId);
2499
+ if (!message)
2500
+ return;
2501
+ const merged = { ...message, ...updates };
2502
+ if (updates.payload) {
2503
+ merged.payload = normalizeMessagePayload(merged.payload);
2504
+ }
2505
+ this.addMessage(merged);
2506
+ }
2507
+ deleteMessage(messageId) {
2508
+ const message = this._messages().get(messageId);
2509
+ if (!message)
2510
+ return;
2511
+ this._messages.update((messages) => {
2512
+ const newMessages = new Map(messages);
2513
+ newMessages.delete(messageId);
2514
+ return newMessages;
2515
+ });
2516
+ this._conversationMessages.update((map) => {
2517
+ const newMap = new Map(map);
2518
+ const existing = newMap.get(message.conversationId);
2519
+ if (existing) {
2520
+ newMap.set(message.conversationId, existing.filter((id) => id !== messageId));
2521
+ }
2522
+ return newMap;
2523
+ });
2524
+ }
2525
+ cleanupOldMessages() {
2526
+ const totalMessages = this._messages().size;
2527
+ if (totalMessages > this.config.maxTotalMessages) {
2528
+ const allMessages = Array.from(this._messages().values()).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
2529
+ const toKeep = allMessages.slice(0, this.config.maxTotalMessages);
2530
+ const toKeepIds = new Set(toKeep.map((m) => m.id));
2531
+ this._messages.update(() => {
2532
+ const newMessages = new Map();
2533
+ toKeep.forEach((msg) => newMessages.set(msg.id, msg));
2534
+ return newMessages;
2535
+ });
2536
+ this._conversationMessages.update((map) => {
2537
+ const newMap = new Map(map);
2538
+ for (const [convId, messageIds] of newMap) {
2539
+ const filteredIds = messageIds.filter((id) => toKeepIds.has(id));
2540
+ newMap.set(convId, filteredIds);
2541
+ }
2542
+ return newMap;
2543
+ });
2544
+ }
2545
+ }
2546
+ cleanupConversationMessages() {
2547
+ const maxMessages = this.config.maxMessagesPerConversation;
2548
+ const idsToRemove = [];
2549
+ this._conversationMessages.update((map) => {
2550
+ const newMap = new Map(map);
2551
+ for (const [convId, messageIds] of newMap) {
2552
+ if (messageIds.length > maxMessages) {
2553
+ idsToRemove.push(...messageIds.slice(0, messageIds.length - maxMessages));
2554
+ newMap.set(convId, messageIds.slice(-maxMessages));
2555
+ }
2556
+ }
2557
+ return newMap;
2558
+ });
2559
+ if (idsToRemove.length > 0) {
2560
+ this._messages.update((messages) => {
2561
+ const newMessages = new Map(messages);
2562
+ idsToRemove.forEach((id) => newMessages.delete(id));
2563
+ return newMessages;
2564
+ });
2565
+ }
2554
2566
  }
2555
- if (i >= 0)
2556
- videos[i] = next;
2557
- else
2558
- videos.push(next);
2559
- return { ...base, type: 'video', videos };
2560
2567
  }
2561
- function applyVideoLocalPreview(payload, localUrl, mimeType = 'application/octet-stream') {
2562
- const base = normalizeVideoPayload(payload);
2563
- const videos = [...base.videos];
2564
- const slot = videos.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
2565
- const preview = { url: localUrl, duration: 0, width: 0, height: 0, mimeType };
2566
- if (slot >= 0) {
2567
- videos[slot] = { ...videos[slot], ...preview };
2568
+
2569
+ /**
2570
+ * Error Handler Service
2571
+ * Centralized error handling and logging
2572
+ */
2573
+ /**
2574
+ * Error Handler Service
2575
+ */
2576
+ class AXErrorHandlerService {
2577
+ constructor() {
2578
+ this.injectedConfig = inject(ERROR_HANDLER_CONFIG);
2579
+ this._errors$ = new Subject();
2580
+ this._config = {
2581
+ logToConsole: true,
2582
+ showUserMessages: true,
2583
+ autoRetry: false,
2584
+ maxRetries: 3,
2585
+ };
2586
+ /** Error stream */
2587
+ this.errors$ = this._errors$.asObservable();
2588
+ this.configure(this.injectedConfig);
2568
2589
  }
2569
- else if (videos.length === 0) {
2570
- videos.push(preview);
2590
+ /**
2591
+ * Configure error handler
2592
+ */
2593
+ configure(config) {
2594
+ Object.assign(this._config, config);
2571
2595
  }
2572
- else {
2573
- videos[0] = { ...videos[0], ...preview };
2596
+ /**
2597
+ * Handle an error
2598
+ */
2599
+ handle(error, operation, context) {
2600
+ const conversationError = this.normalizeError(error, operation, context);
2601
+ this.publish(conversationError);
2602
+ return conversationError;
2574
2603
  }
2575
- return { ...base, type: 'video', videos };
2576
- }
2577
-
2578
- const CONVERSATION_VOICE_CATALOG = 'conversation-voice';
2579
- const MB = 1024 * 1024;
2580
- const CONVERSATION_VOICE_PRESENTATION = {
2581
- icon: 'fa-light fa-microphone text-green-500',
2582
- title: 'Voice message',
2583
- };
2584
- const VOICE_UTILITY = {
2585
- duration: 'duration',
2586
- formatDuration: 'formatDuration',
2587
- createLocalPreviewUrl: 'createLocalPreviewUrl',
2588
- };
2589
- function blobUrl(ctx, blob) {
2590
- if (!isPlatformBrowser(ctx.platformId)) {
2591
- return '';
2604
+ /**
2605
+ * Handle API error (same pipeline as {@link handle}, for typed API failures)
2606
+ */
2607
+ handleApiError(apiError, operation, context) {
2608
+ const conversationError = this.conversationErrorFromApi(apiError, operation, context);
2609
+ this.publish(conversationError);
2610
+ return conversationError;
2592
2611
  }
2593
- return URL.createObjectURL(blob);
2594
- }
2595
- function mediaDuration(ctx, file) {
2596
- if (!isPlatformBrowser(ctx.platformId)) {
2597
- return Promise.resolve(0);
2612
+ publish(conversationError) {
2613
+ this._errors$.next(conversationError);
2614
+ if (this._config.logToConsole) {
2615
+ this.logError(conversationError);
2616
+ }
2617
+ if (this._config.customHandler) {
2618
+ this._config.customHandler(conversationError);
2619
+ }
2598
2620
  }
2599
- return new Promise((resolve, reject) => {
2600
- const el = document.createElement('audio');
2601
- el.preload = 'metadata';
2602
- el.onloadedmetadata = () => {
2603
- URL.revokeObjectURL(el.src);
2604
- resolve(el.duration);
2605
- };
2606
- el.onerror = () => {
2607
- URL.revokeObjectURL(el.src);
2608
- reject(new Error('Failed to load audio metadata'));
2621
+ /**
2622
+ * Normalize any error to conversation error format (does not publish — use {@link handle})
2623
+ */
2624
+ normalizeError(error, operation, context) {
2625
+ if (this.isApiError(error)) {
2626
+ return this.conversationErrorFromApi(error, operation, context);
2627
+ }
2628
+ const errorObj = error;
2629
+ const message = (typeof errorObj['message'] === 'string' && errorObj['message']) ||
2630
+ (error instanceof Error ? error.message : String(error)) ||
2631
+ 'An unknown error occurred';
2632
+ const code = (typeof errorObj['code'] === 'string' && errorObj['code']) || 'UNKNOWN_ERROR';
2633
+ const statusCodeRaw = errorObj['statusCode'] ?? errorObj['status'];
2634
+ const statusCode = typeof statusCodeRaw === 'number' ? statusCodeRaw : undefined;
2635
+ return {
2636
+ code,
2637
+ message,
2638
+ severity: 'error',
2639
+ operation,
2640
+ originalError: error,
2641
+ statusCode,
2642
+ context,
2643
+ timestamp: new Date(),
2644
+ handled: false,
2645
+ recoverySuggestions: this.getDefaultRecoverySuggestions(code),
2609
2646
  };
2610
- el.src = URL.createObjectURL(file);
2611
- });
2612
- }
2613
- function conversationVoiceUtilities() {
2614
- return {
2615
- [VOICE_UTILITY.duration]: (ctx, file) => mediaDuration(ctx, file),
2616
- [VOICE_UTILITY.formatDuration]: (_ctx, seconds) => formatDuration(seconds),
2617
- [VOICE_UTILITY.createLocalPreviewUrl]: (ctx, source) => blobUrl(ctx, source),
2618
- };
2619
- }
2620
- function createConversationVoiceFileType() {
2621
- const presentation = CONVERSATION_VOICE_PRESENTATION;
2622
- return {
2623
- name: CONVERSATION_VOICE_CATALOG,
2624
- metadata: createFileTypeMetadata('conversation'),
2625
- title: presentation.title,
2626
- icon: presentation.icon,
2627
- validations: {
2628
- mimeTypes: ['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/*'],
2629
- minSize: 1,
2630
- maxSize: 50 * MB,
2631
- },
2632
- utilities: conversationVoiceUtilities(),
2633
- copy: (payload) => {
2634
- const voice = payload;
2635
- const url = voice.url?.trim() ?? '';
2636
- return {
2637
- text: '',
2638
- meta: {
2639
- kind: 'voice',
2640
- url,
2641
- duration: voice.duration,
2642
- mimeType: voice.mimeType,
2643
- },
2644
- };
2645
- },
2646
- };
2647
- }
2648
- class AXConversationVoiceFileTypeProvider extends AXFileTypeInfoProvider {
2649
- items() {
2650
- return Promise.resolve([createConversationVoiceFileType()]);
2651
- }
2652
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXConversationVoiceFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
2653
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXConversationVoiceFileTypeProvider, providedIn: 'root' }); }
2654
- }
2655
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXConversationVoiceFileTypeProvider, decorators: [{
2656
- type: Injectable,
2657
- args: [{ providedIn: 'root' }]
2658
- }] });
2659
-
2660
- function mergeVoiceUploadResult(payload, result) {
2661
- return {
2662
- ...payload,
2663
- type: 'voice',
2664
- url: result.url,
2665
- mediaId: result.mediaId,
2666
- mimeType: result.mimeType,
2667
- size: result.size,
2668
- metadata: result.metadata,
2669
- };
2670
- }
2671
- function applyVoiceLocalPreview(payload, localUrl) {
2672
- return { ...payload, type: 'voice', url: localUrl };
2673
- }
2674
-
2675
- const MESSAGE_TYPE_FILE_TYPE = {
2676
- image: CONVERSATION_IMAGE_CATALOG,
2677
- video: CONVERSATION_VIDEO_CATALOG,
2678
- audio: CONVERSATION_AUDIO_CATALOG,
2679
- file: CONVERSATION_FILE_CATALOG,
2680
- voice: CONVERSATION_VOICE_CATALOG,
2681
- sticker: CONVERSATION_IMAGE_CATALOG,
2682
- };
2683
- /** Resolves {@link AXMessage.fileType} from command or message type. */
2684
- function resolveMessageFileType(type, fileType) {
2685
- return fileType ?? MESSAGE_TYPE_FILE_TYPE[type];
2686
- }
2687
- function mergeImageUploadResult(payload, result) {
2688
- const base = normalizeImagePayload(payload);
2689
- const images = [...base.images];
2690
- const i = images.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
2691
- const slot = i >= 0 ? images[i] : undefined;
2692
- const url = resolvePersistableMediaUrl(result.url);
2693
- const next = {
2694
- ...(slot ?? { width: 0, height: 0 }),
2695
- mediaId: result.mediaId,
2696
- mimeType: result.mimeType,
2697
- size: result.size,
2698
- thumbnailUrl: resolvePersistedThumbnailUrl(result.thumbnailUrl ?? slot?.thumbnailUrl, url),
2699
- metadata: result.metadata,
2700
- };
2701
- if (url) {
2702
- next.url = url;
2703
2647
  }
2704
- if (i >= 0)
2705
- images[i] = next;
2706
- else
2707
- images.push(next);
2708
- return { ...base, type: 'image', images };
2709
- }
2710
- function applyImageLocalPreview(payload, localUrl, mimeType = 'application/octet-stream') {
2711
- const base = normalizeImagePayload(payload);
2712
- const images = [...base.images];
2713
- const slot = images.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
2714
- const preview = { url: localUrl, width: 0, height: 0, mimeType };
2715
- if (slot >= 0) {
2716
- images[slot] = { ...images[slot], ...preview };
2648
+ conversationErrorFromApi(apiError, operation, context) {
2649
+ return {
2650
+ code: apiError.code,
2651
+ message: apiError.message,
2652
+ severity: this.determineSeverity(apiError.statusCode),
2653
+ operation,
2654
+ originalError: apiError,
2655
+ statusCode: apiError.statusCode,
2656
+ context,
2657
+ timestamp: apiError.timestamp ?? new Date(),
2658
+ handled: false,
2659
+ recoverySuggestions: this.getRecoverySuggestions(apiError),
2660
+ };
2717
2661
  }
2718
- else if (images.length === 0) {
2719
- images.push(preview);
2662
+ isApiError(error) {
2663
+ return (typeof error === 'object' &&
2664
+ error !== null &&
2665
+ 'code' in error &&
2666
+ 'message' in error &&
2667
+ typeof error.code === 'string' &&
2668
+ typeof error.message === 'string');
2720
2669
  }
2721
- else {
2722
- images[0] = { ...images[0], ...preview };
2670
+ /**
2671
+ * Determine severity based on status code
2672
+ */
2673
+ determineSeverity(statusCode) {
2674
+ if (!statusCode)
2675
+ return 'error';
2676
+ if (statusCode >= 500)
2677
+ return 'critical';
2678
+ if (statusCode >= 400)
2679
+ return 'error';
2680
+ if (statusCode >= 300)
2681
+ return 'warning';
2682
+ return 'info';
2723
2683
  }
2724
- return { ...base, type: 'image', images };
2725
- }
2726
- function mergeUploadResult(type, payload, result) {
2727
- switch (type) {
2728
- case 'image':
2729
- return mergeImageUploadResult(payload, result);
2730
- case 'video':
2731
- return mergeVideoUploadResult(payload, result);
2732
- case 'audio':
2733
- return mergeAudioUploadResult(payload, result);
2734
- case 'file':
2735
- return mergeFileUploadResult(payload, result);
2736
- case 'voice':
2737
- return mergeVoiceUploadResult(payload, result);
2738
- case 'sticker':
2739
- return {
2740
- ...payload,
2741
- type: 'sticker',
2742
- url: result.url,
2743
- mediaId: result.mediaId,
2744
- };
2745
- default:
2746
- return payload;
2684
+ /**
2685
+ * Get recovery suggestions based on error
2686
+ */
2687
+ getRecoverySuggestions(error) {
2688
+ const suggestions = [];
2689
+ if (error.statusCode === 401 || error.code === 'UNAUTHORIZED') {
2690
+ suggestions.push('Please log in again');
2691
+ suggestions.push('Check if your session has expired');
2692
+ }
2693
+ else if (error.statusCode === 403 || error.code === 'FORBIDDEN') {
2694
+ suggestions.push('You do not have permission for this action');
2695
+ suggestions.push('Ask your administrator for access');
2696
+ }
2697
+ else if (error.statusCode === 404 || error.code === 'NOT_FOUND') {
2698
+ suggestions.push('The requested resource was not found');
2699
+ suggestions.push('It may have been deleted or moved');
2700
+ }
2701
+ else if (error.statusCode === 429 || error.code === 'RATE_LIMIT_EXCEEDED') {
2702
+ suggestions.push('Too many requests. Please wait and try again');
2703
+ }
2704
+ else if (error.statusCode && error.statusCode >= 500) {
2705
+ suggestions.push('Server error occurred');
2706
+ suggestions.push('Please try again later');
2707
+ suggestions.push('If the problem persists, reach out to support');
2708
+ }
2709
+ else if (error.code === 'NETWORK_ERROR') {
2710
+ suggestions.push('Check your internet connection');
2711
+ suggestions.push('Try refreshing the page');
2712
+ }
2713
+ return suggestions;
2747
2714
  }
2748
- }
2749
- function applyLocalPreview(type, payload, localUrl, mimeType = 'application/octet-stream') {
2750
- switch (type) {
2751
- case 'image':
2752
- return applyImageLocalPreview(payload, localUrl, mimeType);
2753
- case 'video':
2754
- return applyVideoLocalPreview(payload, localUrl, mimeType);
2755
- case 'audio':
2756
- return applyAudioLocalPreview(payload, localUrl, mimeType);
2757
- case 'file':
2758
- return applyFileLocalPreview(payload, localUrl, mimeType);
2759
- case 'voice':
2760
- return applyVoiceLocalPreview(payload, localUrl);
2761
- case 'sticker':
2762
- return { ...payload, type: 'sticker', url: localUrl };
2763
- default:
2764
- return payload;
2715
+ /**
2716
+ * Get default recovery suggestions
2717
+ */
2718
+ getDefaultRecoverySuggestions(code) {
2719
+ const suggestions = [];
2720
+ if (code.includes('NETWORK') || code.includes('CONNECTION')) {
2721
+ suggestions.push('Check your internet connection');
2722
+ suggestions.push('Try refreshing the page');
2723
+ }
2724
+ else if (code.includes('TIMEOUT')) {
2725
+ suggestions.push('The operation took too long');
2726
+ suggestions.push('Please try again');
2727
+ }
2728
+ else {
2729
+ suggestions.push('Please try again');
2730
+ suggestions.push('If the problem persists, reach out to support');
2731
+ }
2732
+ return suggestions;
2765
2733
  }
2766
- }
2767
- function toUploaderReference$1(payload) {
2768
- switch (payload.type) {
2769
- case 'image': {
2770
- const first = payload.images[0];
2771
- return { url: first?.url, mediaId: first?.mediaId, mimeType: first?.mimeType, size: first?.size };
2734
+ /**
2735
+ * Log error to console
2736
+ */
2737
+ logError(error) {
2738
+ const isError = error.severity === 'critical' || error.severity === 'error';
2739
+ const header = `[Conversation ${error.severity.toUpperCase()}] ${error.operation}:`;
2740
+ if (isError) {
2741
+ console.error(header, error.message, error.context || '');
2772
2742
  }
2773
- case 'video': {
2774
- const first = payload.videos[0];
2775
- return { url: first?.url, mediaId: first?.mediaId, mimeType: first?.mimeType, size: first?.size };
2743
+ else {
2744
+ console.warn(header, error.message, error.context || '');
2776
2745
  }
2777
- case 'audio': {
2778
- const first = payload.audios[0];
2779
- return { url: first?.url, mediaId: first?.mediaId, mimeType: first?.mimeType, size: first?.size };
2746
+ if (error.originalError && error.severity !== 'info') {
2747
+ console.error('Original error:', error.originalError);
2780
2748
  }
2781
- case 'file': {
2782
- const first = payload.files[0];
2783
- return { url: first?.url, mediaId: first?.mediaId, mimeType: first?.mimeType, size: first?.size };
2749
+ if (error.recoverySuggestions && error.recoverySuggestions.length > 0) {
2750
+ console.info('Recovery suggestions:', error.recoverySuggestions);
2784
2751
  }
2785
- case 'voice':
2786
- case 'sticker':
2787
- return {
2788
- url: payload.url,
2789
- mediaId: payload.mediaId,
2790
- mimeType: payload.mimeType,
2791
- size: payload.size,
2792
- };
2793
- default:
2794
- return {};
2795
2752
  }
2796
- }
2797
- function createObjectUrl(platformId, blob) {
2798
- if (!isPlatformBrowser(platformId)) {
2799
- return '';
2753
+ /**
2754
+ * Get user-friendly error message
2755
+ */
2756
+ getUserFriendlyMessage(error) {
2757
+ // Map technical errors to user-friendly messages
2758
+ const messageMap = {
2759
+ NETWORK_ERROR: 'Unable to connect. Please check your internet connection.',
2760
+ UNAUTHORIZED: 'You are not authorized. Please log in again.',
2761
+ FORBIDDEN: 'You do not have permission to perform this action.',
2762
+ NOT_FOUND: 'The requested item could not be found.',
2763
+ RATE_LIMIT_EXCEEDED: 'Too many requests. Please slow down.',
2764
+ VALIDATION_ERROR: 'The provided data is invalid.',
2765
+ SERVER_ERROR: 'A server error occurred. Please try again later.',
2766
+ TIMEOUT: 'The operation timed out. Please try again.',
2767
+ };
2768
+ return messageMap[error.code] || error.message || 'An unexpected error occurred.';
2800
2769
  }
2801
- return URL.createObjectURL(blob);
2802
- }
2803
- function revokeObjectUrl(platformId, url) {
2804
- if (isPlatformBrowser(platformId) && url.startsWith('blob:')) {
2805
- URL.revokeObjectURL(url);
2770
+ /**
2771
+ * Check if error is retryable
2772
+ */
2773
+ isRetryable(error) {
2774
+ const retryableCodes = ['NETWORK_ERROR', 'TIMEOUT', 'RATE_LIMIT_EXCEEDED', 'SERVER_ERROR'];
2775
+ const retryableStatusCodes = [408, 429, 500, 502, 503, 504];
2776
+ return (retryableCodes.includes(error.code) ||
2777
+ (error.statusCode !== undefined && retryableStatusCodes.includes(error.statusCode)));
2806
2778
  }
2807
- }
2808
- async function createLocalPreviewUrl(fileService, platformId, source, messageType) {
2809
- const catalog = MESSAGE_TYPE_FILE_TYPE[messageType];
2810
- if (!catalog) {
2811
- return undefined;
2779
+ /**
2780
+ * Execute an operation with automatic retry logic
2781
+ * @param operation - The async operation to execute
2782
+ * @param operationName - Name of the operation for error tracking
2783
+ * @param context - Additional context for error handling
2784
+ * @returns Promise resolving to the operation result
2785
+ * @throws {AXConversationError} If all retries fail
2786
+ */
2787
+ async executeWithRetry(operation, operationName, context) {
2788
+ if (!this._config.autoRetry) {
2789
+ // If auto-retry is disabled, just execute once
2790
+ return operation();
2791
+ }
2792
+ const maxRetries = this._config.maxRetries ?? 3;
2793
+ let lastError;
2794
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
2795
+ try {
2796
+ return await operation();
2797
+ }
2798
+ catch (error) {
2799
+ lastError = error;
2800
+ const conversationError = this.handle(error, operationName, { ...context, attempt });
2801
+ // Don't retry if error is not retryable or if this was the last attempt
2802
+ if (!this.isRetryable(conversationError) || attempt >= maxRetries) {
2803
+ throw conversationError;
2804
+ }
2805
+ // Exponential backoff: 1s, 2s, 4s, 8s...
2806
+ const delayMs = Math.pow(2, attempt) * 1000;
2807
+ await this.delay(delayMs);
2808
+ }
2809
+ }
2810
+ throw this.handle(lastError, operationName, context);
2812
2811
  }
2813
- const fileType = await fileService.getFileType(catalog);
2814
- if (!fileType) {
2815
- return undefined;
2812
+ /**
2813
+ * Delay helper for retry backoff
2814
+ * @param ms - Milliseconds to delay
2815
+ */
2816
+ delay(ms) {
2817
+ return new Promise((resolve) => setTimeout(resolve, ms));
2816
2818
  }
2817
- const ctx = { readAsDataUrl: (f) => fileService.blobToBase64(f), platformId };
2818
- const extension = resolveFileTypeExtension(fileType, {
2819
- file: source instanceof File ? source : undefined,
2820
- mimeType: source.type,
2821
- });
2822
- const result = await runFileTypeUtility(fileType, ctx, extension, 'createLocalPreviewUrl', source);
2823
- return typeof result === 'string' ? result : undefined;
2819
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXErrorHandlerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
2820
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXErrorHandlerService, providedIn: 'root' }); }
2824
2821
  }
2822
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: AXErrorHandlerService, decorators: [{
2823
+ type: Injectable,
2824
+ args: [{
2825
+ providedIn: 'root',
2826
+ }]
2827
+ }], ctorParameters: () => [] });
2825
2828
 
2826
2829
  /**
2827
2830
  * AXComposerService
@@ -9111,7 +9114,7 @@ class AXConversationIndexedDbRealtimeApi extends AXRealtimeApi {
9111
9114
  // Message Events
9112
9115
  // =====================
9113
9116
  subscribeToMessages(conversationId) {
9114
- return conversationSharedStorage.messageStream$.pipe(filter((msg) => msg.conversationId === conversationId));
9117
+ return conversationSharedStorage.messageStream$.pipe(filter((msg) => !conversationId || msg.conversationId === conversationId));
9115
9118
  }
9116
9119
  subscribeToMessageUpdates(_conversationId) {
9117
9120
  return conversationSharedStorage.messageUpdates$;
@@ -15078,9 +15081,7 @@ class AXConversationService {
15078
15081
  return null;
15079
15082
  }
15080
15083
  const conversation = this.state.getConversation(id);
15081
- return conversation
15082
- ? resolveConversationForViewer(conversation, this._currentUser()?.id)
15083
- : null;
15084
+ return conversation ? resolveConversationForViewer(conversation, this._currentUser()?.id) : null;
15084
15085
  }, ...(ngDevMode ? [{ debugName: "activeConversation" }] : /* istanbul ignore next */ []));
15085
15086
  /** Messages for active conversation */
15086
15087
  this.activeMessages = computed(() => {
@@ -15215,6 +15216,18 @@ class AXConversationService {
15215
15216
  .subscribe((conversation) => {
15216
15217
  this.handleConversationUpdate(conversation);
15217
15218
  });
15219
+ // Global message stream — backs message list even when the backend only pushes
15220
+ // conversation.lastMessage updates or when per-conversation subscribe races loadMessages.
15221
+ this.realtimeApi
15222
+ .subscribeToMessages('')
15223
+ .pipe(takeUntil(this.destroy$), catchError((error) => {
15224
+ this.errorHandler.handle(error, 'subscribeToMessages');
15225
+ return EMPTY;
15226
+ }))
15227
+ .subscribe((message) => {
15228
+ this.handleNewMessage(message);
15229
+ this._messageReceived$.next(message);
15230
+ });
15218
15231
  }
15219
15232
  /**
15220
15233
  * Load conversations from server
@@ -15288,19 +15301,6 @@ class AXConversationService {
15288
15301
  finally {
15289
15302
  this._loadingActiveMessages.set(false);
15290
15303
  }
15291
- // Subscribe to new messages with error handling (only if realtime is available)
15292
- if (this.realtimeApi) {
15293
- this.realtimeApi
15294
- .subscribeToMessages(conversationId)
15295
- .pipe(takeUntil(this._conversationSwitch$), takeUntil(this.destroy$), catchError((error) => {
15296
- this.errorHandler.handle(error, 'subscribeToMessages', { conversationId });
15297
- return EMPTY;
15298
- }))
15299
- .subscribe((message) => {
15300
- this.handleNewMessage(message);
15301
- this._messageReceived$.next(message);
15302
- });
15303
- }
15304
15304
  }
15305
15305
  catch (error) {
15306
15306
  this.errorHandler.handle(error, 'selectConversation', { conversationId });
@@ -15699,11 +15699,15 @@ class AXConversationService {
15699
15699
  * Handle new message received
15700
15700
  */
15701
15701
  handleNewMessage(message) {
15702
+ if (this.state.getMessage(message.id)) {
15703
+ return;
15704
+ }
15702
15705
  this.state.addMessage(message);
15703
15706
  this.state.updateLastMessage(message);
15704
15707
  const currentId = this._currentUser()?.id ?? 'current-user';
15705
15708
  const isFromOtherUser = message.senderId !== currentId;
15706
- if (isFromOtherUser) {
15709
+ const isActiveConversation = message.conversationId === this._activeConversationId();
15710
+ if (isFromOtherUser && !isActiveConversation) {
15707
15711
  this.state.incrementUnreadCount(message.conversationId);
15708
15712
  }
15709
15713
  }
@@ -15711,6 +15715,13 @@ class AXConversationService {
15711
15715
  * Handle message update
15712
15716
  */
15713
15717
  handleMessageUpdate(message) {
15718
+ const existing = this.state.getMessage(message.id);
15719
+ if (!existing) {
15720
+ if (message.conversationId === this._activeConversationId()) {
15721
+ this.handleNewMessage(message);
15722
+ }
15723
+ return;
15724
+ }
15714
15725
  this.state.updateMessage(message.id, message);
15715
15726
  }
15716
15727
  /**
@@ -15759,6 +15770,12 @@ class AXConversationService {
15759
15770
  * Updates conversation metadata including unread count, last message, etc.
15760
15771
  */
15761
15772
  handleConversationUpdate(conversation) {
15773
+ const lastMessage = conversation.lastMessage;
15774
+ if (lastMessage &&
15775
+ conversation.id === this._activeConversationId() &&
15776
+ !this.state.getMessage(lastMessage.id)) {
15777
+ this.state.addMessage(lastMessage);
15778
+ }
15762
15779
  this.state.setConversation(conversation);
15763
15780
  }
15764
15781
  /**
@@ -15884,12 +15901,8 @@ class AXConversationService {
15884
15901
  creatorId,
15885
15902
  title: isPrivate ? undefined : metadata?.['title'],
15886
15903
  description: metadata?.['description'],
15887
- avatar: isPrivate
15888
- ? undefined
15889
- : AXConversationService.normalizeOptionalString(metadata?.['avatar']),
15890
- icon: isPrivate
15891
- ? undefined
15892
- : AXConversationService.normalizeOptionalString(metadata?.['icon']),
15904
+ avatar: isPrivate ? undefined : AXConversationService.normalizeOptionalString(metadata?.['avatar']),
15905
+ icon: isPrivate ? undefined : AXConversationService.normalizeOptionalString(metadata?.['icon']),
15893
15906
  metadata: isPrivate ? undefined : metadata,
15894
15907
  });
15895
15908
  this.state.setConversation(conversation);
@@ -15938,7 +15951,9 @@ class AXConversationService {
15938
15951
  const conversation = this.state.getConversation(conversationId);
15939
15952
  const conversationTitle = conversation?.title || this.translation.translateSync('@acorex:chat.fallbacks.this-conversation');
15940
15953
  // Show confirmation dialog
15941
- 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);
15954
+ const result = await this.dialogService.confirm(this.translation.translateSync('@acorex:chat.dialog.delete-conversation.title'), this.translation.translateSync('@acorex:chat.dialog.delete-conversation.message', {
15955
+ params: { conversationTitle },
15956
+ }), 'danger', 'vertical', false);
15942
15957
  // User cancelled
15943
15958
  if (!result.result) {
15944
15959
  return false;
@@ -20026,11 +20041,12 @@ class AXMessageListComponent {
20026
20041
  }
20027
20042
  }
20028
20043
  /**
20029
- * TrackBy function for message groups
20030
- * Tracks by date only to prevent re-rendering when messages are added
20044
+ * TrackBy function for message groups.
20045
+ * Include the latest message id so new messages in the same date group re-render.
20031
20046
  */
20032
20047
  trackMessageGroup(index, group) {
20033
- return group.date;
20048
+ const lastId = group.messages.at(-1)?.id ?? `empty-${index}`;
20049
+ return `${group.date}:${lastId}`;
20034
20050
  }
20035
20051
  /**
20036
20052
  * TrackBy function for messages