@acorex/components 20.8.27 → 20.8.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -3,14 +3,14 @@ import { AXTabsComponent, AXTabItemComponent } from '@acorex/components/tabs';
|
|
|
3
3
|
import * as i1$4 from '@angular/common';
|
|
4
4
|
import { isPlatformBrowser, AsyncPipe, CommonModule, NgComponentOutlet, DOCUMENT } from '@angular/common';
|
|
5
5
|
import * as i0 from '@angular/core';
|
|
6
|
-
import { InjectionToken, signal, computed, inject,
|
|
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
|
|
309
|
+
function sanitizeImageItem(item) {
|
|
467
310
|
const url = resolvePersistableMediaUrl(item.url);
|
|
468
|
-
|
|
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
|
-
|
|
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['
|
|
320
|
+
const existing = raw['images'];
|
|
476
321
|
if (Array.isArray(existing) && existing.length > 0) {
|
|
477
322
|
return {
|
|
478
|
-
type: '
|
|
479
|
-
|
|
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: '
|
|
332
|
+
type: 'image',
|
|
488
333
|
caption: payload.caption,
|
|
489
|
-
|
|
334
|
+
images: [
|
|
490
335
|
{
|
|
491
336
|
...(url ? { url } : {}),
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
|
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
|
-
|
|
516
|
-
if (url === item.url && thumbnailUrl === item.thumbnailUrl) {
|
|
366
|
+
if (url === item.url) {
|
|
517
367
|
return item;
|
|
518
368
|
}
|
|
519
|
-
return { ...item, url
|
|
369
|
+
return { ...item, url };
|
|
520
370
|
}
|
|
521
|
-
function
|
|
371
|
+
function normalizeAudioPayload(payload) {
|
|
522
372
|
const raw = loose$2(payload);
|
|
523
|
-
const existing = raw['
|
|
373
|
+
const existing = raw['audios'];
|
|
524
374
|
if (Array.isArray(existing) && existing.length > 0) {
|
|
525
375
|
return {
|
|
526
|
-
type: '
|
|
527
|
-
|
|
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: '
|
|
385
|
+
type: 'audio',
|
|
536
386
|
caption: payload.caption,
|
|
537
|
-
|
|
387
|
+
audios: [
|
|
538
388
|
{
|
|
539
389
|
...(url ? { url } : {}),
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
545
|
-
|
|
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: '
|
|
402
|
+
return { type: 'audio', audios: [], caption: payload.caption };
|
|
551
403
|
}
|
|
552
404
|
|
|
553
|
-
function
|
|
554
|
-
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
|
|
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
|
|
560
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
419
|
+
|
|
420
|
+
const CONVERSATION_AUDIO_CATALOG = 'conversation-audio';
|
|
421
|
+
const MB$4 = 1024 * 1024;
|
|
422
|
+
const CONVERSATION_AUDIO_PRESENTATION = {
|
|
423
|
+
icon: 'fa-light fa-music ax-text-amber-500',
|
|
424
|
+
title: 'Audio',
|
|
425
|
+
};
|
|
426
|
+
const AUDIO_UTILITY = {
|
|
427
|
+
preview: 'preview',
|
|
428
|
+
duration: 'duration',
|
|
429
|
+
formatSize: 'formatSize',
|
|
430
|
+
formatDuration: 'formatDuration',
|
|
431
|
+
createLocalPreviewUrl: 'createLocalPreviewUrl',
|
|
432
|
+
};
|
|
433
|
+
function blobUrl$4(ctx, blob) {
|
|
434
|
+
if (!isPlatformBrowser(ctx.platformId)) {
|
|
435
|
+
return '';
|
|
567
436
|
}
|
|
568
|
-
return
|
|
437
|
+
return URL.createObjectURL(blob);
|
|
569
438
|
}
|
|
570
|
-
|
|
571
|
-
|
|
439
|
+
function mediaDuration$2(ctx, file) {
|
|
440
|
+
if (!isPlatformBrowser(ctx.platformId)) {
|
|
441
|
+
return Promise.resolve(0);
|
|
442
|
+
}
|
|
443
|
+
return new Promise((resolve, reject) => {
|
|
444
|
+
const el = document.createElement('audio');
|
|
445
|
+
el.preload = 'metadata';
|
|
446
|
+
el.onloadedmetadata = () => {
|
|
447
|
+
URL.revokeObjectURL(el.src);
|
|
448
|
+
resolve(el.duration);
|
|
449
|
+
};
|
|
450
|
+
el.onerror = () => {
|
|
451
|
+
URL.revokeObjectURL(el.src);
|
|
452
|
+
reject(new Error('Failed to load audio metadata'));
|
|
453
|
+
};
|
|
454
|
+
el.src = URL.createObjectURL(file);
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
function conversationAudioUtilities() {
|
|
458
|
+
return {
|
|
459
|
+
[AUDIO_UTILITY.preview]: (ctx, file) => ctx.readAsDataUrl(file),
|
|
460
|
+
[AUDIO_UTILITY.duration]: (ctx, file) => mediaDuration$2(ctx, file),
|
|
461
|
+
[AUDIO_UTILITY.formatSize]: (_ctx, bytes) => formatFileSize(bytes),
|
|
462
|
+
[AUDIO_UTILITY.formatDuration]: (_ctx, seconds) => formatDuration(seconds),
|
|
463
|
+
[AUDIO_UTILITY.createLocalPreviewUrl]: (ctx, source) => blobUrl$4(ctx, source),
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
function createConversationAudioFileType() {
|
|
467
|
+
const presentation = CONVERSATION_AUDIO_PRESENTATION;
|
|
468
|
+
return {
|
|
469
|
+
name: CONVERSATION_AUDIO_CATALOG,
|
|
470
|
+
metadata: createFileTypeMetadata('conversation'),
|
|
471
|
+
title: presentation.title,
|
|
472
|
+
icon: presentation.icon,
|
|
473
|
+
validations: {
|
|
474
|
+
mimeTypes: ['audio/*'],
|
|
475
|
+
minSize: 1,
|
|
476
|
+
maxSize: 50 * MB$4,
|
|
477
|
+
},
|
|
478
|
+
extensions: [
|
|
479
|
+
{ name: 'mp3', title: 'MP3' },
|
|
480
|
+
{ name: 'wav', title: 'WAV', validations: { maxSize: 30 * MB$4 } },
|
|
481
|
+
{ name: 'ogg', title: 'OGG' },
|
|
482
|
+
{ name: 'm4a', title: 'M4A' },
|
|
483
|
+
],
|
|
484
|
+
utilities: conversationAudioUtilities(),
|
|
485
|
+
copy: (payload) => {
|
|
486
|
+
const audio = normalizeAudioPayload(payload);
|
|
487
|
+
const caption = audio.caption?.trim();
|
|
488
|
+
const items = audio.audios.map((item) => ({
|
|
489
|
+
url: item.url?.trim(),
|
|
490
|
+
title: item.title?.trim(),
|
|
491
|
+
}));
|
|
492
|
+
return {
|
|
493
|
+
text: caption ?? '',
|
|
494
|
+
meta: { kind: 'audio', caption, items, count: audio.audios.length },
|
|
495
|
+
};
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
class AXConversationAudioFileTypeProvider extends AXFileTypeInfoProvider {
|
|
500
|
+
items() {
|
|
501
|
+
return Promise.resolve([createConversationAudioFileType()]);
|
|
502
|
+
}
|
|
503
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationAudioFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
504
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationAudioFileTypeProvider, providedIn: 'root' }); }
|
|
505
|
+
}
|
|
506
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationAudioFileTypeProvider, decorators: [{
|
|
507
|
+
type: Injectable,
|
|
508
|
+
args: [{ providedIn: 'root' }]
|
|
509
|
+
}] });
|
|
510
|
+
|
|
511
|
+
function audioItemFromUpload(file, result, duration) {
|
|
512
|
+
const url = resolvePersistableMediaUrl(result.url);
|
|
513
|
+
const item = {
|
|
514
|
+
mediaId: result.mediaId,
|
|
515
|
+
mimeType: result.mimeType,
|
|
516
|
+
size: result.size,
|
|
517
|
+
duration,
|
|
518
|
+
title: file.name,
|
|
519
|
+
metadata: result.metadata,
|
|
520
|
+
};
|
|
521
|
+
if (url) {
|
|
522
|
+
item.url = url;
|
|
523
|
+
}
|
|
524
|
+
return item;
|
|
525
|
+
}
|
|
526
|
+
function mergeAudioUploadResult(payload, result) {
|
|
527
|
+
const base = normalizeAudioPayload(payload);
|
|
528
|
+
const audios = [...base.audios];
|
|
529
|
+
const i = audios.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
|
|
530
|
+
const slot = i >= 0 ? audios[i] : undefined;
|
|
531
|
+
const url = resolvePersistableMediaUrl(result.url);
|
|
532
|
+
const next = {
|
|
533
|
+
...(slot ?? { duration: 0 }),
|
|
534
|
+
mediaId: result.mediaId,
|
|
535
|
+
mimeType: result.mimeType,
|
|
536
|
+
size: result.size,
|
|
537
|
+
metadata: result.metadata,
|
|
538
|
+
};
|
|
539
|
+
if (url) {
|
|
540
|
+
next.url = url;
|
|
541
|
+
}
|
|
542
|
+
if (i >= 0)
|
|
543
|
+
audios[i] = next;
|
|
544
|
+
else
|
|
545
|
+
audios.push(next);
|
|
546
|
+
return { ...base, type: 'audio', audios };
|
|
547
|
+
}
|
|
548
|
+
function applyAudioLocalPreview(payload, localUrl, mimeType = 'application/octet-stream') {
|
|
549
|
+
const base = normalizeAudioPayload(payload);
|
|
550
|
+
const audios = [...base.audios];
|
|
551
|
+
const slot = audios.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
|
|
552
|
+
const preview = { url: localUrl, duration: 0, mimeType };
|
|
553
|
+
if (slot >= 0) {
|
|
554
|
+
audios[slot] = { ...audios[slot], ...preview };
|
|
555
|
+
}
|
|
556
|
+
else if (audios.length === 0) {
|
|
557
|
+
audios.push(preview);
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
audios[0] = { ...audios[0], ...preview };
|
|
561
|
+
}
|
|
562
|
+
return { ...base, type: 'audio', audios };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function loose$1(payload) {
|
|
566
|
+
return payload;
|
|
567
|
+
}
|
|
568
|
+
function str$1(value) {
|
|
569
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
570
|
+
}
|
|
571
|
+
function sanitizeFileItem(item) {
|
|
572
|
+
const url = resolvePersistableMediaUrl(item.url);
|
|
573
|
+
const thumbnailUrl = resolvePersistedThumbnailUrl(item.thumbnailUrl, url);
|
|
574
|
+
if (url === item.url && thumbnailUrl === item.thumbnailUrl) {
|
|
575
|
+
return item;
|
|
576
|
+
}
|
|
577
|
+
return { ...item, url, thumbnailUrl };
|
|
578
|
+
}
|
|
579
|
+
function normalizeFilePayload(payload) {
|
|
572
580
|
const raw = loose$1(payload);
|
|
573
|
-
const existing = raw['
|
|
581
|
+
const existing = raw['files'];
|
|
574
582
|
if (Array.isArray(existing) && existing.length > 0) {
|
|
575
583
|
return {
|
|
576
|
-
type: '
|
|
577
|
-
|
|
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: '
|
|
593
|
+
type: 'file',
|
|
586
594
|
caption: payload.caption,
|
|
587
|
-
|
|
595
|
+
files: [
|
|
588
596
|
{
|
|
589
597
|
...(url ? { url } : {}),
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
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: '
|
|
608
|
+
return { type: 'file', files: [], caption: payload.caption };
|
|
602
609
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
610
|
+
|
|
611
|
+
const CONVERSATION_IMAGE_CATALOG = 'conversation-image';
|
|
612
|
+
const MB$3 = 1024 * 1024;
|
|
613
|
+
const CONVERSATION_IMAGE_PRESENTATION = {
|
|
614
|
+
icon: 'fa-light fa-image ax-text-purple-500',
|
|
615
|
+
title: 'Image',
|
|
616
|
+
};
|
|
617
|
+
const IMAGE_UTILITY = {
|
|
618
|
+
preview: 'preview',
|
|
619
|
+
formatSize: 'formatSize',
|
|
620
|
+
createLocalPreviewUrl: 'createLocalPreviewUrl',
|
|
621
|
+
};
|
|
622
|
+
function blobUrl$3(ctx, blob) {
|
|
623
|
+
if (!isPlatformBrowser(ctx.platformId)) {
|
|
624
|
+
return '';
|
|
625
|
+
}
|
|
626
|
+
return URL.createObjectURL(blob);
|
|
627
|
+
}
|
|
628
|
+
function conversationImageUtilities() {
|
|
629
|
+
return {
|
|
630
|
+
[IMAGE_UTILITY.preview]: (ctx, file) => ctx.readAsDataUrl(file),
|
|
631
|
+
[IMAGE_UTILITY.formatSize]: (_ctx, bytes) => formatFileSize(bytes),
|
|
632
|
+
[IMAGE_UTILITY.createLocalPreviewUrl]: (ctx, source) => {
|
|
633
|
+
const blob = source;
|
|
634
|
+
if (blob.type.startsWith('image/')) {
|
|
635
|
+
return ctx.readAsDataUrl(blob);
|
|
636
|
+
}
|
|
637
|
+
return blobUrl$3(ctx, blob);
|
|
638
|
+
},
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
function createConversationImageFileType() {
|
|
642
|
+
const presentation = CONVERSATION_IMAGE_PRESENTATION;
|
|
643
|
+
return {
|
|
644
|
+
name: CONVERSATION_IMAGE_CATALOG,
|
|
645
|
+
metadata: createFileTypeMetadata('conversation'),
|
|
646
|
+
title: presentation.title,
|
|
647
|
+
icon: presentation.icon,
|
|
648
|
+
validations: {
|
|
649
|
+
mimeTypes: ['image/*'],
|
|
650
|
+
minSize: 1,
|
|
651
|
+
maxSize: 100 * MB$3,
|
|
652
|
+
},
|
|
653
|
+
extensions: [
|
|
654
|
+
{ name: 'jpg', title: 'JPEG' },
|
|
655
|
+
{ name: 'jpeg', title: 'JPEG', validations: { maxSize: 5 * MB$3 } },
|
|
656
|
+
{ name: 'png', title: 'PNG' },
|
|
657
|
+
{ name: 'gif', title: 'GIF' },
|
|
658
|
+
{ name: 'webp', title: 'WebP' },
|
|
659
|
+
{ name: 'svg', title: 'SVG', validations: { maxSize: 2 * MB$3 } },
|
|
660
|
+
],
|
|
661
|
+
utilities: conversationImageUtilities(),
|
|
662
|
+
copy: (payload) => {
|
|
663
|
+
const image = normalizeImagePayload(payload);
|
|
664
|
+
const caption = image.caption?.trim();
|
|
665
|
+
const urls = image.images
|
|
666
|
+
.map((item) => item.url?.trim() || item.thumbnailUrl?.trim())
|
|
667
|
+
.filter((url) => !!url);
|
|
668
|
+
return {
|
|
669
|
+
text: caption ?? '',
|
|
670
|
+
meta: { kind: 'image', caption, urls, count: image.images.length },
|
|
671
|
+
};
|
|
672
|
+
},
|
|
673
|
+
};
|
|
606
674
|
}
|
|
675
|
+
class AXConversationImageFileTypeProvider extends AXFileTypeInfoProvider {
|
|
676
|
+
items() {
|
|
677
|
+
return Promise.resolve([createConversationImageFileType()]);
|
|
678
|
+
}
|
|
679
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationImageFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
680
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationImageFileTypeProvider, providedIn: 'root' }); }
|
|
681
|
+
}
|
|
682
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationImageFileTypeProvider, decorators: [{
|
|
683
|
+
type: Injectable,
|
|
684
|
+
args: [{ providedIn: 'root' }]
|
|
685
|
+
}] });
|
|
607
686
|
|
|
608
687
|
function loose(payload) {
|
|
609
688
|
return payload;
|
|
@@ -655,2175 +734,2098 @@ function normalizeVideoPayload(payload) {
|
|
|
655
734
|
return { type: 'video', videos: [], caption: payload.caption };
|
|
656
735
|
}
|
|
657
736
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
|
754
|
+
return URL.createObjectURL(blob);
|
|
711
755
|
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
-
|
|
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
|
-
|
|
771
|
+
el.src = URL.createObjectURL(file);
|
|
772
|
+
});
|
|
749
773
|
}
|
|
750
|
-
function
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
|
|
764
|
-
|
|
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
|
-
|
|
822
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationVideoFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
823
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationVideoFileTypeProvider, providedIn: 'root' }); }
|
|
770
824
|
}
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
825
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationVideoFileTypeProvider, decorators: [{
|
|
826
|
+
type: Injectable,
|
|
827
|
+
args: [{ providedIn: 'root' }]
|
|
828
|
+
}] });
|
|
829
|
+
|
|
830
|
+
const CONVERSATION_FILE_CATALOG = 'conversation-file';
|
|
831
|
+
const MB$1 = 1024 * 1024;
|
|
832
|
+
const CONVERSATION_FILE_PRESENTATION = {
|
|
833
|
+
icon: 'fa-light fa-file ax-text-neutral-500',
|
|
834
|
+
title: 'File',
|
|
835
|
+
};
|
|
836
|
+
const CONVERSATION_FILE_ALLOWED_MIME_TYPES = [
|
|
837
|
+
'image/*',
|
|
838
|
+
'video/*',
|
|
839
|
+
'audio/*',
|
|
840
|
+
'application/pdf',
|
|
841
|
+
'application/msword',
|
|
842
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
843
|
+
'application/vnd.ms-excel',
|
|
844
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
845
|
+
'application/zip',
|
|
846
|
+
'application/x-zip-compressed',
|
|
847
|
+
'application/x-7z-compressed',
|
|
848
|
+
'application/vnd.rar',
|
|
849
|
+
'application/octet-stream',
|
|
850
|
+
'text/plain',
|
|
851
|
+
];
|
|
852
|
+
const FILE_UTILITY = {
|
|
853
|
+
preview: 'preview',
|
|
854
|
+
formatSize: 'formatSize',
|
|
855
|
+
createLocalPreviewUrl: 'createLocalPreviewUrl',
|
|
856
|
+
pickerCatalog: 'pickerCatalog',
|
|
857
|
+
};
|
|
858
|
+
function blobUrl$1(ctx, blob) {
|
|
859
|
+
if (!isPlatformBrowser(ctx.platformId)) {
|
|
860
|
+
return '';
|
|
784
861
|
}
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
if (
|
|
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
|
-
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
return
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
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
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
};
|
|
934
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationFileFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
935
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationFileFileTypeProvider, providedIn: 'root' }); }
|
|
936
|
+
}
|
|
937
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationFileFileTypeProvider, decorators: [{
|
|
938
|
+
type: Injectable,
|
|
939
|
+
args: [{ providedIn: 'root' }]
|
|
940
|
+
}] });
|
|
941
|
+
|
|
942
|
+
function fileItemFromUpload(file, result) {
|
|
943
|
+
const extension = file.name.includes('.') ? file.name.split('.').pop() : undefined;
|
|
944
|
+
const url = resolvePersistableMediaUrl(result.url);
|
|
945
|
+
const item = {
|
|
946
|
+
mediaId: result.mediaId,
|
|
947
|
+
mimeType: result.mimeType,
|
|
948
|
+
size: result.size,
|
|
949
|
+
name: file.name,
|
|
950
|
+
thumbnailUrl: resolvePersistedThumbnailUrl(result.thumbnailUrl, url),
|
|
951
|
+
extension,
|
|
952
|
+
metadata: result.metadata,
|
|
953
|
+
};
|
|
954
|
+
if (url) {
|
|
955
|
+
item.url = url;
|
|
856
956
|
}
|
|
857
|
-
return
|
|
957
|
+
return item;
|
|
858
958
|
}
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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 (
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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 (
|
|
882
|
-
|
|
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
|
-
|
|
889
|
-
|
|
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 {
|
|
998
|
+
return { ...base, type: 'file', files };
|
|
898
999
|
}
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
|
|
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
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
|
|
1035
|
+
if (i >= 0)
|
|
1036
|
+
videos[i] = next;
|
|
1037
|
+
else
|
|
1038
|
+
videos.push(next);
|
|
1039
|
+
return { ...base, type: 'video', videos };
|
|
932
1040
|
}
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
-
|
|
956
|
-
|
|
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
|
-
|
|
968
|
-
|
|
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
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1057
|
+
|
|
1058
|
+
const CONVERSATION_VOICE_CATALOG = 'conversation-voice';
|
|
1059
|
+
const MB = 1024 * 1024;
|
|
1060
|
+
const CONVERSATION_VOICE_PRESENTATION = {
|
|
1061
|
+
icon: 'fa-light fa-microphone ax-text-green-500',
|
|
1062
|
+
title: 'Voice message',
|
|
1063
|
+
};
|
|
1064
|
+
const VOICE_UTILITY = {
|
|
1065
|
+
duration: 'duration',
|
|
1066
|
+
formatDuration: 'formatDuration',
|
|
1067
|
+
createLocalPreviewUrl: 'createLocalPreviewUrl',
|
|
1068
|
+
};
|
|
1069
|
+
function blobUrl(ctx, blob) {
|
|
1070
|
+
if (!isPlatformBrowser(ctx.platformId)) {
|
|
986
1071
|
return '';
|
|
987
|
-
return input
|
|
988
|
-
.replace(/&/g, '&')
|
|
989
|
-
.replace(/</g, '<')
|
|
990
|
-
.replace(/>/g, '>')
|
|
991
|
-
.replace(/"/g, '"')
|
|
992
|
-
.replace(/'/g, ''')
|
|
993
|
-
.replace(/\//g, '/')
|
|
994
|
-
.replace(/`/g, '`')
|
|
995
|
-
.replace(/=/g, '=');
|
|
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
|
|
1073
|
+
return URL.createObjectURL(blob);
|
|
1018
1074
|
}
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
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
|
-
|
|
1132
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationVoiceFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
1133
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationVoiceFileTypeProvider, providedIn: 'root' }); }
|
|
1040
1134
|
}
|
|
1135
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationVoiceFileTypeProvider, decorators: [{
|
|
1136
|
+
type: Injectable,
|
|
1137
|
+
args: [{ providedIn: 'root' }]
|
|
1138
|
+
}] });
|
|
1041
1139
|
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
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
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
return
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
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
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
return
|
|
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
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
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
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
return message.senderId === currentUserId;
|
|
1276
|
+
}
|
|
1277
|
+
function createObjectUrl(platformId, blob) {
|
|
1278
|
+
if (!isPlatformBrowser(platformId)) {
|
|
1279
|
+
return '';
|
|
1225
1280
|
}
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
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
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
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
|
-
|
|
1380
|
-
|
|
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
|
-
*
|
|
1403
|
-
*
|
|
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
|
-
*
|
|
1420
|
-
*
|
|
1386
|
+
* Configuration token for conversation component
|
|
1387
|
+
* Uses centralized defaults from AX_DEFAULT_CONVERSATION_CONFIG
|
|
1421
1388
|
*/
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
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
|
-
*
|
|
1448
|
-
*
|
|
1402
|
+
* Pluggable avatar components for the conversation UI.
|
|
1403
|
+
* Register via `provideConversation({ avatarComponents: { user, conversation } })`.
|
|
1449
1404
|
*/
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1405
|
+
const AX_CONVERSATION_USER_AVATAR_COMPONENT = new InjectionToken('AX_CONVERSATION_USER_AVATAR_COMPONENT');
|
|
1406
|
+
const AX_CONVERSATION_CONVERSATION_AVATAR_COMPONENT = new InjectionToken('AX_CONVERSATION_CONVERSATION_AVATAR_COMPONENT');
|
|
1407
|
+
|
|
1408
|
+
/**
|
|
1409
|
+
* Registry Configuration Tokens
|
|
1410
|
+
* Injection tokens for configuring default registry values
|
|
1411
|
+
*/
|
|
1412
|
+
/**
|
|
1413
|
+
* Additional message renderers configuration
|
|
1414
|
+
* Provide this token to add custom message renderers in addition to built-in ones
|
|
1415
|
+
* Note: Built-in renderers (text, system, fallback) are registered by default; other types are provided via plugins/constants.
|
|
1416
|
+
*/
|
|
1417
|
+
const DEFAULT_MESSAGE_RENDERERS = new InjectionToken('DEFAULT_MESSAGE_RENDERERS', {
|
|
1418
|
+
providedIn: 'root',
|
|
1419
|
+
factory: () => [],
|
|
1420
|
+
});
|
|
1421
|
+
/**
|
|
1422
|
+
* Default message actions configuration
|
|
1423
|
+
* Provide this token to override default message actions
|
|
1424
|
+
*/
|
|
1425
|
+
const DEFAULT_MESSAGE_ACTIONS = new InjectionToken('DEFAULT_MESSAGE_ACTIONS', {
|
|
1426
|
+
providedIn: 'root',
|
|
1427
|
+
factory: () => [],
|
|
1428
|
+
});
|
|
1429
|
+
/**
|
|
1430
|
+
* Default composer tabs configuration
|
|
1431
|
+
* Provide this token to override default composer tabs (emoji, stickers, etc.)
|
|
1432
|
+
*/
|
|
1433
|
+
const DEFAULT_COMPOSER_TABS = new InjectionToken('DEFAULT_COMPOSER_TABS', {
|
|
1434
|
+
providedIn: 'root',
|
|
1435
|
+
factory: () => [],
|
|
1436
|
+
});
|
|
1437
|
+
/**
|
|
1438
|
+
* Default composer actions configuration
|
|
1439
|
+
* Provide this token to override default composer actions (attach, voice, etc.)
|
|
1440
|
+
*/
|
|
1441
|
+
const DEFAULT_COMPOSER_ACTIONS = new InjectionToken('DEFAULT_COMPOSER_ACTIONS', {
|
|
1442
|
+
providedIn: 'root',
|
|
1443
|
+
factory: () => [],
|
|
1444
|
+
});
|
|
1445
|
+
/**
|
|
1446
|
+
* Default conversation tabs configuration
|
|
1447
|
+
* Provide this token to override default conversation tabs (all, private, groups, etc.)
|
|
1448
|
+
*/
|
|
1449
|
+
const DEFAULT_CONVERSATION_TABS = new InjectionToken('DEFAULT_CONVERSATION_TABS', {
|
|
1450
|
+
providedIn: 'root',
|
|
1451
|
+
factory: () => [],
|
|
1452
|
+
});
|
|
1453
|
+
/**
|
|
1454
|
+
* Default info bar actions configuration
|
|
1455
|
+
* Provide this token to override default info bar actions (mute, archive, block, etc.)
|
|
1456
|
+
*/
|
|
1457
|
+
const DEFAULT_INFO_BAR_ACTIONS = new InjectionToken('DEFAULT_INFO_BAR_ACTIONS', {
|
|
1458
|
+
providedIn: 'root',
|
|
1459
|
+
factory: () => [],
|
|
1460
|
+
});
|
|
1461
|
+
/**
|
|
1462
|
+
* Default conversation item actions configuration
|
|
1463
|
+
* Provide this token to override default conversation item actions (mute, delete, archive, etc.)
|
|
1464
|
+
*/
|
|
1465
|
+
const DEFAULT_CONVERSATION_ITEM_ACTIONS = new InjectionToken('DEFAULT_CONVERSATION_ITEM_ACTIONS', {
|
|
1466
|
+
providedIn: 'root',
|
|
1467
|
+
factory: () => [],
|
|
1468
|
+
});
|
|
1469
|
+
/**
|
|
1470
|
+
* Complete registry configuration token
|
|
1471
|
+
* Provide this for comprehensive registry configuration
|
|
1472
|
+
*/
|
|
1473
|
+
const REGISTRY_CONFIG = new InjectionToken('REGISTRY_CONFIG', {
|
|
1474
|
+
providedIn: 'root',
|
|
1475
|
+
factory: () => ({}),
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
class AXConversationMessageUtilsService {
|
|
1479
|
+
/**
|
|
1480
|
+
* Normalize optional avatar/icon values so empty or whitespace-only strings
|
|
1481
|
+
* are treated as missing data.
|
|
1482
|
+
*/
|
|
1483
|
+
static normalizeOptionalMediaValue(value) {
|
|
1484
|
+
if (typeof value !== 'string') {
|
|
1485
|
+
return undefined;
|
|
1486
|
+
}
|
|
1487
|
+
const normalized = value.trim();
|
|
1488
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
1470
1489
|
}
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
if (newConversations.size > maxCached) {
|
|
1477
|
-
const sorted = Array.from(newConversations.values()).sort((a, b) => (b.lastMessageAt?.getTime() ?? 0) - (a.lastMessageAt?.getTime() ?? 0));
|
|
1478
|
-
const toKeep = sorted.slice(0, maxCached);
|
|
1479
|
-
const cleanedMap = new Map();
|
|
1480
|
-
toKeep.forEach((conv) => cleanedMap.set(conv.id, conv));
|
|
1481
|
-
return cleanedMap;
|
|
1482
|
-
}
|
|
1483
|
-
return newConversations;
|
|
1484
|
-
});
|
|
1490
|
+
/**
|
|
1491
|
+
* Get conversation avatar image URL.
|
|
1492
|
+
*/
|
|
1493
|
+
static getConversationAvatar(conversation) {
|
|
1494
|
+
return AXConversationMessageUtilsService.normalizeOptionalMediaValue(conversation.avatar);
|
|
1485
1495
|
}
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1496
|
+
/**
|
|
1497
|
+
* Font Awesome icon class(es) for a conversation when there is no avatar image.
|
|
1498
|
+
*/
|
|
1499
|
+
static getConversationAvatarIcon(conversation) {
|
|
1500
|
+
if (AXConversationMessageUtilsService.getConversationAvatar(conversation)) {
|
|
1501
|
+
return undefined;
|
|
1502
|
+
}
|
|
1503
|
+
return AXConversationMessageUtilsService.normalizeOptionalMediaValue(conversation.icon);
|
|
1492
1504
|
}
|
|
1493
|
-
|
|
1494
|
-
|
|
1505
|
+
/**
|
|
1506
|
+
* Get sender name from message
|
|
1507
|
+
*/
|
|
1508
|
+
static getSenderName(message, conversation) {
|
|
1509
|
+
const participant = conversation.participants.find((p) => p.id === message.senderId);
|
|
1510
|
+
return participant?.name || translateSync('@acorex:chat.fallbacks.unknown-user');
|
|
1495
1511
|
}
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1512
|
+
/**
|
|
1513
|
+
* Get sender avatar image URL (takes precedence over {@link getSenderAvatarIcon}).
|
|
1514
|
+
*/
|
|
1515
|
+
static getSenderAvatar(message, conversation) {
|
|
1516
|
+
const participant = conversation.participants.find((p) => p.id === message.senderId);
|
|
1517
|
+
return AXConversationMessageUtilsService.normalizeOptionalMediaValue(participant?.avatar);
|
|
1501
1518
|
}
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
});
|
|
1514
|
-
this._conversationMessages.update((map) => {
|
|
1515
|
-
const newMap = new Map(map);
|
|
1516
|
-
newMap.delete(conversationId);
|
|
1517
|
-
return newMap;
|
|
1518
|
-
});
|
|
1519
|
+
/**
|
|
1520
|
+
* Font Awesome icon class(es) for the sender when there is no avatar image:
|
|
1521
|
+
* participant `icon` first, then conversation-level `icon`.
|
|
1522
|
+
*/
|
|
1523
|
+
static getSenderAvatarIcon(message, conversation) {
|
|
1524
|
+
const participant = conversation.participants.find((p) => p.id === message.senderId);
|
|
1525
|
+
if (AXConversationMessageUtilsService.normalizeOptionalMediaValue(participant?.avatar)) {
|
|
1526
|
+
return undefined;
|
|
1527
|
+
}
|
|
1528
|
+
return (AXConversationMessageUtilsService.normalizeOptionalMediaValue(participant?.icon) ??
|
|
1529
|
+
AXConversationMessageUtilsService.getConversationAvatarIcon(conversation));
|
|
1519
1530
|
}
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1531
|
+
/**
|
|
1532
|
+
* Get initials from name
|
|
1533
|
+
*/
|
|
1534
|
+
static getInitials(name) {
|
|
1535
|
+
if (!name)
|
|
1536
|
+
return '?';
|
|
1537
|
+
return name
|
|
1538
|
+
.split(' ')
|
|
1539
|
+
.map((n) => n[0])
|
|
1540
|
+
.join('')
|
|
1541
|
+
.toUpperCase()
|
|
1542
|
+
.substring(0, 2);
|
|
1526
1543
|
}
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1544
|
+
/**
|
|
1545
|
+
* Type guard for text payload
|
|
1546
|
+
*/
|
|
1547
|
+
static isTextPayload(payload) {
|
|
1548
|
+
return 'text' in payload && typeof payload.text === 'string';
|
|
1532
1549
|
}
|
|
1533
|
-
|
|
1534
|
-
|
|
1550
|
+
/**
|
|
1551
|
+
* Get message text content
|
|
1552
|
+
*/
|
|
1553
|
+
static getMessageText(message) {
|
|
1554
|
+
if (message.type === 'text' && AXConversationMessageUtilsService.isTextPayload(message.payload)) {
|
|
1555
|
+
return message.payload.text;
|
|
1556
|
+
}
|
|
1557
|
+
return `[${message.type}]`;
|
|
1535
1558
|
}
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1559
|
+
/**
|
|
1560
|
+
* Format message preview text
|
|
1561
|
+
*/
|
|
1562
|
+
static getPreviewText(message, maxLength = 50) {
|
|
1563
|
+
// Handle different message types
|
|
1564
|
+
switch (message.type) {
|
|
1565
|
+
case 'text': {
|
|
1566
|
+
const textPayload = message.payload;
|
|
1567
|
+
const text = textPayload.text || '';
|
|
1568
|
+
const truncatedText = text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
|
|
1569
|
+
return {
|
|
1570
|
+
value: truncatedText,
|
|
1571
|
+
type: 'text',
|
|
1572
|
+
icon: 'fa-light fa-message',
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
case 'image': {
|
|
1576
|
+
const imagePayload = normalizeImagePayload(message.payload);
|
|
1577
|
+
const first = imagePayload.images[0];
|
|
1578
|
+
const label = imagePayload.caption?.trim() ||
|
|
1579
|
+
(imagePayload.images && imagePayload.images.length > 1
|
|
1580
|
+
? `${imagePayload.images.length} images`
|
|
1581
|
+
: '');
|
|
1582
|
+
return {
|
|
1583
|
+
value: label || first?.thumbnailUrl || first?.url || '',
|
|
1584
|
+
type: 'image',
|
|
1585
|
+
icon: 'fa-light fa-image',
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
case 'video': {
|
|
1589
|
+
const videoPayload = normalizeVideoPayload(message.payload);
|
|
1590
|
+
const first = videoPayload.videos[0];
|
|
1591
|
+
const label = videoPayload.caption?.trim() ||
|
|
1592
|
+
(videoPayload.videos.length > 1 ? `${videoPayload.videos.length} videos` : '');
|
|
1593
|
+
return {
|
|
1594
|
+
value: label || first?.url || '',
|
|
1595
|
+
type: 'video',
|
|
1596
|
+
icon: 'fa-light fa-video',
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
case 'audio': {
|
|
1600
|
+
const audioPayload = normalizeAudioPayload(message.payload);
|
|
1601
|
+
const names = audioPayload.audios.map((a) => a.title).filter(Boolean).join(', ');
|
|
1602
|
+
const first = audioPayload.audios[0];
|
|
1603
|
+
const label = audioPayload.caption?.trim() ||
|
|
1604
|
+
(audioPayload.audios.length > 1 ? `${audioPayload.audios.length} audio files` : names);
|
|
1605
|
+
return {
|
|
1606
|
+
value: label || first?.url || '',
|
|
1607
|
+
type: 'audio',
|
|
1608
|
+
icon: 'fa-light fa-music',
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
case 'voice': {
|
|
1612
|
+
const voicePayload = message.payload;
|
|
1613
|
+
return {
|
|
1614
|
+
value: voicePayload.url || '',
|
|
1615
|
+
type: 'voice',
|
|
1616
|
+
icon: 'fa-light fa-microphone',
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
case 'file': {
|
|
1620
|
+
const filePayload = normalizeFilePayload(message.payload);
|
|
1621
|
+
const names = filePayload.files.map((f) => f.name).join(', ');
|
|
1622
|
+
const label = filePayload.caption?.trim() ||
|
|
1623
|
+
(filePayload.files.length > 1 ? `${filePayload.files.length} files` : names);
|
|
1624
|
+
return {
|
|
1625
|
+
value: label || names,
|
|
1626
|
+
type: 'file',
|
|
1627
|
+
icon: 'fa-light fa-file',
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
case 'location': {
|
|
1631
|
+
const locationPayload = message.payload;
|
|
1632
|
+
return {
|
|
1633
|
+
value: locationPayload.latitude && locationPayload.longitude
|
|
1634
|
+
? `${locationPayload.latitude},${locationPayload.longitude}`
|
|
1635
|
+
: '',
|
|
1636
|
+
type: 'location',
|
|
1637
|
+
icon: 'fa-light fa-location-dot',
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
case 'sticker': {
|
|
1641
|
+
const stickerPayload = message.payload;
|
|
1642
|
+
return {
|
|
1643
|
+
value: stickerPayload.url || '',
|
|
1644
|
+
type: 'sticker',
|
|
1645
|
+
icon: 'fa-light fa-face-smile',
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
default:
|
|
1649
|
+
return {
|
|
1650
|
+
value: '',
|
|
1651
|
+
type: message.type,
|
|
1652
|
+
icon: 'fa-light fa-message',
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1544
1655
|
}
|
|
1545
|
-
|
|
1546
|
-
|
|
1656
|
+
/**
|
|
1657
|
+
* Check if message is from current user
|
|
1658
|
+
*/
|
|
1659
|
+
static isOwnMessage(message, currentUserId) {
|
|
1660
|
+
return message.senderId === currentUserId;
|
|
1547
1661
|
}
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1662
|
+
/**
|
|
1663
|
+
* Get message status icon class (Font Awesome)
|
|
1664
|
+
*/
|
|
1665
|
+
static getStatusIcon(message) {
|
|
1666
|
+
switch (message.status) {
|
|
1667
|
+
case 'sending':
|
|
1668
|
+
return 'fa-light fa-clock';
|
|
1669
|
+
case 'sent':
|
|
1670
|
+
return 'fa-light fa-check';
|
|
1671
|
+
case 'delivered':
|
|
1672
|
+
return 'fa-light fa-check-double';
|
|
1673
|
+
case 'read':
|
|
1674
|
+
return 'fa-light fa-check-double';
|
|
1675
|
+
case 'failed':
|
|
1676
|
+
return 'fa-light fa-circle-exclamation';
|
|
1677
|
+
default:
|
|
1678
|
+
return 'fa-light fa-check';
|
|
1679
|
+
}
|
|
1556
1680
|
}
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
if (
|
|
1563
|
-
if
|
|
1564
|
-
|
|
1681
|
+
/**
|
|
1682
|
+
* Check if message should show avatar
|
|
1683
|
+
*/
|
|
1684
|
+
static shouldShowAvatar(message, previousMessage, conversation) {
|
|
1685
|
+
// Always show avatar for group conversations
|
|
1686
|
+
if (conversation.type === 'group' || conversation.type === 'channel') {
|
|
1687
|
+
// Don't show if same sender as previous message within 5 minutes
|
|
1688
|
+
if (previousMessage && previousMessage.senderId === message.senderId) {
|
|
1689
|
+
const timeDiff = message.timestamp.getTime() - previousMessage.timestamp.getTime();
|
|
1690
|
+
return timeDiff > 5 * 60 * 1000; // 5 minutes
|
|
1565
1691
|
}
|
|
1692
|
+
return true;
|
|
1566
1693
|
}
|
|
1567
|
-
|
|
1568
|
-
typingUsers = typingUsers.filter((id) => id !== userId);
|
|
1569
|
-
}
|
|
1570
|
-
this.updateConversation(conversationId, {
|
|
1571
|
-
status: {
|
|
1572
|
-
...conversation.status,
|
|
1573
|
-
isTyping: typingUsers.length > 0,
|
|
1574
|
-
typingUsers,
|
|
1575
|
-
},
|
|
1576
|
-
});
|
|
1694
|
+
return false;
|
|
1577
1695
|
}
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
}
|
|
1591
|
-
}
|
|
1592
|
-
return newConversations;
|
|
1593
|
-
});
|
|
1696
|
+
/**
|
|
1697
|
+
* Group messages by sender for consecutive messages
|
|
1698
|
+
*/
|
|
1699
|
+
static shouldGroupWithPrevious(message, previousMessage) {
|
|
1700
|
+
if (!previousMessage)
|
|
1701
|
+
return false;
|
|
1702
|
+
// Same sender
|
|
1703
|
+
if (message.senderId !== previousMessage.senderId)
|
|
1704
|
+
return false;
|
|
1705
|
+
// Within 5 minutes
|
|
1706
|
+
const timeDiff = message.timestamp.getTime() - previousMessage.timestamp.getTime();
|
|
1707
|
+
return timeDiff < 5 * 60 * 1000;
|
|
1594
1708
|
}
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
return
|
|
1601
|
-
}
|
|
1602
|
-
|
|
1603
|
-
const newMap = new Map(map);
|
|
1604
|
-
const existing = newMap.get(normalized.conversationId) || [];
|
|
1605
|
-
if (existing.includes(normalized.id)) {
|
|
1606
|
-
return newMap;
|
|
1607
|
-
}
|
|
1608
|
-
const updated = [...existing, normalized.id].sort((a, b) => {
|
|
1609
|
-
const msgA = this._messages().get(a);
|
|
1610
|
-
const msgB = this._messages().get(b);
|
|
1611
|
-
if (!msgA || !msgB)
|
|
1612
|
-
return 0;
|
|
1613
|
-
return msgA.timestamp.getTime() - msgB.timestamp.getTime();
|
|
1614
|
-
});
|
|
1615
|
-
newMap.set(normalized.conversationId, updated);
|
|
1616
|
-
return newMap;
|
|
1617
|
-
});
|
|
1709
|
+
/**
|
|
1710
|
+
* Get conversation status for avatar (private conversations only)
|
|
1711
|
+
*/
|
|
1712
|
+
static getConversationStatus(conversation) {
|
|
1713
|
+
if (conversation.type === 'private') {
|
|
1714
|
+
return conversation.status.presence;
|
|
1715
|
+
}
|
|
1716
|
+
return undefined;
|
|
1618
1717
|
}
|
|
1619
1718
|
/**
|
|
1620
|
-
*
|
|
1719
|
+
* Get typing indicator text for conversation
|
|
1621
1720
|
*/
|
|
1622
|
-
|
|
1623
|
-
const
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
}
|
|
1634
|
-
|
|
1635
|
-
});
|
|
1636
|
-
this._conversationMessages.update((map) => {
|
|
1637
|
-
const newMap = new Map(map);
|
|
1638
|
-
newMap.set(conversationId, sortedIds);
|
|
1639
|
-
return newMap;
|
|
1640
|
-
});
|
|
1641
|
-
this.cleanupOldMessages();
|
|
1721
|
+
static getTypingText(conversation) {
|
|
1722
|
+
const typingUsers = conversation.status.typingUsers;
|
|
1723
|
+
if (typingUsers.length === 0)
|
|
1724
|
+
return '';
|
|
1725
|
+
if (conversation.type === 'private') {
|
|
1726
|
+
return translateSync('@acorex:chat.status.typing');
|
|
1727
|
+
}
|
|
1728
|
+
const firstUser = conversation.participants.find((p) => p.id === typingUsers[0]);
|
|
1729
|
+
if (typingUsers.length === 1) {
|
|
1730
|
+
return translateSync('@acorex:chat.status.user-is-typing', {
|
|
1731
|
+
params: { userName: firstUser?.name || translateSync('@acorex:chat.fallbacks.someone') },
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
return translateSync('@acorex:chat.status.people-typing', { params: { count: typingUsers.length } });
|
|
1642
1735
|
}
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
const newMap = new Map(map);
|
|
1660
|
-
for (const [conversationId, newMsgIds] of conversationGroups) {
|
|
1661
|
-
const existing = newMap.get(conversationId) || [];
|
|
1662
|
-
const idSet = new Set(existing);
|
|
1663
|
-
const merged = [...existing];
|
|
1664
|
-
for (const id of newMsgIds) {
|
|
1665
|
-
if (!idSet.has(id)) {
|
|
1666
|
-
merged.push(id);
|
|
1667
|
-
idSet.add(id);
|
|
1668
|
-
}
|
|
1669
|
-
}
|
|
1670
|
-
const sorted = merged.sort((a, b) => {
|
|
1671
|
-
const msgA = this._messages().get(a);
|
|
1672
|
-
const msgB = this._messages().get(b);
|
|
1673
|
-
if (!msgA || !msgB)
|
|
1674
|
-
return 0;
|
|
1675
|
-
return msgA.timestamp.getTime() - msgB.timestamp.getTime();
|
|
1676
|
-
});
|
|
1677
|
-
newMap.set(conversationId, sorted);
|
|
1678
|
-
}
|
|
1679
|
-
return newMap;
|
|
1680
|
-
});
|
|
1681
|
-
this.cleanupOldMessages();
|
|
1682
|
-
this.cleanupConversationMessages();
|
|
1683
|
-
}
|
|
1684
|
-
getMessage(messageId) {
|
|
1685
|
-
return this._messages().get(messageId);
|
|
1686
|
-
}
|
|
1687
|
-
getConversationMessages(conversationId) {
|
|
1688
|
-
const messageIds = this._conversationMessages().get(conversationId) || [];
|
|
1689
|
-
return messageIds.map((id) => this._messages().get(id)).filter((msg) => msg !== undefined);
|
|
1736
|
+
/**
|
|
1737
|
+
* Format last seen time
|
|
1738
|
+
*/
|
|
1739
|
+
static formatLastSeen(date) {
|
|
1740
|
+
const now = new Date();
|
|
1741
|
+
const diff = now.getTime() - date.getTime();
|
|
1742
|
+
const seconds = Math.floor(diff / 1000);
|
|
1743
|
+
if (seconds < 60)
|
|
1744
|
+
return translateSync('@acorex:chat.time.just-now');
|
|
1745
|
+
if (seconds < 3600)
|
|
1746
|
+
return translateSync('@acorex:chat.time.minutes-ago', { params: { count: Math.floor(seconds / 60) } });
|
|
1747
|
+
if (seconds < 86400)
|
|
1748
|
+
return translateSync('@acorex:chat.time.hours-ago', { params: { count: Math.floor(seconds / 3600) } });
|
|
1749
|
+
if (seconds < 604800)
|
|
1750
|
+
return translateSync('@acorex:chat.time.days-ago', { params: { count: Math.floor(seconds / 86400) } });
|
|
1751
|
+
return date.toLocaleDateString();
|
|
1690
1752
|
}
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
merged.payload = normalizeMessagePayload(merged.payload);
|
|
1753
|
+
/**
|
|
1754
|
+
* Get conversation subtitle (status or member count)
|
|
1755
|
+
*/
|
|
1756
|
+
static getConversationSubtitle(conversation) {
|
|
1757
|
+
if (conversation.status.isTyping) {
|
|
1758
|
+
return AXConversationMessageUtilsService.getTypingText(conversation);
|
|
1698
1759
|
}
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
if (!message)
|
|
1704
|
-
return;
|
|
1705
|
-
this._messages.update((messages) => {
|
|
1706
|
-
const newMessages = new Map(messages);
|
|
1707
|
-
newMessages.delete(messageId);
|
|
1708
|
-
return newMessages;
|
|
1709
|
-
});
|
|
1710
|
-
this._conversationMessages.update((map) => {
|
|
1711
|
-
const newMap = new Map(map);
|
|
1712
|
-
const existing = newMap.get(message.conversationId);
|
|
1713
|
-
if (existing) {
|
|
1714
|
-
newMap.set(message.conversationId, existing.filter((id) => id !== messageId));
|
|
1715
|
-
}
|
|
1716
|
-
return newMap;
|
|
1717
|
-
});
|
|
1718
|
-
}
|
|
1719
|
-
cleanupOldMessages() {
|
|
1720
|
-
const totalMessages = this._messages().size;
|
|
1721
|
-
if (totalMessages > this.config.maxTotalMessages) {
|
|
1722
|
-
const allMessages = Array.from(this._messages().values()).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
|
1723
|
-
const toKeep = allMessages.slice(0, this.config.maxTotalMessages);
|
|
1724
|
-
const toKeepIds = new Set(toKeep.map((m) => m.id));
|
|
1725
|
-
this._messages.update(() => {
|
|
1726
|
-
const newMessages = new Map();
|
|
1727
|
-
toKeep.forEach((msg) => newMessages.set(msg.id, msg));
|
|
1728
|
-
return newMessages;
|
|
1729
|
-
});
|
|
1730
|
-
this._conversationMessages.update((map) => {
|
|
1731
|
-
const newMap = new Map(map);
|
|
1732
|
-
for (const [convId, messageIds] of newMap) {
|
|
1733
|
-
const filteredIds = messageIds.filter((id) => toKeepIds.has(id));
|
|
1734
|
-
newMap.set(convId, filteredIds);
|
|
1760
|
+
switch (conversation.type) {
|
|
1761
|
+
case 'private':
|
|
1762
|
+
if (conversation.status.presence === 'online') {
|
|
1763
|
+
return translateSync('@acorex:chat.status.online');
|
|
1735
1764
|
}
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
cleanupConversationMessages() {
|
|
1741
|
-
const maxMessages = this.config.maxMessagesPerConversation;
|
|
1742
|
-
const idsToRemove = [];
|
|
1743
|
-
this._conversationMessages.update((map) => {
|
|
1744
|
-
const newMap = new Map(map);
|
|
1745
|
-
for (const [convId, messageIds] of newMap) {
|
|
1746
|
-
if (messageIds.length > maxMessages) {
|
|
1747
|
-
idsToRemove.push(...messageIds.slice(0, messageIds.length - maxMessages));
|
|
1748
|
-
newMap.set(convId, messageIds.slice(-maxMessages));
|
|
1765
|
+
if (conversation.status.lastSeen) {
|
|
1766
|
+
return translateSync('@acorex:chat.status.last-seen', {
|
|
1767
|
+
params: { value: AXConversationMessageUtilsService.formatLastSeen(conversation.status.lastSeen) },
|
|
1768
|
+
});
|
|
1749
1769
|
}
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1770
|
+
return translateSync('@acorex:chat.status.offline');
|
|
1771
|
+
case 'group':
|
|
1772
|
+
return translateSync('@acorex:chat.members.count', { params: { count: conversation.participants.length } });
|
|
1773
|
+
case 'channel':
|
|
1774
|
+
return translateSync('@acorex:chat.members.subscribers-count', { params: { count: conversation.participants.length } });
|
|
1775
|
+
case 'bot':
|
|
1776
|
+
return translateSync('@acorex:chat.bot');
|
|
1777
|
+
default:
|
|
1778
|
+
return '';
|
|
1759
1779
|
}
|
|
1760
1780
|
}
|
|
1761
1781
|
}
|
|
1762
1782
|
|
|
1763
|
-
/**
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1783
|
+
/** Other participant in a private chat (excludes the current user). */
|
|
1784
|
+
function resolvePrivatePeerUserId(conversation, currentUserId) {
|
|
1785
|
+
if (conversation.type !== 'private') {
|
|
1786
|
+
return undefined;
|
|
1787
|
+
}
|
|
1788
|
+
const currentId = currentUserId ?? 'current-user';
|
|
1789
|
+
return conversation.participants.find((participant) => participant.id !== currentId)?.id;
|
|
1790
|
+
}
|
|
1791
|
+
/** Whether `auto` kind should render a user avatar for this conversation. */
|
|
1792
|
+
function shouldUseUserAvatarForConversation(conversation, currentUserId) {
|
|
1793
|
+
return conversation.type === 'private' && !!resolvePrivatePeerUserId(conversation, currentUserId);
|
|
1794
|
+
}
|
|
1795
|
+
function resolveUserAvatarDisplay(userId, conversation, message) {
|
|
1796
|
+
if (message && conversation) {
|
|
1797
|
+
return {
|
|
1798
|
+
name: AXConversationMessageUtilsService.getSenderName(message, conversation),
|
|
1799
|
+
avatar: AXConversationMessageUtilsService.getSenderAvatar(message, conversation),
|
|
1800
|
+
icon: AXConversationMessageUtilsService.getSenderAvatarIcon(message, conversation),
|
|
1779
1801
|
};
|
|
1780
|
-
/** Error stream */
|
|
1781
|
-
this.errors$ = this._errors$.asObservable();
|
|
1782
|
-
this.configure(this.injectedConfig);
|
|
1783
1802
|
}
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1803
|
+
const participant = conversation?.participants.find((p) => p.id === userId);
|
|
1804
|
+
return {
|
|
1805
|
+
name: participant?.name ?? userId,
|
|
1806
|
+
avatar: participant?.avatar,
|
|
1807
|
+
icon: participant?.icon,
|
|
1808
|
+
};
|
|
1809
|
+
}
|
|
1810
|
+
function resolveConversationAvatarDisplay(conversation, currentUserId) {
|
|
1811
|
+
const title = currentUserId !== undefined
|
|
1812
|
+
? resolveConversationTitleForViewer(conversation, currentUserId)
|
|
1813
|
+
: conversation.title;
|
|
1814
|
+
return {
|
|
1815
|
+
name: title,
|
|
1816
|
+
avatar: AXConversationMessageUtilsService.getConversationAvatar(conversation),
|
|
1817
|
+
icon: AXConversationMessageUtilsService.getConversationAvatarIcon(conversation),
|
|
1818
|
+
};
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
const GENERIC_PRIVATE_TITLES = new Set(['', 'new chat', 'new conversation']);
|
|
1822
|
+
/** True when the stored title is a placeholder, not a user-defined name. */
|
|
1823
|
+
function isGenericPrivateConversationTitle(title) {
|
|
1824
|
+
return GENERIC_PRIVATE_TITLES.has((title ?? '').trim().toLowerCase());
|
|
1825
|
+
}
|
|
1826
|
+
/** Other participant in a private 1v1 chat (excludes the current viewer). */
|
|
1827
|
+
function resolvePrivatePeerParticipant(conversation, currentUserId) {
|
|
1828
|
+
if (conversation.type !== 'private') {
|
|
1829
|
+
return undefined;
|
|
1789
1830
|
}
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
handle(error, operation, context) {
|
|
1794
|
-
const conversationError = this.normalizeError(error, operation, context);
|
|
1795
|
-
this.publish(conversationError);
|
|
1796
|
-
return conversationError;
|
|
1831
|
+
const peerId = resolvePrivatePeerUserId(conversation, currentUserId);
|
|
1832
|
+
if (!peerId) {
|
|
1833
|
+
return undefined;
|
|
1797
1834
|
}
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1835
|
+
return conversation.participants.find((participant) => participant.id === peerId);
|
|
1836
|
+
}
|
|
1837
|
+
/**
|
|
1838
|
+
* Resolves the display title for the current viewer.
|
|
1839
|
+
* Private 1v1 chats show the other participant's name when no custom title is set.
|
|
1840
|
+
*/
|
|
1841
|
+
function resolveConversationTitleForViewer(conversation, currentUserId) {
|
|
1842
|
+
if (conversation.type !== 'private') {
|
|
1843
|
+
return conversation.title;
|
|
1805
1844
|
}
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
this.logError(conversationError);
|
|
1810
|
-
}
|
|
1811
|
-
if (this._config.customHandler) {
|
|
1812
|
-
this._config.customHandler(conversationError);
|
|
1813
|
-
}
|
|
1845
|
+
const peer = resolvePrivatePeerParticipant(conversation, currentUserId);
|
|
1846
|
+
if (peer?.name) {
|
|
1847
|
+
return peer.name;
|
|
1814
1848
|
}
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1849
|
+
if (isGenericPrivateConversationTitle(conversation.title)) {
|
|
1850
|
+
return conversation.title || 'New Chat';
|
|
1851
|
+
}
|
|
1852
|
+
return conversation.title;
|
|
1853
|
+
}
|
|
1854
|
+
/**
|
|
1855
|
+
* Returns a viewer-scoped copy of a conversation with dynamic private title/avatar/icon.
|
|
1856
|
+
* Does not mutate the source object.
|
|
1857
|
+
*/
|
|
1858
|
+
function resolveConversationForViewer(conversation, currentUserId) {
|
|
1859
|
+
if (conversation.type !== 'private' || !currentUserId) {
|
|
1860
|
+
return conversation;
|
|
1861
|
+
}
|
|
1862
|
+
const peer = resolvePrivatePeerParticipant(conversation, currentUserId);
|
|
1863
|
+
if (!peer) {
|
|
1864
|
+
return conversation;
|
|
1865
|
+
}
|
|
1866
|
+
const title = resolveConversationTitleForViewer(conversation, currentUserId);
|
|
1867
|
+
const avatar = conversation.avatar ?? peer.avatar;
|
|
1868
|
+
const icon = conversation.icon ?? peer.icon;
|
|
1869
|
+
if (title === conversation.title &&
|
|
1870
|
+
avatar === conversation.avatar &&
|
|
1871
|
+
icon === conversation.icon) {
|
|
1872
|
+
return conversation;
|
|
1873
|
+
}
|
|
1874
|
+
return {
|
|
1875
|
+
...conversation,
|
|
1876
|
+
title,
|
|
1877
|
+
avatar,
|
|
1878
|
+
icon,
|
|
1879
|
+
};
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
/**
|
|
1883
|
+
* Validation Utilities
|
|
1884
|
+
* Centralized validation functions for messages and user input
|
|
1885
|
+
*/
|
|
1886
|
+
/**
|
|
1887
|
+
* Validate message text content
|
|
1888
|
+
* @param text - Text to validate
|
|
1889
|
+
* @param config - Configuration for validation rules
|
|
1890
|
+
* @returns Validation result
|
|
1891
|
+
*/
|
|
1892
|
+
function validateMessageText(text, config) {
|
|
1893
|
+
// Check for empty text
|
|
1894
|
+
if (!text || text.trim().length === 0) {
|
|
1829
1895
|
return {
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
operation,
|
|
1834
|
-
originalError: error,
|
|
1835
|
-
statusCode,
|
|
1836
|
-
context,
|
|
1837
|
-
timestamp: new Date(),
|
|
1838
|
-
handled: false,
|
|
1839
|
-
recoverySuggestions: this.getDefaultRecoverySuggestions(code),
|
|
1896
|
+
valid: false,
|
|
1897
|
+
error: 'Message text cannot be empty',
|
|
1898
|
+
errorCode: 'EMPTY_MESSAGE',
|
|
1840
1899
|
};
|
|
1841
1900
|
}
|
|
1842
|
-
|
|
1901
|
+
// Check minimum length
|
|
1902
|
+
const minLength = config.minMessageLength ?? 1;
|
|
1903
|
+
if (text.trim().length < minLength) {
|
|
1843
1904
|
return {
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
operation,
|
|
1848
|
-
originalError: apiError,
|
|
1849
|
-
statusCode: apiError.statusCode,
|
|
1850
|
-
context,
|
|
1851
|
-
timestamp: apiError.timestamp ?? new Date(),
|
|
1852
|
-
handled: false,
|
|
1853
|
-
recoverySuggestions: this.getRecoverySuggestions(apiError),
|
|
1905
|
+
valid: false,
|
|
1906
|
+
error: `Message must be at least ${minLength} character(s)`,
|
|
1907
|
+
errorCode: 'MESSAGE_TOO_SHORT',
|
|
1854
1908
|
};
|
|
1855
1909
|
}
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
/**
|
|
1865
|
-
* Determine severity based on status code
|
|
1866
|
-
*/
|
|
1867
|
-
determineSeverity(statusCode) {
|
|
1868
|
-
if (!statusCode)
|
|
1869
|
-
return 'error';
|
|
1870
|
-
if (statusCode >= 500)
|
|
1871
|
-
return 'critical';
|
|
1872
|
-
if (statusCode >= 400)
|
|
1873
|
-
return 'error';
|
|
1874
|
-
if (statusCode >= 300)
|
|
1875
|
-
return 'warning';
|
|
1876
|
-
return 'info';
|
|
1877
|
-
}
|
|
1878
|
-
/**
|
|
1879
|
-
* Get recovery suggestions based on error
|
|
1880
|
-
*/
|
|
1881
|
-
getRecoverySuggestions(error) {
|
|
1882
|
-
const suggestions = [];
|
|
1883
|
-
if (error.statusCode === 401 || error.code === 'UNAUTHORIZED') {
|
|
1884
|
-
suggestions.push('Please log in again');
|
|
1885
|
-
suggestions.push('Check if your session has expired');
|
|
1886
|
-
}
|
|
1887
|
-
else if (error.statusCode === 403 || error.code === 'FORBIDDEN') {
|
|
1888
|
-
suggestions.push('You do not have permission for this action');
|
|
1889
|
-
suggestions.push('Ask your administrator for access');
|
|
1890
|
-
}
|
|
1891
|
-
else if (error.statusCode === 404 || error.code === 'NOT_FOUND') {
|
|
1892
|
-
suggestions.push('The requested resource was not found');
|
|
1893
|
-
suggestions.push('It may have been deleted or moved');
|
|
1894
|
-
}
|
|
1895
|
-
else if (error.statusCode === 429 || error.code === 'RATE_LIMIT_EXCEEDED') {
|
|
1896
|
-
suggestions.push('Too many requests. Please wait and try again');
|
|
1897
|
-
}
|
|
1898
|
-
else if (error.statusCode && error.statusCode >= 500) {
|
|
1899
|
-
suggestions.push('Server error occurred');
|
|
1900
|
-
suggestions.push('Please try again later');
|
|
1901
|
-
suggestions.push('If the problem persists, reach out to support');
|
|
1902
|
-
}
|
|
1903
|
-
else if (error.code === 'NETWORK_ERROR') {
|
|
1904
|
-
suggestions.push('Check your internet connection');
|
|
1905
|
-
suggestions.push('Try refreshing the page');
|
|
1906
|
-
}
|
|
1907
|
-
return suggestions;
|
|
1910
|
+
// Check maximum length
|
|
1911
|
+
const maxLength = config.maxMessageLength ?? 10000;
|
|
1912
|
+
if (text.length > maxLength) {
|
|
1913
|
+
return {
|
|
1914
|
+
valid: false,
|
|
1915
|
+
error: `Message exceeds ${maxLength} character limit`,
|
|
1916
|
+
errorCode: 'MESSAGE_TOO_LONG',
|
|
1917
|
+
};
|
|
1908
1918
|
}
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
suggestions.push('Please try again');
|
|
1924
|
-
suggestions.push('If the problem persists, reach out to support');
|
|
1925
|
-
}
|
|
1926
|
-
return suggestions;
|
|
1919
|
+
return { valid: true };
|
|
1920
|
+
}
|
|
1921
|
+
/**
|
|
1922
|
+
* Validate conversation ID
|
|
1923
|
+
* @param conversationId - Conversation ID to validate
|
|
1924
|
+
* @returns Validation result
|
|
1925
|
+
*/
|
|
1926
|
+
function validateConversationId(conversationId) {
|
|
1927
|
+
if (!conversationId || typeof conversationId !== 'string' || conversationId.trim().length === 0) {
|
|
1928
|
+
return {
|
|
1929
|
+
valid: false,
|
|
1930
|
+
error: 'Conversation ID is required',
|
|
1931
|
+
errorCode: 'MISSING_CONVERSATION_ID',
|
|
1932
|
+
};
|
|
1927
1933
|
}
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
console.error(header, error.message, error.context || '');
|
|
1936
|
-
}
|
|
1937
|
-
else {
|
|
1938
|
-
console.warn(header, error.message, error.context || '');
|
|
1939
|
-
}
|
|
1940
|
-
if (error.originalError && error.severity !== 'info') {
|
|
1941
|
-
console.error('Original error:', error.originalError);
|
|
1942
|
-
}
|
|
1943
|
-
if (error.recoverySuggestions && error.recoverySuggestions.length > 0) {
|
|
1944
|
-
console.info('Recovery suggestions:', error.recoverySuggestions);
|
|
1945
|
-
}
|
|
1934
|
+
// Check for reasonable length
|
|
1935
|
+
if (conversationId.length > 255) {
|
|
1936
|
+
return {
|
|
1937
|
+
valid: false,
|
|
1938
|
+
error: 'Conversation ID is too long',
|
|
1939
|
+
errorCode: 'MISSING_CONVERSATION_ID',
|
|
1940
|
+
};
|
|
1946
1941
|
}
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
TIMEOUT: 'The operation timed out. Please try again.',
|
|
1942
|
+
return { valid: true };
|
|
1943
|
+
}
|
|
1944
|
+
/**
|
|
1945
|
+
* Validate message type
|
|
1946
|
+
* @param type - Message type to validate
|
|
1947
|
+
* @returns Validation result
|
|
1948
|
+
*/
|
|
1949
|
+
function validateMessageType(type) {
|
|
1950
|
+
if (!type || type.trim().length === 0) {
|
|
1951
|
+
return {
|
|
1952
|
+
valid: false,
|
|
1953
|
+
error: 'Message type is required',
|
|
1954
|
+
errorCode: 'MISSING_MESSAGE_TYPE',
|
|
1961
1955
|
};
|
|
1962
|
-
return messageMap[error.code] || error.message || 'An unexpected error occurred.';
|
|
1963
1956
|
}
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1957
|
+
return { valid: true };
|
|
1958
|
+
}
|
|
1959
|
+
function validateMediaItems(items, label) {
|
|
1960
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
1961
|
+
return {
|
|
1962
|
+
valid: false,
|
|
1963
|
+
error: `${label} message must include at least one attachment`,
|
|
1964
|
+
errorCode: 'INVALID_MEDIA_PAYLOAD',
|
|
1965
|
+
};
|
|
1972
1966
|
}
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
if (!this._config.autoRetry) {
|
|
1983
|
-
// If auto-retry is disabled, just execute once
|
|
1984
|
-
return operation();
|
|
1967
|
+
for (const item of items) {
|
|
1968
|
+
const ok = (typeof item.url === 'string' && item.url.length > 0) ||
|
|
1969
|
+
(typeof item.mediaId === 'string' && item.mediaId.length > 0);
|
|
1970
|
+
if (!ok) {
|
|
1971
|
+
return {
|
|
1972
|
+
valid: false,
|
|
1973
|
+
error: `Each ${label.toLowerCase()} attachment must have a url or mediaId`,
|
|
1974
|
+
errorCode: 'INVALID_MEDIA_PAYLOAD',
|
|
1975
|
+
};
|
|
1985
1976
|
}
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1977
|
+
}
|
|
1978
|
+
return { valid: true };
|
|
1979
|
+
}
|
|
1980
|
+
/**
|
|
1981
|
+
* Validate message payload
|
|
1982
|
+
* @param payload - Message payload to validate
|
|
1983
|
+
* @param type - Message type
|
|
1984
|
+
* @returns Validation result
|
|
1985
|
+
*/
|
|
1986
|
+
function validateMessagePayload(payload, type) {
|
|
1987
|
+
if (!payload) {
|
|
1988
|
+
return {
|
|
1989
|
+
valid: false,
|
|
1990
|
+
error: 'Message payload is required',
|
|
1991
|
+
errorCode: 'MISSING_PAYLOAD',
|
|
1992
|
+
};
|
|
1993
|
+
}
|
|
1994
|
+
const normalized = type === 'image' || type === 'video' || type === 'audio' || type === 'file'
|
|
1995
|
+
? normalizeMessagePayload(payload)
|
|
1996
|
+
: payload;
|
|
1997
|
+
// Type-specific validation
|
|
1998
|
+
switch (type) {
|
|
1999
|
+
case 'text':
|
|
2000
|
+
if (!('text' in payload) || typeof payload.text !== 'string') {
|
|
2001
|
+
return {
|
|
2002
|
+
valid: false,
|
|
2003
|
+
error: 'Text message must have a text property',
|
|
2004
|
+
errorCode: 'INVALID_TEXT_PAYLOAD',
|
|
2005
|
+
};
|
|
1991
2006
|
}
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2007
|
+
break;
|
|
2008
|
+
case 'image':
|
|
2009
|
+
return validateMediaItems(normalized.images, 'Image');
|
|
2010
|
+
case 'video':
|
|
2011
|
+
return validateMediaItems(normalized.videos, 'Video');
|
|
2012
|
+
case 'audio':
|
|
2013
|
+
return validateMediaItems(normalized.audios, 'Audio');
|
|
2014
|
+
case 'file':
|
|
2015
|
+
return validateMediaItems(normalized.files, 'File');
|
|
2016
|
+
case 'voice':
|
|
2017
|
+
case 'sticker': {
|
|
2018
|
+
const media = payload;
|
|
2019
|
+
const hasUrl = typeof media.url === 'string' && media.url.length > 0;
|
|
2020
|
+
const hasMediaId = typeof media.mediaId === 'string' && media.mediaId.length > 0;
|
|
2021
|
+
if (!hasUrl && !hasMediaId) {
|
|
2022
|
+
return {
|
|
2023
|
+
valid: false,
|
|
2024
|
+
error: `${type} message must have a url or mediaId`,
|
|
2025
|
+
errorCode: 'INVALID_MEDIA_PAYLOAD',
|
|
2026
|
+
};
|
|
2002
2027
|
}
|
|
2028
|
+
break;
|
|
2003
2029
|
}
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2030
|
+
case 'location':
|
|
2031
|
+
if (!('latitude' in payload) ||
|
|
2032
|
+
!('longitude' in payload) ||
|
|
2033
|
+
typeof payload.latitude !== 'number' ||
|
|
2034
|
+
typeof payload.longitude !== 'number') {
|
|
2035
|
+
return {
|
|
2036
|
+
valid: false,
|
|
2037
|
+
error: 'Location message must have latitude and longitude properties',
|
|
2038
|
+
errorCode: 'INVALID_LOCATION_PAYLOAD',
|
|
2039
|
+
};
|
|
2040
|
+
}
|
|
2041
|
+
break;
|
|
2012
2042
|
}
|
|
2013
|
-
|
|
2014
|
-
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXErrorHandlerService, providedIn: 'root' }); }
|
|
2015
|
-
}
|
|
2016
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXErrorHandlerService, decorators: [{
|
|
2017
|
-
type: Injectable,
|
|
2018
|
-
args: [{
|
|
2019
|
-
providedIn: 'root',
|
|
2020
|
-
}]
|
|
2021
|
-
}], ctorParameters: () => [] });
|
|
2022
|
-
|
|
2023
|
-
function formatFileSize(bytes) {
|
|
2024
|
-
if (bytes < 1024)
|
|
2025
|
-
return `${bytes} B`;
|
|
2026
|
-
if (bytes < 1024 * 1024)
|
|
2027
|
-
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
2028
|
-
if (bytes < 1024 * 1024 * 1024)
|
|
2029
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
2030
|
-
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
2031
|
-
}
|
|
2032
|
-
function formatDuration(seconds) {
|
|
2033
|
-
const mins = Math.floor(seconds / 60);
|
|
2034
|
-
const secs = Math.floor(seconds % 60);
|
|
2035
|
-
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
2043
|
+
return { valid: true };
|
|
2036
2044
|
}
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
createLocalPreviewUrl: 'createLocalPreviewUrl',
|
|
2050
|
-
};
|
|
2051
|
-
function blobUrl$4(ctx, blob) {
|
|
2052
|
-
if (!isPlatformBrowser(ctx.platformId)) {
|
|
2053
|
-
return '';
|
|
2045
|
+
/**
|
|
2046
|
+
* Validate user ID
|
|
2047
|
+
* @param userId - User ID to validate
|
|
2048
|
+
* @returns Validation result
|
|
2049
|
+
*/
|
|
2050
|
+
function validateUserId(userId) {
|
|
2051
|
+
if (!userId || typeof userId !== 'string' || userId.trim().length === 0) {
|
|
2052
|
+
return {
|
|
2053
|
+
valid: false,
|
|
2054
|
+
error: 'User ID is required',
|
|
2055
|
+
errorCode: 'MISSING_USER_ID',
|
|
2056
|
+
};
|
|
2054
2057
|
}
|
|
2055
|
-
|
|
2058
|
+
// Check for reasonable length (prevent extremely long IDs)
|
|
2059
|
+
if (userId.length > 255) {
|
|
2060
|
+
return {
|
|
2061
|
+
valid: false,
|
|
2062
|
+
error: 'User ID is too long',
|
|
2063
|
+
errorCode: 'INVALID_USER_ID',
|
|
2064
|
+
};
|
|
2065
|
+
}
|
|
2066
|
+
return { valid: true };
|
|
2056
2067
|
}
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2068
|
+
/**
|
|
2069
|
+
* Validate array of user IDs
|
|
2070
|
+
* @param userIds - Array of user IDs to validate
|
|
2071
|
+
* @param minCount - Minimum number of users required
|
|
2072
|
+
* @param maxCount - Maximum number of users allowed
|
|
2073
|
+
* @returns Validation result
|
|
2074
|
+
*/
|
|
2075
|
+
function validateUserIds(userIds, minCount = 1, maxCount) {
|
|
2076
|
+
if (!userIds || !Array.isArray(userIds)) {
|
|
2077
|
+
return {
|
|
2078
|
+
valid: false,
|
|
2079
|
+
error: 'User IDs must be an array',
|
|
2080
|
+
errorCode: 'INVALID_USER_IDS',
|
|
2081
|
+
};
|
|
2060
2082
|
}
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
resolve(el.duration);
|
|
2083
|
+
if (userIds.length < minCount) {
|
|
2084
|
+
return {
|
|
2085
|
+
valid: false,
|
|
2086
|
+
error: `At least ${minCount} user(s) required`,
|
|
2087
|
+
errorCode: 'TOO_FEW_USERS',
|
|
2067
2088
|
};
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2089
|
+
}
|
|
2090
|
+
if (maxCount && userIds.length > maxCount) {
|
|
2091
|
+
return {
|
|
2092
|
+
valid: false,
|
|
2093
|
+
error: `Maximum ${maxCount} user(s) allowed`,
|
|
2094
|
+
errorCode: 'TOO_MANY_USERS',
|
|
2071
2095
|
};
|
|
2072
|
-
|
|
2073
|
-
|
|
2096
|
+
}
|
|
2097
|
+
// Check for empty or invalid IDs
|
|
2098
|
+
const invalidIds = userIds.filter((id) => !id || id.trim().length === 0);
|
|
2099
|
+
if (invalidIds.length > 0) {
|
|
2100
|
+
return {
|
|
2101
|
+
valid: false,
|
|
2102
|
+
error: 'All user IDs must be non-empty strings',
|
|
2103
|
+
errorCode: 'INVALID_USER_ID',
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
return { valid: true };
|
|
2074
2107
|
}
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2108
|
+
/**
|
|
2109
|
+
* Validate email address
|
|
2110
|
+
* @param email - Email to validate
|
|
2111
|
+
* @returns Validation result
|
|
2112
|
+
*/
|
|
2113
|
+
function validateEmail(email) {
|
|
2114
|
+
if (!email || email.trim().length === 0) {
|
|
2115
|
+
return {
|
|
2116
|
+
valid: false,
|
|
2117
|
+
error: 'Email is required',
|
|
2118
|
+
errorCode: 'MISSING_EMAIL',
|
|
2119
|
+
};
|
|
2120
|
+
}
|
|
2121
|
+
// Trim whitespace
|
|
2122
|
+
const trimmedEmail = email.trim();
|
|
2123
|
+
// Check length constraints
|
|
2124
|
+
if (trimmedEmail.length > 254) {
|
|
2125
|
+
return {
|
|
2126
|
+
valid: false,
|
|
2127
|
+
error: 'Email is too long',
|
|
2128
|
+
errorCode: 'INVALID_EMAIL',
|
|
2129
|
+
};
|
|
2130
|
+
}
|
|
2131
|
+
// Enhanced email regex with better validation
|
|
2132
|
+
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
|
2133
|
+
if (!emailRegex.test(trimmedEmail)) {
|
|
2134
|
+
return {
|
|
2135
|
+
valid: false,
|
|
2136
|
+
error: 'Invalid email format',
|
|
2137
|
+
errorCode: 'INVALID_EMAIL',
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
return { valid: true };
|
|
2083
2141
|
}
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
}
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2142
|
+
/**
|
|
2143
|
+
* Validate URL
|
|
2144
|
+
* @param url - URL to validate
|
|
2145
|
+
* @returns Validation result
|
|
2146
|
+
*/
|
|
2147
|
+
function validateUrl(url) {
|
|
2148
|
+
if (!url || url.trim().length === 0) {
|
|
2149
|
+
return {
|
|
2150
|
+
valid: false,
|
|
2151
|
+
error: 'URL is required',
|
|
2152
|
+
errorCode: 'MISSING_URL',
|
|
2153
|
+
};
|
|
2154
|
+
}
|
|
2155
|
+
const trimmedUrl = url.trim();
|
|
2156
|
+
// Check for common URL issues
|
|
2157
|
+
if (trimmedUrl.length > 2048) {
|
|
2158
|
+
return {
|
|
2159
|
+
valid: false,
|
|
2160
|
+
error: 'URL is too long',
|
|
2161
|
+
errorCode: 'INVALID_URL',
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
try {
|
|
2165
|
+
const urlObj = new URL(trimmedUrl);
|
|
2166
|
+
// Validate protocol
|
|
2167
|
+
if (!['http:', 'https:', 'ftp:', 'ftps:'].includes(urlObj.protocol)) {
|
|
2110
2168
|
return {
|
|
2111
|
-
|
|
2112
|
-
|
|
2169
|
+
valid: false,
|
|
2170
|
+
error: 'Invalid URL protocol',
|
|
2171
|
+
errorCode: 'INVALID_URL',
|
|
2113
2172
|
};
|
|
2114
|
-
}
|
|
2115
|
-
|
|
2116
|
-
}
|
|
2117
|
-
class AXConversationAudioFileTypeProvider extends AXFileTypeInfoProvider {
|
|
2118
|
-
items() {
|
|
2119
|
-
return Promise.resolve([createConversationAudioFileType()]);
|
|
2173
|
+
}
|
|
2174
|
+
return { valid: true };
|
|
2120
2175
|
}
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
}] });
|
|
2128
|
-
|
|
2129
|
-
function audioItemFromUpload(file, result, duration) {
|
|
2130
|
-
const url = resolvePersistableMediaUrl(result.url);
|
|
2131
|
-
const item = {
|
|
2132
|
-
mediaId: result.mediaId,
|
|
2133
|
-
mimeType: result.mimeType,
|
|
2134
|
-
size: result.size,
|
|
2135
|
-
duration,
|
|
2136
|
-
title: file.name,
|
|
2137
|
-
metadata: result.metadata,
|
|
2138
|
-
};
|
|
2139
|
-
if (url) {
|
|
2140
|
-
item.url = url;
|
|
2176
|
+
catch {
|
|
2177
|
+
return {
|
|
2178
|
+
valid: false,
|
|
2179
|
+
error: 'Invalid URL format',
|
|
2180
|
+
errorCode: 'INVALID_URL',
|
|
2181
|
+
};
|
|
2141
2182
|
}
|
|
2142
|
-
return item;
|
|
2143
2183
|
}
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
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, '&')
|
|
2198
|
+
.replace(/</g, '<')
|
|
2199
|
+
.replace(/>/g, '>')
|
|
2200
|
+
.replace(/"/g, '"')
|
|
2201
|
+
.replace(/'/g, ''')
|
|
2202
|
+
.replace(/\//g, '/')
|
|
2203
|
+
.replace(/`/g, '`')
|
|
2204
|
+
.replace(/=/g, '=');
|
|
2165
2205
|
}
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2206
|
+
/**
|
|
2207
|
+
* Validate latitude coordinate
|
|
2208
|
+
* @param latitude - Latitude to validate
|
|
2209
|
+
* @returns Validation result
|
|
2210
|
+
*/
|
|
2211
|
+
function validateLatitude(latitude) {
|
|
2212
|
+
if (latitude === undefined || latitude === null || typeof latitude !== 'number' || isNaN(latitude)) {
|
|
2213
|
+
return {
|
|
2214
|
+
valid: false,
|
|
2215
|
+
error: 'Latitude is required',
|
|
2216
|
+
errorCode: 'MISSING_LATITUDE',
|
|
2217
|
+
};
|
|
2173
2218
|
}
|
|
2174
|
-
|
|
2175
|
-
|
|
2219
|
+
if (latitude < -90 || latitude > 90) {
|
|
2220
|
+
return {
|
|
2221
|
+
valid: false,
|
|
2222
|
+
error: 'Latitude must be between -90 and 90',
|
|
2223
|
+
errorCode: 'INVALID_LATITUDE',
|
|
2224
|
+
};
|
|
2176
2225
|
}
|
|
2177
|
-
|
|
2178
|
-
|
|
2226
|
+
return { valid: true };
|
|
2227
|
+
}
|
|
2228
|
+
/**
|
|
2229
|
+
* Validate longitude coordinate
|
|
2230
|
+
* @param longitude - Longitude to validate
|
|
2231
|
+
* @returns Validation result
|
|
2232
|
+
*/
|
|
2233
|
+
function validateLongitude(longitude) {
|
|
2234
|
+
if (longitude === undefined || longitude === null || typeof longitude !== 'number' || isNaN(longitude)) {
|
|
2235
|
+
return {
|
|
2236
|
+
valid: false,
|
|
2237
|
+
error: 'Longitude is required',
|
|
2238
|
+
errorCode: 'MISSING_LONGITUDE',
|
|
2239
|
+
};
|
|
2179
2240
|
}
|
|
2180
|
-
|
|
2241
|
+
if (longitude < -180 || longitude > 180) {
|
|
2242
|
+
return {
|
|
2243
|
+
valid: false,
|
|
2244
|
+
error: 'Longitude must be between -180 and 180',
|
|
2245
|
+
errorCode: 'INVALID_LONGITUDE',
|
|
2246
|
+
};
|
|
2247
|
+
}
|
|
2248
|
+
return { valid: true };
|
|
2181
2249
|
}
|
|
2182
2250
|
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
};
|
|
2189
|
-
const IMAGE_UTILITY = {
|
|
2190
|
-
preview: 'preview',
|
|
2191
|
-
formatSize: 'formatSize',
|
|
2192
|
-
createLocalPreviewUrl: 'createLocalPreviewUrl',
|
|
2193
|
-
};
|
|
2194
|
-
function blobUrl$3(ctx, blob) {
|
|
2195
|
-
if (!isPlatformBrowser(ctx.platformId)) {
|
|
2196
|
-
return '';
|
|
2197
|
-
}
|
|
2198
|
-
return URL.createObjectURL(blob);
|
|
2251
|
+
/**
|
|
2252
|
+
* In-memory conversation and message graph (signal-based).
|
|
2253
|
+
* Plain class — not DI-registered; instantiated by `AXConversationService`.
|
|
2254
|
+
*/
|
|
2255
|
+
function withNormalizedPayload(message) {
|
|
2256
|
+
return { ...message, payload: normalizeMessagePayload(message.payload) };
|
|
2199
2257
|
}
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2258
|
+
class ConversationState {
|
|
2259
|
+
constructor(config) {
|
|
2260
|
+
this.config = config;
|
|
2261
|
+
this._conversations = signal(new Map(), ...(ngDevMode ? [{ debugName: "_conversations" }] : []));
|
|
2262
|
+
this._messages = signal(new Map(), ...(ngDevMode ? [{ debugName: "_messages" }] : []));
|
|
2263
|
+
this._conversationMessages = signal(new Map(), ...(ngDevMode ? [{ debugName: "_conversationMessages" }] : []));
|
|
2264
|
+
this.conversations = computed(() => {
|
|
2265
|
+
const convMap = this._conversations();
|
|
2266
|
+
return Array.from(convMap.values());
|
|
2267
|
+
}, ...(ngDevMode ? [{ debugName: "conversations", equal: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]) }] : [{
|
|
2268
|
+
equal: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]),
|
|
2269
|
+
}]));
|
|
2270
|
+
}
|
|
2271
|
+
setConversations(conversations) {
|
|
2272
|
+
const convMap = new Map();
|
|
2273
|
+
conversations.forEach((conv) => convMap.set(conv.id, conv));
|
|
2274
|
+
this._conversations.set(convMap);
|
|
2275
|
+
}
|
|
2276
|
+
addConversations(conversations) {
|
|
2277
|
+
this._conversations.update((existingConversations) => {
|
|
2278
|
+
const newConversations = new Map(existingConversations);
|
|
2279
|
+
conversations.forEach((conv) => newConversations.set(conv.id, conv));
|
|
2280
|
+
const maxCached = this.config.maxCachedConversations;
|
|
2281
|
+
if (newConversations.size > maxCached) {
|
|
2282
|
+
const sorted = Array.from(newConversations.values()).sort((a, b) => (b.lastMessageAt?.getTime() ?? 0) - (a.lastMessageAt?.getTime() ?? 0));
|
|
2283
|
+
const toKeep = sorted.slice(0, maxCached);
|
|
2284
|
+
const cleanedMap = new Map();
|
|
2285
|
+
toKeep.forEach((conv) => cleanedMap.set(conv.id, conv));
|
|
2286
|
+
return cleanedMap;
|
|
2208
2287
|
}
|
|
2209
|
-
return
|
|
2210
|
-
}
|
|
2211
|
-
};
|
|
2212
|
-
}
|
|
2213
|
-
function createConversationImageFileType() {
|
|
2214
|
-
const presentation = CONVERSATION_IMAGE_PRESENTATION;
|
|
2215
|
-
return {
|
|
2216
|
-
name: CONVERSATION_IMAGE_CATALOG,
|
|
2217
|
-
metadata: createFileTypeMetadata('conversation'),
|
|
2218
|
-
title: presentation.title,
|
|
2219
|
-
icon: presentation.icon,
|
|
2220
|
-
validations: {
|
|
2221
|
-
mimeTypes: ['image/*'],
|
|
2222
|
-
minSize: 1,
|
|
2223
|
-
maxSize: 100 * MB$3,
|
|
2224
|
-
},
|
|
2225
|
-
extensions: [
|
|
2226
|
-
{ name: 'jpg', title: 'JPEG' },
|
|
2227
|
-
{ name: 'jpeg', title: 'JPEG', validations: { maxSize: 5 * MB$3 } },
|
|
2228
|
-
{ name: 'png', title: 'PNG' },
|
|
2229
|
-
{ name: 'gif', title: 'GIF' },
|
|
2230
|
-
{ name: 'webp', title: 'WebP' },
|
|
2231
|
-
{ name: 'svg', title: 'SVG', validations: { maxSize: 2 * MB$3 } },
|
|
2232
|
-
],
|
|
2233
|
-
utilities: conversationImageUtilities(),
|
|
2234
|
-
copy: (payload) => {
|
|
2235
|
-
const image = normalizeImagePayload(payload);
|
|
2236
|
-
const caption = image.caption?.trim();
|
|
2237
|
-
const urls = image.images
|
|
2238
|
-
.map((item) => item.url?.trim() || item.thumbnailUrl?.trim())
|
|
2239
|
-
.filter((url) => !!url);
|
|
2240
|
-
return {
|
|
2241
|
-
text: caption ?? '',
|
|
2242
|
-
meta: { kind: 'image', caption, urls, count: image.images.length },
|
|
2243
|
-
};
|
|
2244
|
-
},
|
|
2245
|
-
};
|
|
2246
|
-
}
|
|
2247
|
-
class AXConversationImageFileTypeProvider extends AXFileTypeInfoProvider {
|
|
2248
|
-
items() {
|
|
2249
|
-
return Promise.resolve([createConversationImageFileType()]);
|
|
2288
|
+
return newConversations;
|
|
2289
|
+
});
|
|
2250
2290
|
}
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
}] });
|
|
2258
|
-
|
|
2259
|
-
const CONVERSATION_VIDEO_CATALOG = 'conversation-video';
|
|
2260
|
-
const MB$2 = 1024 * 1024;
|
|
2261
|
-
const CONVERSATION_VIDEO_PRESENTATION = {
|
|
2262
|
-
icon: 'fa-light fa-video ax-text-blue-500',
|
|
2263
|
-
title: 'Video',
|
|
2264
|
-
};
|
|
2265
|
-
const VIDEO_UTILITY = {
|
|
2266
|
-
preview: 'preview',
|
|
2267
|
-
duration: 'duration',
|
|
2268
|
-
formatSize: 'formatSize',
|
|
2269
|
-
formatDuration: 'formatDuration',
|
|
2270
|
-
createLocalPreviewUrl: 'createLocalPreviewUrl',
|
|
2271
|
-
};
|
|
2272
|
-
function blobUrl$2(ctx, blob) {
|
|
2273
|
-
if (!isPlatformBrowser(ctx.platformId)) {
|
|
2274
|
-
return '';
|
|
2291
|
+
setConversation(conversation) {
|
|
2292
|
+
this._conversations.update((conversations) => {
|
|
2293
|
+
const newConversations = new Map(conversations);
|
|
2294
|
+
newConversations.set(conversation.id, conversation);
|
|
2295
|
+
return newConversations;
|
|
2296
|
+
});
|
|
2275
2297
|
}
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
function mediaDuration$1(ctx, file, tag) {
|
|
2279
|
-
if (!isPlatformBrowser(ctx.platformId)) {
|
|
2280
|
-
return Promise.resolve(0);
|
|
2298
|
+
getConversation(conversationId) {
|
|
2299
|
+
return this._conversations().get(conversationId);
|
|
2281
2300
|
}
|
|
2282
|
-
|
|
2283
|
-
const
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
resolve(el.duration);
|
|
2288
|
-
};
|
|
2289
|
-
el.onerror = () => {
|
|
2290
|
-
URL.revokeObjectURL(el.src);
|
|
2291
|
-
reject(new Error(`Failed to load ${tag} metadata`));
|
|
2292
|
-
};
|
|
2293
|
-
el.src = URL.createObjectURL(file);
|
|
2294
|
-
});
|
|
2295
|
-
}
|
|
2296
|
-
function conversationVideoUtilities() {
|
|
2297
|
-
return {
|
|
2298
|
-
[VIDEO_UTILITY.preview]: (ctx, file) => ctx.readAsDataUrl(file),
|
|
2299
|
-
[VIDEO_UTILITY.duration]: (ctx, file) => mediaDuration$1(ctx, file, 'video'),
|
|
2300
|
-
[VIDEO_UTILITY.formatSize]: (_ctx, bytes) => formatFileSize(bytes),
|
|
2301
|
-
[VIDEO_UTILITY.formatDuration]: (_ctx, seconds) => formatDuration(seconds),
|
|
2302
|
-
[VIDEO_UTILITY.createLocalPreviewUrl]: async (ctx, source) => {
|
|
2303
|
-
const blob = source;
|
|
2304
|
-
if (blob.type.startsWith('image/')) {
|
|
2305
|
-
return ctx.readAsDataUrl(blob);
|
|
2306
|
-
}
|
|
2307
|
-
return blobUrl$2(ctx, blob);
|
|
2308
|
-
},
|
|
2309
|
-
};
|
|
2310
|
-
}
|
|
2311
|
-
function createConversationVideoFileType() {
|
|
2312
|
-
const presentation = CONVERSATION_VIDEO_PRESENTATION;
|
|
2313
|
-
return {
|
|
2314
|
-
name: CONVERSATION_VIDEO_CATALOG,
|
|
2315
|
-
metadata: createFileTypeMetadata('conversation'),
|
|
2316
|
-
title: presentation.title,
|
|
2317
|
-
icon: presentation.icon,
|
|
2318
|
-
validations: {
|
|
2319
|
-
mimeTypes: ['video/*'],
|
|
2320
|
-
minSize: 1,
|
|
2321
|
-
maxSize: 500 * MB$2,
|
|
2322
|
-
},
|
|
2323
|
-
extensions: [
|
|
2324
|
-
{ name: 'mp4', title: 'MP4' },
|
|
2325
|
-
{ name: 'webm', title: 'WebM', validations: { maxSize: 200 * MB$2 } },
|
|
2326
|
-
{ name: 'ogg', title: 'OGG' },
|
|
2327
|
-
],
|
|
2328
|
-
utilities: conversationVideoUtilities(),
|
|
2329
|
-
copy: (payload) => {
|
|
2330
|
-
const video = normalizeVideoPayload(payload);
|
|
2331
|
-
const caption = video.caption?.trim();
|
|
2332
|
-
const urls = video.videos.map((item) => item.url?.trim()).filter((url) => !!url);
|
|
2333
|
-
return {
|
|
2334
|
-
text: caption ?? '',
|
|
2335
|
-
meta: { kind: 'video', caption, urls, count: video.videos.length },
|
|
2336
|
-
};
|
|
2337
|
-
},
|
|
2338
|
-
};
|
|
2339
|
-
}
|
|
2340
|
-
class AXConversationVideoFileTypeProvider extends AXFileTypeInfoProvider {
|
|
2341
|
-
items() {
|
|
2342
|
-
return Promise.resolve([createConversationVideoFileType()]);
|
|
2301
|
+
updateConversation(conversationId, updates) {
|
|
2302
|
+
const conversation = this._conversations().get(conversationId);
|
|
2303
|
+
if (!conversation)
|
|
2304
|
+
return;
|
|
2305
|
+
this.setConversation({ ...conversation, ...updates });
|
|
2343
2306
|
}
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
const
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
'audio/*',
|
|
2362
|
-
'application/pdf',
|
|
2363
|
-
'application/msword',
|
|
2364
|
-
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
2365
|
-
'application/vnd.ms-excel',
|
|
2366
|
-
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
2367
|
-
'application/zip',
|
|
2368
|
-
'application/x-zip-compressed',
|
|
2369
|
-
'application/x-7z-compressed',
|
|
2370
|
-
'application/vnd.rar',
|
|
2371
|
-
'application/octet-stream',
|
|
2372
|
-
'text/plain',
|
|
2373
|
-
];
|
|
2374
|
-
const FILE_UTILITY = {
|
|
2375
|
-
preview: 'preview',
|
|
2376
|
-
formatSize: 'formatSize',
|
|
2377
|
-
createLocalPreviewUrl: 'createLocalPreviewUrl',
|
|
2378
|
-
pickerCatalog: 'pickerCatalog',
|
|
2379
|
-
};
|
|
2380
|
-
function blobUrl$1(ctx, blob) {
|
|
2381
|
-
if (!isPlatformBrowser(ctx.platformId)) {
|
|
2382
|
-
return '';
|
|
2307
|
+
deleteConversation(conversationId) {
|
|
2308
|
+
this._conversations.update((conversations) => {
|
|
2309
|
+
const newConversations = new Map(conversations);
|
|
2310
|
+
newConversations.delete(conversationId);
|
|
2311
|
+
return newConversations;
|
|
2312
|
+
});
|
|
2313
|
+
const messageIds = this._conversationMessages().get(conversationId) || [];
|
|
2314
|
+
this._messages.update((messages) => {
|
|
2315
|
+
const newMessages = new Map(messages);
|
|
2316
|
+
messageIds.forEach((id) => newMessages.delete(id));
|
|
2317
|
+
return newMessages;
|
|
2318
|
+
});
|
|
2319
|
+
this._conversationMessages.update((map) => {
|
|
2320
|
+
const newMap = new Map(map);
|
|
2321
|
+
newMap.delete(conversationId);
|
|
2322
|
+
return newMap;
|
|
2323
|
+
});
|
|
2383
2324
|
}
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2325
|
+
updateLastMessage(message) {
|
|
2326
|
+
this.updateConversation(message.conversationId, {
|
|
2327
|
+
lastMessage: message,
|
|
2328
|
+
lastMessageAt: message.timestamp,
|
|
2329
|
+
updatedAt: message.timestamp,
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2332
|
+
incrementUnreadCount(conversationId) {
|
|
2333
|
+
const conversation = this._conversations().get(conversationId);
|
|
2334
|
+
if (!conversation)
|
|
2335
|
+
return;
|
|
2336
|
+
this.updateConversation(conversationId, { unreadCount: conversation.unreadCount + 1 });
|
|
2337
|
+
}
|
|
2338
|
+
resetUnreadCount(conversationId) {
|
|
2339
|
+
this.updateConversation(conversationId, { unreadCount: 0 });
|
|
2340
|
+
}
|
|
2341
|
+
updateSettings(conversationId, settings) {
|
|
2342
|
+
const conversation = this._conversations().get(conversationId);
|
|
2343
|
+
if (!conversation)
|
|
2344
|
+
return;
|
|
2345
|
+
this.updateConversation(conversationId, {
|
|
2346
|
+
settings: { ...conversation.settings, ...settings },
|
|
2347
|
+
updatedAt: new Date(),
|
|
2348
|
+
});
|
|
2349
|
+
}
|
|
2350
|
+
updateTitle(conversationId, title) {
|
|
2351
|
+
this.updateConversation(conversationId, { title, updatedAt: new Date() });
|
|
2352
|
+
}
|
|
2353
|
+
updateMetadata(conversationId, metadata) {
|
|
2354
|
+
const conversation = this._conversations().get(conversationId);
|
|
2355
|
+
if (!conversation)
|
|
2356
|
+
return;
|
|
2357
|
+
this.updateConversation(conversationId, {
|
|
2358
|
+
metadata: { ...conversation.metadata, ...metadata },
|
|
2359
|
+
updatedAt: new Date(),
|
|
2360
|
+
});
|
|
2361
|
+
}
|
|
2362
|
+
updateTypingIndicator(conversationId, userId, isTyping) {
|
|
2363
|
+
const conversation = this._conversations().get(conversationId);
|
|
2364
|
+
if (!conversation)
|
|
2365
|
+
return;
|
|
2366
|
+
let typingUsers = [...conversation.status.typingUsers];
|
|
2367
|
+
if (isTyping) {
|
|
2368
|
+
if (!typingUsers.includes(userId)) {
|
|
2369
|
+
typingUsers.push(userId);
|
|
2392
2370
|
}
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
}
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
const url = item.url?.trim();
|
|
2442
|
-
const line = name && url ? `${name} — ${url}` : url || name;
|
|
2443
|
-
return { name, url, line };
|
|
2371
|
+
}
|
|
2372
|
+
else {
|
|
2373
|
+
typingUsers = typingUsers.filter((id) => id !== userId);
|
|
2374
|
+
}
|
|
2375
|
+
this.updateConversation(conversationId, {
|
|
2376
|
+
status: {
|
|
2377
|
+
...conversation.status,
|
|
2378
|
+
isTyping: typingUsers.length > 0,
|
|
2379
|
+
typingUsers,
|
|
2380
|
+
},
|
|
2381
|
+
});
|
|
2382
|
+
}
|
|
2383
|
+
updateParticipantPresence(userId, status, lastSeen) {
|
|
2384
|
+
this._conversations.update((conversations) => {
|
|
2385
|
+
const newConversations = new Map(conversations);
|
|
2386
|
+
for (const [id, conv] of conversations) {
|
|
2387
|
+
const participant = conv.participants.find((p) => p.id === userId);
|
|
2388
|
+
if (participant) {
|
|
2389
|
+
const updatedConversation = {
|
|
2390
|
+
...conv,
|
|
2391
|
+
participants: conv.participants.map((p) => (p.id === userId ? { ...p, status, lastSeen } : p)),
|
|
2392
|
+
status: conv.type === 'private' ? { ...conv.status, presence: status, lastSeen } : conv.status,
|
|
2393
|
+
};
|
|
2394
|
+
newConversations.set(id, updatedConversation);
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
return newConversations;
|
|
2398
|
+
});
|
|
2399
|
+
}
|
|
2400
|
+
addMessage(message) {
|
|
2401
|
+
const normalized = withNormalizedPayload(message);
|
|
2402
|
+
this._messages.update((messages) => {
|
|
2403
|
+
const newMessages = new Map(messages);
|
|
2404
|
+
newMessages.set(normalized.id, normalized);
|
|
2405
|
+
return newMessages;
|
|
2406
|
+
});
|
|
2407
|
+
this._conversationMessages.update((map) => {
|
|
2408
|
+
const newMap = new Map(map);
|
|
2409
|
+
const existing = newMap.get(normalized.conversationId) || [];
|
|
2410
|
+
if (existing.includes(normalized.id)) {
|
|
2411
|
+
return newMap;
|
|
2412
|
+
}
|
|
2413
|
+
const updated = [...existing, normalized.id].sort((a, b) => {
|
|
2414
|
+
const msgA = this._messages().get(a);
|
|
2415
|
+
const msgB = this._messages().get(b);
|
|
2416
|
+
if (!msgA || !msgB)
|
|
2417
|
+
return 0;
|
|
2418
|
+
return msgA.timestamp.getTime() - msgB.timestamp.getTime();
|
|
2444
2419
|
});
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
};
|
|
2449
|
-
},
|
|
2450
|
-
};
|
|
2451
|
-
}
|
|
2452
|
-
class AXConversationFileFileTypeProvider extends AXFileTypeInfoProvider {
|
|
2453
|
-
items() {
|
|
2454
|
-
return Promise.resolve([createConversationFileFileType()]);
|
|
2420
|
+
newMap.set(normalized.conversationId, updated);
|
|
2421
|
+
return newMap;
|
|
2422
|
+
});
|
|
2455
2423
|
}
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2424
|
+
/**
|
|
2425
|
+
* Replace all messages for a conversation (initial page load).
|
|
2426
|
+
*/
|
|
2427
|
+
setConversationMessages(conversationId, messages) {
|
|
2428
|
+
const sorted = [...messages]
|
|
2429
|
+
.map(withNormalizedPayload)
|
|
2430
|
+
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
2431
|
+
const sortedIds = sorted.map((m) => m.id);
|
|
2432
|
+
this._messages.update((msgs) => {
|
|
2433
|
+
const newMessages = new Map(msgs);
|
|
2434
|
+
const previousIds = this._conversationMessages().get(conversationId) || [];
|
|
2435
|
+
for (const id of previousIds) {
|
|
2436
|
+
newMessages.delete(id);
|
|
2437
|
+
}
|
|
2438
|
+
for (const msg of sorted) {
|
|
2439
|
+
newMessages.set(msg.id, msg);
|
|
2440
|
+
}
|
|
2441
|
+
return newMessages;
|
|
2442
|
+
});
|
|
2443
|
+
this._conversationMessages.update((map) => {
|
|
2444
|
+
const newMap = new Map(map);
|
|
2445
|
+
newMap.set(conversationId, sortedIds);
|
|
2446
|
+
return newMap;
|
|
2447
|
+
});
|
|
2448
|
+
this.cleanupOldMessages();
|
|
2478
2449
|
}
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2450
|
+
addMessages(messages) {
|
|
2451
|
+
if (messages.length === 0)
|
|
2452
|
+
return;
|
|
2453
|
+
const normalized = messages.map(withNormalizedPayload);
|
|
2454
|
+
this._messages.update((msgs) => {
|
|
2455
|
+
const newMessages = new Map(msgs);
|
|
2456
|
+
normalized.forEach((msg) => newMessages.set(msg.id, msg));
|
|
2457
|
+
return newMessages;
|
|
2458
|
+
});
|
|
2459
|
+
const conversationGroups = new Map();
|
|
2460
|
+
normalized.forEach((msg) => {
|
|
2461
|
+
const existing = conversationGroups.get(msg.conversationId) || [];
|
|
2462
|
+
existing.push(msg.id);
|
|
2463
|
+
conversationGroups.set(msg.conversationId, existing);
|
|
2464
|
+
});
|
|
2465
|
+
this._conversationMessages.update((map) => {
|
|
2466
|
+
const newMap = new Map(map);
|
|
2467
|
+
for (const [conversationId, newMsgIds] of conversationGroups) {
|
|
2468
|
+
const existing = newMap.get(conversationId) || [];
|
|
2469
|
+
const idSet = new Set(existing);
|
|
2470
|
+
const merged = [...existing];
|
|
2471
|
+
for (const id of newMsgIds) {
|
|
2472
|
+
if (!idSet.has(id)) {
|
|
2473
|
+
merged.push(id);
|
|
2474
|
+
idSet.add(id);
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
const sorted = merged.sort((a, b) => {
|
|
2478
|
+
const msgA = this._messages().get(a);
|
|
2479
|
+
const msgB = this._messages().get(b);
|
|
2480
|
+
if (!msgA || !msgB)
|
|
2481
|
+
return 0;
|
|
2482
|
+
return msgA.timestamp.getTime() - msgB.timestamp.getTime();
|
|
2483
|
+
});
|
|
2484
|
+
newMap.set(conversationId, sorted);
|
|
2485
|
+
}
|
|
2486
|
+
return newMap;
|
|
2487
|
+
});
|
|
2488
|
+
this.cleanupOldMessages();
|
|
2489
|
+
this.cleanupConversationMessages();
|
|
2499
2490
|
}
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
else
|
|
2503
|
-
files.push(next);
|
|
2504
|
-
return { ...base, type: 'file', files };
|
|
2505
|
-
}
|
|
2506
|
-
function applyFileLocalPreview(payload, localUrl, mimeType = 'application/octet-stream') {
|
|
2507
|
-
const base = normalizeFilePayload(payload);
|
|
2508
|
-
const files = [...base.files];
|
|
2509
|
-
const slot = files.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
|
|
2510
|
-
const preview = { url: localUrl, name: 'upload', mimeType };
|
|
2511
|
-
if (slot >= 0) {
|
|
2512
|
-
files[slot] = { ...files[slot], ...preview };
|
|
2491
|
+
getMessage(messageId) {
|
|
2492
|
+
return this._messages().get(messageId);
|
|
2513
2493
|
}
|
|
2514
|
-
|
|
2515
|
-
|
|
2494
|
+
getConversationMessages(conversationId) {
|
|
2495
|
+
const messageIds = this._conversationMessages().get(conversationId) || [];
|
|
2496
|
+
return messageIds.map((id) => this._messages().get(id)).filter((msg) => msg !== undefined);
|
|
2516
2497
|
}
|
|
2517
|
-
|
|
2518
|
-
|
|
2498
|
+
updateMessage(messageId, updates) {
|
|
2499
|
+
const message = this._messages().get(messageId);
|
|
2500
|
+
if (!message)
|
|
2501
|
+
return;
|
|
2502
|
+
const merged = { ...message, ...updates };
|
|
2503
|
+
if (updates.payload) {
|
|
2504
|
+
merged.payload = normalizeMessagePayload(merged.payload);
|
|
2505
|
+
}
|
|
2506
|
+
this.addMessage(merged);
|
|
2519
2507
|
}
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2508
|
+
deleteMessage(messageId) {
|
|
2509
|
+
const message = this._messages().get(messageId);
|
|
2510
|
+
if (!message)
|
|
2511
|
+
return;
|
|
2512
|
+
this._messages.update((messages) => {
|
|
2513
|
+
const newMessages = new Map(messages);
|
|
2514
|
+
newMessages.delete(messageId);
|
|
2515
|
+
return newMessages;
|
|
2516
|
+
});
|
|
2517
|
+
this._conversationMessages.update((map) => {
|
|
2518
|
+
const newMap = new Map(map);
|
|
2519
|
+
const existing = newMap.get(message.conversationId);
|
|
2520
|
+
if (existing) {
|
|
2521
|
+
newMap.set(message.conversationId, existing.filter((id) => id !== messageId));
|
|
2522
|
+
}
|
|
2523
|
+
return newMap;
|
|
2524
|
+
});
|
|
2537
2525
|
}
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2526
|
+
cleanupOldMessages() {
|
|
2527
|
+
const totalMessages = this._messages().size;
|
|
2528
|
+
if (totalMessages > this.config.maxTotalMessages) {
|
|
2529
|
+
const allMessages = Array.from(this._messages().values()).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
|
2530
|
+
const toKeep = allMessages.slice(0, this.config.maxTotalMessages);
|
|
2531
|
+
const toKeepIds = new Set(toKeep.map((m) => m.id));
|
|
2532
|
+
this._messages.update(() => {
|
|
2533
|
+
const newMessages = new Map();
|
|
2534
|
+
toKeep.forEach((msg) => newMessages.set(msg.id, msg));
|
|
2535
|
+
return newMessages;
|
|
2536
|
+
});
|
|
2537
|
+
this._conversationMessages.update((map) => {
|
|
2538
|
+
const newMap = new Map(map);
|
|
2539
|
+
for (const [convId, messageIds] of newMap) {
|
|
2540
|
+
const filteredIds = messageIds.filter((id) => toKeepIds.has(id));
|
|
2541
|
+
newMap.set(convId, filteredIds);
|
|
2542
|
+
}
|
|
2543
|
+
return newMap;
|
|
2544
|
+
});
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
cleanupConversationMessages() {
|
|
2548
|
+
const maxMessages = this.config.maxMessagesPerConversation;
|
|
2549
|
+
const idsToRemove = [];
|
|
2550
|
+
this._conversationMessages.update((map) => {
|
|
2551
|
+
const newMap = new Map(map);
|
|
2552
|
+
for (const [convId, messageIds] of newMap) {
|
|
2553
|
+
if (messageIds.length > maxMessages) {
|
|
2554
|
+
idsToRemove.push(...messageIds.slice(0, messageIds.length - maxMessages));
|
|
2555
|
+
newMap.set(convId, messageIds.slice(-maxMessages));
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
return newMap;
|
|
2559
|
+
});
|
|
2560
|
+
if (idsToRemove.length > 0) {
|
|
2561
|
+
this._messages.update((messages) => {
|
|
2562
|
+
const newMessages = new Map(messages);
|
|
2563
|
+
idsToRemove.forEach((id) => newMessages.delete(id));
|
|
2564
|
+
return newMessages;
|
|
2565
|
+
});
|
|
2566
|
+
}
|
|
2556
2567
|
}
|
|
2557
|
-
if (i >= 0)
|
|
2558
|
-
videos[i] = next;
|
|
2559
|
-
else
|
|
2560
|
-
videos.push(next);
|
|
2561
|
-
return { ...base, type: 'video', videos };
|
|
2562
2568
|
}
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2569
|
+
|
|
2570
|
+
/**
|
|
2571
|
+
* Error Handler Service
|
|
2572
|
+
* Centralized error handling and logging
|
|
2573
|
+
*/
|
|
2574
|
+
/**
|
|
2575
|
+
* Error Handler Service
|
|
2576
|
+
*/
|
|
2577
|
+
class AXErrorHandlerService {
|
|
2578
|
+
constructor() {
|
|
2579
|
+
this.injectedConfig = inject(ERROR_HANDLER_CONFIG);
|
|
2580
|
+
this._errors$ = new Subject();
|
|
2581
|
+
this._config = {
|
|
2582
|
+
logToConsole: true,
|
|
2583
|
+
showUserMessages: true,
|
|
2584
|
+
autoRetry: false,
|
|
2585
|
+
maxRetries: 3,
|
|
2586
|
+
};
|
|
2587
|
+
/** Error stream */
|
|
2588
|
+
this.errors$ = this._errors$.asObservable();
|
|
2589
|
+
this.configure(this.injectedConfig);
|
|
2570
2590
|
}
|
|
2571
|
-
|
|
2572
|
-
|
|
2591
|
+
/**
|
|
2592
|
+
* Configure error handler
|
|
2593
|
+
*/
|
|
2594
|
+
configure(config) {
|
|
2595
|
+
Object.assign(this._config, config);
|
|
2573
2596
|
}
|
|
2574
|
-
|
|
2575
|
-
|
|
2597
|
+
/**
|
|
2598
|
+
* Handle an error
|
|
2599
|
+
*/
|
|
2600
|
+
handle(error, operation, context) {
|
|
2601
|
+
const conversationError = this.normalizeError(error, operation, context);
|
|
2602
|
+
this.publish(conversationError);
|
|
2603
|
+
return conversationError;
|
|
2576
2604
|
}
|
|
2577
|
-
|
|
2578
|
-
}
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
const
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
title: 'Voice message',
|
|
2585
|
-
};
|
|
2586
|
-
const VOICE_UTILITY = {
|
|
2587
|
-
duration: 'duration',
|
|
2588
|
-
formatDuration: 'formatDuration',
|
|
2589
|
-
createLocalPreviewUrl: 'createLocalPreviewUrl',
|
|
2590
|
-
};
|
|
2591
|
-
function blobUrl(ctx, blob) {
|
|
2592
|
-
if (!isPlatformBrowser(ctx.platformId)) {
|
|
2593
|
-
return '';
|
|
2605
|
+
/**
|
|
2606
|
+
* Handle API error (same pipeline as {@link handle}, for typed API failures)
|
|
2607
|
+
*/
|
|
2608
|
+
handleApiError(apiError, operation, context) {
|
|
2609
|
+
const conversationError = this.conversationErrorFromApi(apiError, operation, context);
|
|
2610
|
+
this.publish(conversationError);
|
|
2611
|
+
return conversationError;
|
|
2594
2612
|
}
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2613
|
+
publish(conversationError) {
|
|
2614
|
+
this._errors$.next(conversationError);
|
|
2615
|
+
if (this._config.logToConsole) {
|
|
2616
|
+
this.logError(conversationError);
|
|
2617
|
+
}
|
|
2618
|
+
if (this._config.customHandler) {
|
|
2619
|
+
this._config.customHandler(conversationError);
|
|
2620
|
+
}
|
|
2600
2621
|
}
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
}
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2622
|
+
/**
|
|
2623
|
+
* Normalize any error to conversation error format (does not publish — use {@link handle})
|
|
2624
|
+
*/
|
|
2625
|
+
normalizeError(error, operation, context) {
|
|
2626
|
+
if (this.isApiError(error)) {
|
|
2627
|
+
return this.conversationErrorFromApi(error, operation, context);
|
|
2628
|
+
}
|
|
2629
|
+
const errorObj = error;
|
|
2630
|
+
const message = (typeof errorObj['message'] === 'string' && errorObj['message']) ||
|
|
2631
|
+
(error instanceof Error ? error.message : String(error)) ||
|
|
2632
|
+
'An unknown error occurred';
|
|
2633
|
+
const code = (typeof errorObj['code'] === 'string' && errorObj['code']) || 'UNKNOWN_ERROR';
|
|
2634
|
+
const statusCodeRaw = errorObj['statusCode'] ?? errorObj['status'];
|
|
2635
|
+
const statusCode = typeof statusCodeRaw === 'number' ? statusCodeRaw : undefined;
|
|
2636
|
+
return {
|
|
2637
|
+
code,
|
|
2638
|
+
message,
|
|
2639
|
+
severity: 'error',
|
|
2640
|
+
operation,
|
|
2641
|
+
originalError: error,
|
|
2642
|
+
statusCode,
|
|
2643
|
+
context,
|
|
2644
|
+
timestamp: new Date(),
|
|
2645
|
+
handled: false,
|
|
2646
|
+
recoverySuggestions: this.getDefaultRecoverySuggestions(code),
|
|
2611
2647
|
};
|
|
2612
|
-
el.src = URL.createObjectURL(file);
|
|
2613
|
-
});
|
|
2614
|
-
}
|
|
2615
|
-
function conversationVoiceUtilities() {
|
|
2616
|
-
return {
|
|
2617
|
-
[VOICE_UTILITY.duration]: (ctx, file) => mediaDuration(ctx, file),
|
|
2618
|
-
[VOICE_UTILITY.formatDuration]: (_ctx, seconds) => formatDuration(seconds),
|
|
2619
|
-
[VOICE_UTILITY.createLocalPreviewUrl]: (ctx, source) => blobUrl(ctx, source),
|
|
2620
|
-
};
|
|
2621
|
-
}
|
|
2622
|
-
function createConversationVoiceFileType() {
|
|
2623
|
-
const presentation = CONVERSATION_VOICE_PRESENTATION;
|
|
2624
|
-
return {
|
|
2625
|
-
name: CONVERSATION_VOICE_CATALOG,
|
|
2626
|
-
metadata: createFileTypeMetadata('conversation'),
|
|
2627
|
-
title: presentation.title,
|
|
2628
|
-
icon: presentation.icon,
|
|
2629
|
-
validations: {
|
|
2630
|
-
mimeTypes: ['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/*'],
|
|
2631
|
-
minSize: 1,
|
|
2632
|
-
maxSize: 50 * MB,
|
|
2633
|
-
},
|
|
2634
|
-
utilities: conversationVoiceUtilities(),
|
|
2635
|
-
copy: (payload) => {
|
|
2636
|
-
const voice = payload;
|
|
2637
|
-
const url = voice.url?.trim() ?? '';
|
|
2638
|
-
return {
|
|
2639
|
-
text: '',
|
|
2640
|
-
meta: {
|
|
2641
|
-
kind: 'voice',
|
|
2642
|
-
url,
|
|
2643
|
-
duration: voice.duration,
|
|
2644
|
-
mimeType: voice.mimeType,
|
|
2645
|
-
},
|
|
2646
|
-
};
|
|
2647
|
-
},
|
|
2648
|
-
};
|
|
2649
|
-
}
|
|
2650
|
-
class AXConversationVoiceFileTypeProvider extends AXFileTypeInfoProvider {
|
|
2651
|
-
items() {
|
|
2652
|
-
return Promise.resolve([createConversationVoiceFileType()]);
|
|
2653
|
-
}
|
|
2654
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationVoiceFileTypeProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
2655
|
-
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationVoiceFileTypeProvider, providedIn: 'root' }); }
|
|
2656
|
-
}
|
|
2657
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationVoiceFileTypeProvider, decorators: [{
|
|
2658
|
-
type: Injectable,
|
|
2659
|
-
args: [{ providedIn: 'root' }]
|
|
2660
|
-
}] });
|
|
2661
|
-
|
|
2662
|
-
function mergeVoiceUploadResult(payload, result) {
|
|
2663
|
-
return {
|
|
2664
|
-
...payload,
|
|
2665
|
-
type: 'voice',
|
|
2666
|
-
url: result.url,
|
|
2667
|
-
mediaId: result.mediaId,
|
|
2668
|
-
mimeType: result.mimeType,
|
|
2669
|
-
size: result.size,
|
|
2670
|
-
metadata: result.metadata,
|
|
2671
|
-
};
|
|
2672
|
-
}
|
|
2673
|
-
function applyVoiceLocalPreview(payload, localUrl) {
|
|
2674
|
-
return { ...payload, type: 'voice', url: localUrl };
|
|
2675
|
-
}
|
|
2676
|
-
|
|
2677
|
-
const MESSAGE_TYPE_FILE_TYPE = {
|
|
2678
|
-
image: CONVERSATION_IMAGE_CATALOG,
|
|
2679
|
-
video: CONVERSATION_VIDEO_CATALOG,
|
|
2680
|
-
audio: CONVERSATION_AUDIO_CATALOG,
|
|
2681
|
-
file: CONVERSATION_FILE_CATALOG,
|
|
2682
|
-
voice: CONVERSATION_VOICE_CATALOG,
|
|
2683
|
-
sticker: CONVERSATION_IMAGE_CATALOG,
|
|
2684
|
-
};
|
|
2685
|
-
/** Resolves {@link AXMessage.fileType} from command or message type. */
|
|
2686
|
-
function resolveMessageFileType(type, fileType) {
|
|
2687
|
-
return fileType ?? MESSAGE_TYPE_FILE_TYPE[type];
|
|
2688
|
-
}
|
|
2689
|
-
function mergeImageUploadResult(payload, result) {
|
|
2690
|
-
const base = normalizeImagePayload(payload);
|
|
2691
|
-
const images = [...base.images];
|
|
2692
|
-
const i = images.findIndex((x) => !x.mediaId && !resolvePersistableMediaUrl(x.url));
|
|
2693
|
-
const slot = i >= 0 ? images[i] : undefined;
|
|
2694
|
-
const url = resolvePersistableMediaUrl(result.url);
|
|
2695
|
-
const next = {
|
|
2696
|
-
...(slot ?? { width: 0, height: 0 }),
|
|
2697
|
-
mediaId: result.mediaId,
|
|
2698
|
-
mimeType: result.mimeType,
|
|
2699
|
-
size: result.size,
|
|
2700
|
-
thumbnailUrl: resolvePersistedThumbnailUrl(result.thumbnailUrl ?? slot?.thumbnailUrl, url),
|
|
2701
|
-
metadata: result.metadata,
|
|
2702
|
-
};
|
|
2703
|
-
if (url) {
|
|
2704
|
-
next.url = url;
|
|
2705
2648
|
}
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2649
|
+
conversationErrorFromApi(apiError, operation, context) {
|
|
2650
|
+
return {
|
|
2651
|
+
code: apiError.code,
|
|
2652
|
+
message: apiError.message,
|
|
2653
|
+
severity: this.determineSeverity(apiError.statusCode),
|
|
2654
|
+
operation,
|
|
2655
|
+
originalError: apiError,
|
|
2656
|
+
statusCode: apiError.statusCode,
|
|
2657
|
+
context,
|
|
2658
|
+
timestamp: apiError.timestamp ?? new Date(),
|
|
2659
|
+
handled: false,
|
|
2660
|
+
recoverySuggestions: this.getRecoverySuggestions(apiError),
|
|
2661
|
+
};
|
|
2719
2662
|
}
|
|
2720
|
-
|
|
2721
|
-
|
|
2663
|
+
isApiError(error) {
|
|
2664
|
+
return (typeof error === 'object' &&
|
|
2665
|
+
error !== null &&
|
|
2666
|
+
'code' in error &&
|
|
2667
|
+
'message' in error &&
|
|
2668
|
+
typeof error.code === 'string' &&
|
|
2669
|
+
typeof error.message === 'string');
|
|
2722
2670
|
}
|
|
2723
|
-
|
|
2724
|
-
|
|
2671
|
+
/**
|
|
2672
|
+
* Determine severity based on status code
|
|
2673
|
+
*/
|
|
2674
|
+
determineSeverity(statusCode) {
|
|
2675
|
+
if (!statusCode)
|
|
2676
|
+
return 'error';
|
|
2677
|
+
if (statusCode >= 500)
|
|
2678
|
+
return 'critical';
|
|
2679
|
+
if (statusCode >= 400)
|
|
2680
|
+
return 'error';
|
|
2681
|
+
if (statusCode >= 300)
|
|
2682
|
+
return 'warning';
|
|
2683
|
+
return 'info';
|
|
2725
2684
|
}
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2685
|
+
/**
|
|
2686
|
+
* Get recovery suggestions based on error
|
|
2687
|
+
*/
|
|
2688
|
+
getRecoverySuggestions(error) {
|
|
2689
|
+
const suggestions = [];
|
|
2690
|
+
if (error.statusCode === 401 || error.code === 'UNAUTHORIZED') {
|
|
2691
|
+
suggestions.push('Please log in again');
|
|
2692
|
+
suggestions.push('Check if your session has expired');
|
|
2693
|
+
}
|
|
2694
|
+
else if (error.statusCode === 403 || error.code === 'FORBIDDEN') {
|
|
2695
|
+
suggestions.push('You do not have permission for this action');
|
|
2696
|
+
suggestions.push('Ask your administrator for access');
|
|
2697
|
+
}
|
|
2698
|
+
else if (error.statusCode === 404 || error.code === 'NOT_FOUND') {
|
|
2699
|
+
suggestions.push('The requested resource was not found');
|
|
2700
|
+
suggestions.push('It may have been deleted or moved');
|
|
2701
|
+
}
|
|
2702
|
+
else if (error.statusCode === 429 || error.code === 'RATE_LIMIT_EXCEEDED') {
|
|
2703
|
+
suggestions.push('Too many requests. Please wait and try again');
|
|
2704
|
+
}
|
|
2705
|
+
else if (error.statusCode && error.statusCode >= 500) {
|
|
2706
|
+
suggestions.push('Server error occurred');
|
|
2707
|
+
suggestions.push('Please try again later');
|
|
2708
|
+
suggestions.push('If the problem persists, reach out to support');
|
|
2709
|
+
}
|
|
2710
|
+
else if (error.code === 'NETWORK_ERROR') {
|
|
2711
|
+
suggestions.push('Check your internet connection');
|
|
2712
|
+
suggestions.push('Try refreshing the page');
|
|
2713
|
+
}
|
|
2714
|
+
return suggestions;
|
|
2749
2715
|
}
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2716
|
+
/**
|
|
2717
|
+
* Get default recovery suggestions
|
|
2718
|
+
*/
|
|
2719
|
+
getDefaultRecoverySuggestions(code) {
|
|
2720
|
+
const suggestions = [];
|
|
2721
|
+
if (code.includes('NETWORK') || code.includes('CONNECTION')) {
|
|
2722
|
+
suggestions.push('Check your internet connection');
|
|
2723
|
+
suggestions.push('Try refreshing the page');
|
|
2724
|
+
}
|
|
2725
|
+
else if (code.includes('TIMEOUT')) {
|
|
2726
|
+
suggestions.push('The operation took too long');
|
|
2727
|
+
suggestions.push('Please try again');
|
|
2728
|
+
}
|
|
2729
|
+
else {
|
|
2730
|
+
suggestions.push('Please try again');
|
|
2731
|
+
suggestions.push('If the problem persists, reach out to support');
|
|
2732
|
+
}
|
|
2733
|
+
return suggestions;
|
|
2767
2734
|
}
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2735
|
+
/**
|
|
2736
|
+
* Log error to console
|
|
2737
|
+
*/
|
|
2738
|
+
logError(error) {
|
|
2739
|
+
const isError = error.severity === 'critical' || error.severity === 'error';
|
|
2740
|
+
const header = `[Conversation ${error.severity.toUpperCase()}] ${error.operation}:`;
|
|
2741
|
+
if (isError) {
|
|
2742
|
+
console.error(header, error.message, error.context || '');
|
|
2774
2743
|
}
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
return { url: first?.url, mediaId: first?.mediaId, mimeType: first?.mimeType, size: first?.size };
|
|
2744
|
+
else {
|
|
2745
|
+
console.warn(header, error.message, error.context || '');
|
|
2778
2746
|
}
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
return { url: first?.url, mediaId: first?.mediaId, mimeType: first?.mimeType, size: first?.size };
|
|
2747
|
+
if (error.originalError && error.severity !== 'info') {
|
|
2748
|
+
console.error('Original error:', error.originalError);
|
|
2782
2749
|
}
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
return { url: first?.url, mediaId: first?.mediaId, mimeType: first?.mimeType, size: first?.size };
|
|
2750
|
+
if (error.recoverySuggestions && error.recoverySuggestions.length > 0) {
|
|
2751
|
+
console.info('Recovery suggestions:', error.recoverySuggestions);
|
|
2786
2752
|
}
|
|
2787
|
-
case 'voice':
|
|
2788
|
-
case 'sticker':
|
|
2789
|
-
return {
|
|
2790
|
-
url: payload.url,
|
|
2791
|
-
mediaId: payload.mediaId,
|
|
2792
|
-
mimeType: payload.mimeType,
|
|
2793
|
-
size: payload.size,
|
|
2794
|
-
};
|
|
2795
|
-
default:
|
|
2796
|
-
return {};
|
|
2797
2753
|
}
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2754
|
+
/**
|
|
2755
|
+
* Get user-friendly error message
|
|
2756
|
+
*/
|
|
2757
|
+
getUserFriendlyMessage(error) {
|
|
2758
|
+
// Map technical errors to user-friendly messages
|
|
2759
|
+
const messageMap = {
|
|
2760
|
+
NETWORK_ERROR: 'Unable to connect. Please check your internet connection.',
|
|
2761
|
+
UNAUTHORIZED: 'You are not authorized. Please log in again.',
|
|
2762
|
+
FORBIDDEN: 'You do not have permission to perform this action.',
|
|
2763
|
+
NOT_FOUND: 'The requested item could not be found.',
|
|
2764
|
+
RATE_LIMIT_EXCEEDED: 'Too many requests. Please slow down.',
|
|
2765
|
+
VALIDATION_ERROR: 'The provided data is invalid.',
|
|
2766
|
+
SERVER_ERROR: 'A server error occurred. Please try again later.',
|
|
2767
|
+
TIMEOUT: 'The operation timed out. Please try again.',
|
|
2768
|
+
};
|
|
2769
|
+
return messageMap[error.code] || error.message || 'An unexpected error occurred.';
|
|
2802
2770
|
}
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2771
|
+
/**
|
|
2772
|
+
* Check if error is retryable
|
|
2773
|
+
*/
|
|
2774
|
+
isRetryable(error) {
|
|
2775
|
+
const retryableCodes = ['NETWORK_ERROR', 'TIMEOUT', 'RATE_LIMIT_EXCEEDED', 'SERVER_ERROR'];
|
|
2776
|
+
const retryableStatusCodes = [408, 429, 500, 502, 503, 504];
|
|
2777
|
+
return (retryableCodes.includes(error.code) ||
|
|
2778
|
+
(error.statusCode !== undefined && retryableStatusCodes.includes(error.statusCode)));
|
|
2808
2779
|
}
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2780
|
+
/**
|
|
2781
|
+
* Execute an operation with automatic retry logic
|
|
2782
|
+
* @param operation - The async operation to execute
|
|
2783
|
+
* @param operationName - Name of the operation for error tracking
|
|
2784
|
+
* @param context - Additional context for error handling
|
|
2785
|
+
* @returns Promise resolving to the operation result
|
|
2786
|
+
* @throws {AXConversationError} If all retries fail
|
|
2787
|
+
*/
|
|
2788
|
+
async executeWithRetry(operation, operationName, context) {
|
|
2789
|
+
if (!this._config.autoRetry) {
|
|
2790
|
+
// If auto-retry is disabled, just execute once
|
|
2791
|
+
return operation();
|
|
2792
|
+
}
|
|
2793
|
+
const maxRetries = this._config.maxRetries ?? 3;
|
|
2794
|
+
let lastError;
|
|
2795
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
2796
|
+
try {
|
|
2797
|
+
return await operation();
|
|
2798
|
+
}
|
|
2799
|
+
catch (error) {
|
|
2800
|
+
lastError = error;
|
|
2801
|
+
const conversationError = this.handle(error, operationName, { ...context, attempt });
|
|
2802
|
+
// Don't retry if error is not retryable or if this was the last attempt
|
|
2803
|
+
if (!this.isRetryable(conversationError) || attempt >= maxRetries) {
|
|
2804
|
+
throw conversationError;
|
|
2805
|
+
}
|
|
2806
|
+
// Exponential backoff: 1s, 2s, 4s, 8s...
|
|
2807
|
+
const delayMs = Math.pow(2, attempt) * 1000;
|
|
2808
|
+
await this.delay(delayMs);
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
throw this.handle(lastError, operationName, context);
|
|
2814
2812
|
}
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2813
|
+
/**
|
|
2814
|
+
* Delay helper for retry backoff
|
|
2815
|
+
* @param ms - Milliseconds to delay
|
|
2816
|
+
*/
|
|
2817
|
+
delay(ms) {
|
|
2818
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2818
2819
|
}
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
file: source instanceof File ? source : undefined,
|
|
2822
|
-
mimeType: source.type,
|
|
2823
|
-
});
|
|
2824
|
-
const result = await runFileTypeUtility(fileType, ctx, extension, 'createLocalPreviewUrl', source);
|
|
2825
|
-
return typeof result === 'string' ? result : undefined;
|
|
2820
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXErrorHandlerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
2821
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXErrorHandlerService, providedIn: 'root' }); }
|
|
2826
2822
|
}
|
|
2823
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXErrorHandlerService, decorators: [{
|
|
2824
|
+
type: Injectable,
|
|
2825
|
+
args: [{
|
|
2826
|
+
providedIn: 'root',
|
|
2827
|
+
}]
|
|
2828
|
+
}], ctorParameters: () => [] });
|
|
2827
2829
|
|
|
2828
2830
|
/**
|
|
2829
2831
|
* AXComposerService
|
|
@@ -9097,7 +9099,7 @@ class AXConversationIndexedDbRealtimeApi extends AXRealtimeApi {
|
|
|
9097
9099
|
// Message Events
|
|
9098
9100
|
// =====================
|
|
9099
9101
|
subscribeToMessages(conversationId) {
|
|
9100
|
-
return conversationSharedStorage.messageStream$.pipe(filter((msg) => msg.conversationId === conversationId));
|
|
9102
|
+
return conversationSharedStorage.messageStream$.pipe(filter((msg) => !conversationId || msg.conversationId === conversationId));
|
|
9101
9103
|
}
|
|
9102
9104
|
subscribeToMessageUpdates(_conversationId) {
|
|
9103
9105
|
return conversationSharedStorage.messageUpdates$;
|
|
@@ -15064,9 +15066,7 @@ class AXConversationService {
|
|
|
15064
15066
|
return null;
|
|
15065
15067
|
}
|
|
15066
15068
|
const conversation = this.state.getConversation(id);
|
|
15067
|
-
return conversation
|
|
15068
|
-
? resolveConversationForViewer(conversation, this._currentUser()?.id)
|
|
15069
|
-
: null;
|
|
15069
|
+
return conversation ? resolveConversationForViewer(conversation, this._currentUser()?.id) : null;
|
|
15070
15070
|
}, ...(ngDevMode ? [{ debugName: "activeConversation" }] : []));
|
|
15071
15071
|
/** Messages for active conversation */
|
|
15072
15072
|
this.activeMessages = computed(() => {
|
|
@@ -15870,12 +15870,8 @@ class AXConversationService {
|
|
|
15870
15870
|
creatorId,
|
|
15871
15871
|
title: isPrivate ? undefined : metadata?.['title'],
|
|
15872
15872
|
description: metadata?.['description'],
|
|
15873
|
-
avatar: isPrivate
|
|
15874
|
-
|
|
15875
|
-
: AXConversationService.normalizeOptionalString(metadata?.['avatar']),
|
|
15876
|
-
icon: isPrivate
|
|
15877
|
-
? undefined
|
|
15878
|
-
: AXConversationService.normalizeOptionalString(metadata?.['icon']),
|
|
15873
|
+
avatar: isPrivate ? undefined : AXConversationService.normalizeOptionalString(metadata?.['avatar']),
|
|
15874
|
+
icon: isPrivate ? undefined : AXConversationService.normalizeOptionalString(metadata?.['icon']),
|
|
15879
15875
|
metadata: isPrivate ? undefined : metadata,
|
|
15880
15876
|
});
|
|
15881
15877
|
this.state.setConversation(conversation);
|
|
@@ -15924,7 +15920,9 @@ class AXConversationService {
|
|
|
15924
15920
|
const conversation = this.state.getConversation(conversationId);
|
|
15925
15921
|
const conversationTitle = conversation?.title || this.translation.translateSync('@acorex:chat.fallbacks.this-conversation');
|
|
15926
15922
|
// Show confirmation dialog
|
|
15927
|
-
const result = await this.dialogService.confirm(this.translation.translateSync('@acorex:chat.dialog.delete-conversation.title'), this.translation.translateSync('@acorex:chat.dialog.delete-conversation.message', {
|
|
15923
|
+
const result = await this.dialogService.confirm(this.translation.translateSync('@acorex:chat.dialog.delete-conversation.title'), this.translation.translateSync('@acorex:chat.dialog.delete-conversation.message', {
|
|
15924
|
+
params: { conversationTitle },
|
|
15925
|
+
}), 'danger', 'vertical', false);
|
|
15928
15926
|
// User cancelled
|
|
15929
15927
|
if (!result.result) {
|
|
15930
15928
|
return false;
|
|
@@ -20010,11 +20008,12 @@ class AXMessageListComponent {
|
|
|
20010
20008
|
}
|
|
20011
20009
|
}
|
|
20012
20010
|
/**
|
|
20013
|
-
* TrackBy function for message groups
|
|
20014
|
-
*
|
|
20011
|
+
* TrackBy function for message groups.
|
|
20012
|
+
* Include the latest message id so new messages in the same date group re-render.
|
|
20015
20013
|
*/
|
|
20016
20014
|
trackMessageGroup(index, group) {
|
|
20017
|
-
|
|
20015
|
+
const lastId = group.messages.at(-1)?.id ?? `empty-${index}`;
|
|
20016
|
+
return `${group.date}:${lastId}`;
|
|
20018
20017
|
}
|
|
20019
20018
|
/**
|
|
20020
20019
|
* TrackBy function for messages
|