@acorex/components 20.6.33 → 20.6.34
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.
- package/conversation2/index.d.ts +17 -236
- package/fesm2022/acorex-components-conversation2.mjs +878 -1161
- package/fesm2022/acorex-components-conversation2.mjs.map +1 -1
- package/fesm2022/acorex-components-loading-dialog.mjs +2 -2
- package/fesm2022/acorex-components-loading-dialog.mjs.map +1 -1
- package/package.json +35 -35
|
@@ -4,12 +4,12 @@ import { AXTabsModule, AXTabsComponent, AXTabItemComponent } from '@acorex/compo
|
|
|
4
4
|
import * as i1$2 from '@angular/common';
|
|
5
5
|
import { isPlatformBrowser, AsyncPipe, CommonModule, NgComponentOutlet } from '@angular/common';
|
|
6
6
|
import * as i0 from '@angular/core';
|
|
7
|
-
import { InjectionToken,
|
|
8
|
-
import { Subject, BehaviorSubject, Observable, filter, firstValueFrom, takeUntil, catchError, EMPTY } from 'rxjs';
|
|
7
|
+
import { InjectionToken, signal, computed, inject, Injectable, Injector, runInInjectionContext, PLATFORM_ID, input, output, Component, viewChild, DestroyRef, effect, model, ChangeDetectionStrategy, SecurityContext, untracked, ViewContainerRef, Directive, EventEmitter, ElementRef, afterNextRender, NgModule } from '@angular/core';
|
|
9
8
|
import { AXDialogService } from '@acorex/components/dialog';
|
|
10
9
|
import { AXPopupService } from '@acorex/components/popup';
|
|
11
10
|
import * as i4 from '@acorex/core/translation';
|
|
12
11
|
import { AXTranslationService, AXTranslationModule, translateSync } from '@acorex/core/translation';
|
|
12
|
+
import { Subject, BehaviorSubject, Observable, filter, firstValueFrom, takeUntil, catchError, EMPTY } from 'rxjs';
|
|
13
13
|
import * as i1 from '@acorex/components/button';
|
|
14
14
|
import { AXButtonModule, AXButtonComponent } from '@acorex/components/button';
|
|
15
15
|
import * as i2 from '@acorex/components/decorators';
|
|
@@ -419,873 +419,101 @@ const REGISTRY_CONFIG = new InjectionToken('REGISTRY_CONFIG', {
|
|
|
419
419
|
});
|
|
420
420
|
|
|
421
421
|
/**
|
|
422
|
-
*
|
|
423
|
-
* Centralized
|
|
422
|
+
* Validation Utilities
|
|
423
|
+
* Centralized validation functions for messages, files, and user input
|
|
424
424
|
*/
|
|
425
425
|
/**
|
|
426
|
-
*
|
|
426
|
+
* Validate message text content
|
|
427
|
+
* @param text - Text to validate
|
|
428
|
+
* @param config - Configuration for validation rules
|
|
429
|
+
* @returns Validation result
|
|
427
430
|
*/
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
autoRetry: false,
|
|
436
|
-
maxRetries: 3,
|
|
431
|
+
function validateMessageText(text, config) {
|
|
432
|
+
// Check for empty text
|
|
433
|
+
if (!text || text.trim().length === 0) {
|
|
434
|
+
return {
|
|
435
|
+
valid: false,
|
|
436
|
+
error: 'Message text cannot be empty',
|
|
437
|
+
errorCode: 'EMPTY_MESSAGE',
|
|
437
438
|
};
|
|
438
|
-
/** Error stream */
|
|
439
|
-
this.errors$ = this._errors$.asObservable();
|
|
440
|
-
// Apply injected configuration at construction
|
|
441
|
-
if (this.injectedConfig) {
|
|
442
|
-
this.configure(this.injectedConfig);
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
/**
|
|
446
|
-
* Configure error handler
|
|
447
|
-
*/
|
|
448
|
-
configure(config) {
|
|
449
|
-
Object.assign(this._config, config);
|
|
450
|
-
}
|
|
451
|
-
/**
|
|
452
|
-
* Handle an error
|
|
453
|
-
*/
|
|
454
|
-
handle(error, operation, context) {
|
|
455
|
-
const conversationError = this.normalizeError(error, operation, context);
|
|
456
|
-
// Emit error event
|
|
457
|
-
this._errors$.next(conversationError);
|
|
458
|
-
// Log to console if enabled
|
|
459
|
-
if (this._config.logToConsole) {
|
|
460
|
-
this.logError(conversationError);
|
|
461
|
-
}
|
|
462
|
-
// Call custom handler if provided
|
|
463
|
-
if (this._config.customHandler) {
|
|
464
|
-
this._config.customHandler(conversationError);
|
|
465
|
-
}
|
|
466
|
-
return conversationError;
|
|
467
439
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
severity: this.determineSeverity(apiError.statusCode),
|
|
476
|
-
operation,
|
|
477
|
-
originalError: apiError,
|
|
478
|
-
statusCode: apiError.statusCode,
|
|
479
|
-
context,
|
|
480
|
-
timestamp: apiError.timestamp || new Date(),
|
|
481
|
-
handled: false,
|
|
482
|
-
recoverySuggestions: this.getRecoverySuggestions(apiError),
|
|
440
|
+
// Check minimum length
|
|
441
|
+
const minLength = config.minMessageLength ?? 1;
|
|
442
|
+
if (text.trim().length < minLength) {
|
|
443
|
+
return {
|
|
444
|
+
valid: false,
|
|
445
|
+
error: `Message must be at least ${minLength} character(s)`,
|
|
446
|
+
errorCode: 'MESSAGE_TOO_SHORT',
|
|
483
447
|
};
|
|
484
|
-
this._errors$.next(conversationError);
|
|
485
|
-
if (this._config.logToConsole) {
|
|
486
|
-
this.logError(conversationError);
|
|
487
|
-
}
|
|
488
|
-
if (this._config.customHandler) {
|
|
489
|
-
this._config.customHandler(conversationError);
|
|
490
|
-
}
|
|
491
|
-
return conversationError;
|
|
492
448
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
normalizeError(error, operation, context) {
|
|
497
|
-
// Handle AXApiError
|
|
498
|
-
if (this.isApiError(error)) {
|
|
499
|
-
return this.handleApiError(error, operation, context);
|
|
500
|
-
}
|
|
501
|
-
// Handle standard Error
|
|
502
|
-
const errorObj = error;
|
|
503
|
-
const message = errorObj?.['message'] || String(error) || 'An unknown error occurred';
|
|
504
|
-
const code = errorObj?.['code'] || 'UNKNOWN_ERROR';
|
|
505
|
-
const statusCode = errorObj?.['statusCode'] || errorObj?.['status'];
|
|
449
|
+
// Check maximum length
|
|
450
|
+
const maxLength = config.maxMessageLength ?? 10000;
|
|
451
|
+
if (text.length > maxLength) {
|
|
506
452
|
return {
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
operation,
|
|
511
|
-
originalError: error,
|
|
512
|
-
statusCode,
|
|
513
|
-
context,
|
|
514
|
-
timestamp: new Date(),
|
|
515
|
-
handled: false,
|
|
516
|
-
recoverySuggestions: this.getDefaultRecoverySuggestions(code),
|
|
453
|
+
valid: false,
|
|
454
|
+
error: `Message exceeds ${maxLength} character limit`,
|
|
455
|
+
errorCode: 'MESSAGE_TOO_LONG',
|
|
517
456
|
};
|
|
518
457
|
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
}
|
|
539
|
-
/**
|
|
540
|
-
* Get recovery suggestions based on error
|
|
541
|
-
*/
|
|
542
|
-
getRecoverySuggestions(error) {
|
|
543
|
-
const suggestions = [];
|
|
544
|
-
if (error.statusCode === 401 || error.code === 'UNAUTHORIZED') {
|
|
545
|
-
suggestions.push('Please log in again');
|
|
546
|
-
suggestions.push('Check if your session has expired');
|
|
547
|
-
}
|
|
548
|
-
else if (error.statusCode === 403 || error.code === 'FORBIDDEN') {
|
|
549
|
-
suggestions.push('You do not have permission for this action');
|
|
550
|
-
suggestions.push('Ask your administrator for access');
|
|
551
|
-
}
|
|
552
|
-
else if (error.statusCode === 404 || error.code === 'NOT_FOUND') {
|
|
553
|
-
suggestions.push('The requested resource was not found');
|
|
554
|
-
suggestions.push('It may have been deleted or moved');
|
|
555
|
-
}
|
|
556
|
-
else if (error.statusCode === 429 || error.code === 'RATE_LIMIT_EXCEEDED') {
|
|
557
|
-
suggestions.push('Too many requests. Please wait and try again');
|
|
558
|
-
}
|
|
559
|
-
else if (error.statusCode && error.statusCode >= 500) {
|
|
560
|
-
suggestions.push('Server error occurred');
|
|
561
|
-
suggestions.push('Please try again later');
|
|
562
|
-
suggestions.push('If the problem persists, reach out to support');
|
|
563
|
-
}
|
|
564
|
-
else if (error.code === 'NETWORK_ERROR') {
|
|
565
|
-
suggestions.push('Check your internet connection');
|
|
566
|
-
suggestions.push('Try refreshing the page');
|
|
567
|
-
}
|
|
568
|
-
return suggestions;
|
|
458
|
+
return { valid: true };
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Validate file upload
|
|
462
|
+
* @param file - File to validate
|
|
463
|
+
* @param config - Configuration for validation rules
|
|
464
|
+
* @returns File validation result
|
|
465
|
+
*/
|
|
466
|
+
function validateFile(file, config) {
|
|
467
|
+
// Check file size
|
|
468
|
+
const maxSize = config.maxFileSize ?? 10 * 1024 * 1024; // 10MB default
|
|
469
|
+
if (file.size > maxSize) {
|
|
470
|
+
return {
|
|
471
|
+
valid: false,
|
|
472
|
+
error: `File size exceeds ${formatFileSize(maxSize)} limit`,
|
|
473
|
+
errorCode: 'FILE_TOO_LARGE',
|
|
474
|
+
size: file.size,
|
|
475
|
+
type: file.type,
|
|
476
|
+
};
|
|
569
477
|
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
}
|
|
583
|
-
else {
|
|
584
|
-
suggestions.push('Please try again');
|
|
585
|
-
suggestions.push('If the problem persists, reach out to support');
|
|
478
|
+
// Check file type if restrictions exist
|
|
479
|
+
const allowedTypes = config.allowedFileTypes;
|
|
480
|
+
if (allowedTypes && allowedTypes.length > 0) {
|
|
481
|
+
const isAllowed = allowedTypes.some((pattern) => matchMimeType(file.type, pattern));
|
|
482
|
+
if (!isAllowed) {
|
|
483
|
+
return {
|
|
484
|
+
valid: false,
|
|
485
|
+
error: `File type "${file.type}" is not allowed`,
|
|
486
|
+
errorCode: 'FILE_TYPE_NOT_ALLOWED',
|
|
487
|
+
size: file.size,
|
|
488
|
+
type: file.type,
|
|
489
|
+
};
|
|
586
490
|
}
|
|
587
|
-
return suggestions;
|
|
588
491
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
}
|
|
492
|
+
return {
|
|
493
|
+
valid: true,
|
|
494
|
+
size: file.size,
|
|
495
|
+
type: file.type,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Validate conversation ID
|
|
500
|
+
* @param conversationId - Conversation ID to validate
|
|
501
|
+
* @returns Validation result
|
|
502
|
+
*/
|
|
503
|
+
function validateConversationId(conversationId) {
|
|
504
|
+
if (!conversationId || typeof conversationId !== 'string' || conversationId.trim().length === 0) {
|
|
505
|
+
return {
|
|
506
|
+
valid: false,
|
|
507
|
+
error: 'Conversation ID is required',
|
|
508
|
+
errorCode: 'MISSING_CONVERSATION_ID',
|
|
509
|
+
};
|
|
607
510
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
NETWORK_ERROR: 'Unable to connect. Please check your internet connection.',
|
|
615
|
-
UNAUTHORIZED: 'You are not authorized. Please log in again.',
|
|
616
|
-
FORBIDDEN: 'You do not have permission to perform this action.',
|
|
617
|
-
NOT_FOUND: 'The requested item could not be found.',
|
|
618
|
-
RATE_LIMIT_EXCEEDED: 'Too many requests. Please slow down.',
|
|
619
|
-
VALIDATION_ERROR: 'The provided data is invalid.',
|
|
620
|
-
SERVER_ERROR: 'A server error occurred. Please try again later.',
|
|
621
|
-
TIMEOUT: 'The operation timed out. Please try again.',
|
|
622
|
-
};
|
|
623
|
-
return messageMap[error.code] || error.message || 'An unexpected error occurred.';
|
|
624
|
-
}
|
|
625
|
-
/**
|
|
626
|
-
* Check if error is retryable
|
|
627
|
-
*/
|
|
628
|
-
isRetryable(error) {
|
|
629
|
-
const retryableCodes = ['NETWORK_ERROR', 'TIMEOUT', 'RATE_LIMIT_EXCEEDED', 'SERVER_ERROR'];
|
|
630
|
-
const retryableStatusCodes = [408, 429, 500, 502, 503, 504];
|
|
631
|
-
return (retryableCodes.includes(error.code) ||
|
|
632
|
-
(error.statusCode !== undefined && retryableStatusCodes.includes(error.statusCode)));
|
|
633
|
-
}
|
|
634
|
-
/**
|
|
635
|
-
* Execute an operation with automatic retry logic
|
|
636
|
-
* @param operation - The async operation to execute
|
|
637
|
-
* @param operationName - Name of the operation for error tracking
|
|
638
|
-
* @param context - Additional context for error handling
|
|
639
|
-
* @returns Promise resolving to the operation result
|
|
640
|
-
* @throws {AXConversationError} If all retries fail
|
|
641
|
-
*/
|
|
642
|
-
async executeWithRetry(operation, operationName, context) {
|
|
643
|
-
if (!this._config.autoRetry) {
|
|
644
|
-
// If auto-retry is disabled, just execute once
|
|
645
|
-
return operation();
|
|
646
|
-
}
|
|
647
|
-
const maxRetries = this._config.maxRetries ?? 3;
|
|
648
|
-
let lastError;
|
|
649
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
650
|
-
try {
|
|
651
|
-
return await operation();
|
|
652
|
-
}
|
|
653
|
-
catch (error) {
|
|
654
|
-
lastError = error;
|
|
655
|
-
const conversationError = this.handle(error, operationName, { ...context, attempt });
|
|
656
|
-
// Don't retry if error is not retryable or if this was the last attempt
|
|
657
|
-
if (!this.isRetryable(conversationError) || attempt >= maxRetries) {
|
|
658
|
-
throw conversationError;
|
|
659
|
-
}
|
|
660
|
-
// Exponential backoff: 1s, 2s, 4s, 8s...
|
|
661
|
-
const delayMs = Math.pow(2, attempt) * 1000;
|
|
662
|
-
await this.delay(delayMs);
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
// This should never be reached, but TypeScript needs it
|
|
666
|
-
throw this.handle(lastError, operationName, context);
|
|
667
|
-
}
|
|
668
|
-
/**
|
|
669
|
-
* Delay helper for retry backoff
|
|
670
|
-
* @param ms - Milliseconds to delay
|
|
671
|
-
*/
|
|
672
|
-
delay(ms) {
|
|
673
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
674
|
-
}
|
|
675
|
-
/**
|
|
676
|
-
* Clear error history
|
|
677
|
-
*/
|
|
678
|
-
clear() {
|
|
679
|
-
// Errors are not stored, just emitted through observable
|
|
680
|
-
// This method is here for future extensions if needed
|
|
681
|
-
}
|
|
682
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXErrorHandlerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
683
|
-
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXErrorHandlerService, providedIn: 'root' }); }
|
|
684
|
-
}
|
|
685
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXErrorHandlerService, decorators: [{
|
|
686
|
-
type: Injectable,
|
|
687
|
-
args: [{
|
|
688
|
-
providedIn: 'root',
|
|
689
|
-
}]
|
|
690
|
-
}], ctorParameters: () => [] });
|
|
691
|
-
|
|
692
|
-
/**
|
|
693
|
-
* Conversation Store Service
|
|
694
|
-
* Single signal store for managing all conversation and message state
|
|
695
|
-
*
|
|
696
|
-
* This is the single source of truth for:
|
|
697
|
-
* - Conversations list and metadata
|
|
698
|
-
* - Messages for all conversations
|
|
699
|
-
* - Active conversation selection
|
|
700
|
-
* - In-memory state management
|
|
701
|
-
*/
|
|
702
|
-
/**
|
|
703
|
-
* Conversation Store Service
|
|
704
|
-
* Unified signal-based store for conversations and messages
|
|
705
|
-
*/
|
|
706
|
-
class AXConversationStoreService {
|
|
707
|
-
constructor() {
|
|
708
|
-
this.config = inject(CONVERSATION_CONFIG);
|
|
709
|
-
this.errorHandler = inject(AXErrorHandlerService);
|
|
710
|
-
// =====================
|
|
711
|
-
// State Signals
|
|
712
|
-
// =====================
|
|
713
|
-
/** All conversations (keyed by ID) */
|
|
714
|
-
this._conversations = signal(new Map(), ...(ngDevMode ? [{ debugName: "_conversations" }] : []));
|
|
715
|
-
/** All messages (keyed by message ID) */
|
|
716
|
-
this._messages = signal(new Map(), ...(ngDevMode ? [{ debugName: "_messages" }] : []));
|
|
717
|
-
/** Message IDs grouped by conversation */
|
|
718
|
-
this._conversationMessages = signal(new Map(), ...(ngDevMode ? [{ debugName: "_conversationMessages" }] : []));
|
|
719
|
-
// =====================
|
|
720
|
-
// Public Computed Signals
|
|
721
|
-
// =====================
|
|
722
|
-
/** All conversations as array (unsorted - sorting is handled by consumers) */
|
|
723
|
-
this.conversations = computed(() => {
|
|
724
|
-
const convMap = this._conversations();
|
|
725
|
-
return Array.from(convMap.values());
|
|
726
|
-
}, ...(ngDevMode ? [{ debugName: "conversations", equal: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]) }] : [{
|
|
727
|
-
equal: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]),
|
|
728
|
-
}]));
|
|
729
|
-
/** All messages as array */
|
|
730
|
-
this.messages = computed(() => Array.from(this._messages().values()), ...(ngDevMode ? [{ debugName: "messages", equal: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]) }] : [{ equal: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]) }]));
|
|
731
|
-
}
|
|
732
|
-
// =====================
|
|
733
|
-
// Initialization
|
|
734
|
-
// =====================
|
|
735
|
-
/**
|
|
736
|
-
* Initialize the store
|
|
737
|
-
*/
|
|
738
|
-
async initialize() {
|
|
739
|
-
// Store is initialized with empty state
|
|
740
|
-
// Data will be loaded from API
|
|
741
|
-
}
|
|
742
|
-
// =====================
|
|
743
|
-
// Conversation Operations
|
|
744
|
-
// =====================
|
|
745
|
-
/**
|
|
746
|
-
* Set all conversations (replaces existing)
|
|
747
|
-
* @param conversations - Array of conversations
|
|
748
|
-
*/
|
|
749
|
-
setConversations(conversations) {
|
|
750
|
-
const convMap = new Map();
|
|
751
|
-
conversations.forEach((conv) => convMap.set(conv.id, conv));
|
|
752
|
-
this._conversations.set(convMap);
|
|
753
|
-
}
|
|
754
|
-
/**
|
|
755
|
-
* Add multiple conversations (append to existing)
|
|
756
|
-
* Used for pagination - appends new conversations without replacing existing ones
|
|
757
|
-
* @param conversations - Array of conversations to add
|
|
758
|
-
*/
|
|
759
|
-
addConversations(conversations) {
|
|
760
|
-
this._conversations.update((existingConversations) => {
|
|
761
|
-
const newConversations = new Map(existingConversations);
|
|
762
|
-
conversations.forEach((conv) => newConversations.set(conv.id, conv));
|
|
763
|
-
// Cleanup old conversations if cache is too large
|
|
764
|
-
const maxCached = this.config.maxCachedConversations;
|
|
765
|
-
if (newConversations.size > maxCached) {
|
|
766
|
-
// Sort by last activity and keep only the most recent
|
|
767
|
-
const sorted = Array.from(newConversations.values()).sort((a, b) => (b.lastMessageAt?.getTime() ?? 0) - (a.lastMessageAt?.getTime() ?? 0));
|
|
768
|
-
// Keep only the most recent conversations
|
|
769
|
-
const toKeep = sorted.slice(0, maxCached);
|
|
770
|
-
const cleanedMap = new Map();
|
|
771
|
-
toKeep.forEach((conv) => cleanedMap.set(conv.id, conv));
|
|
772
|
-
return cleanedMap;
|
|
773
|
-
}
|
|
774
|
-
return newConversations;
|
|
775
|
-
});
|
|
776
|
-
}
|
|
777
|
-
/**
|
|
778
|
-
* Add or update a single conversation
|
|
779
|
-
* @param conversation - Conversation to add/update
|
|
780
|
-
*/
|
|
781
|
-
setConversation(conversation) {
|
|
782
|
-
this._conversations.update((conversations) => {
|
|
783
|
-
const newConversations = new Map(conversations);
|
|
784
|
-
newConversations.set(conversation.id, conversation);
|
|
785
|
-
return newConversations;
|
|
786
|
-
});
|
|
787
|
-
}
|
|
788
|
-
/**
|
|
789
|
-
* Get a conversation by ID
|
|
790
|
-
* @param conversationId - Conversation ID
|
|
791
|
-
* @returns Conversation or undefined
|
|
792
|
-
*/
|
|
793
|
-
getConversation(conversationId) {
|
|
794
|
-
return this._conversations().get(conversationId);
|
|
795
|
-
}
|
|
796
|
-
/**
|
|
797
|
-
* Update a conversation with partial data
|
|
798
|
-
* @param conversationId - Conversation ID
|
|
799
|
-
* @param updates - Partial conversation updates
|
|
800
|
-
*/
|
|
801
|
-
updateConversation(conversationId, updates) {
|
|
802
|
-
const conversation = this._conversations().get(conversationId);
|
|
803
|
-
if (!conversation)
|
|
804
|
-
return;
|
|
805
|
-
const updatedConversation = { ...conversation, ...updates };
|
|
806
|
-
this.setConversation(updatedConversation);
|
|
807
|
-
}
|
|
808
|
-
/**
|
|
809
|
-
* Delete a conversation
|
|
810
|
-
* @param conversationId - Conversation ID
|
|
811
|
-
*/
|
|
812
|
-
deleteConversation(conversationId) {
|
|
813
|
-
// Remove conversation
|
|
814
|
-
this._conversations.update((conversations) => {
|
|
815
|
-
const newConversations = new Map(conversations);
|
|
816
|
-
newConversations.delete(conversationId);
|
|
817
|
-
return newConversations;
|
|
818
|
-
});
|
|
819
|
-
// Remove all messages for this conversation
|
|
820
|
-
const messageIds = this._conversationMessages().get(conversationId) || [];
|
|
821
|
-
this._messages.update((messages) => {
|
|
822
|
-
const newMessages = new Map(messages);
|
|
823
|
-
messageIds.forEach((id) => newMessages.delete(id));
|
|
824
|
-
return newMessages;
|
|
825
|
-
});
|
|
826
|
-
this._conversationMessages.update((map) => {
|
|
827
|
-
const newMap = new Map(map);
|
|
828
|
-
newMap.delete(conversationId);
|
|
829
|
-
return newMap;
|
|
830
|
-
});
|
|
831
|
-
}
|
|
832
|
-
// =====================
|
|
833
|
-
// Conversation Updates
|
|
834
|
-
// =====================
|
|
835
|
-
/**
|
|
836
|
-
* Update conversation's last message
|
|
837
|
-
* @param message - The message to set as last message
|
|
838
|
-
*/
|
|
839
|
-
updateLastMessage(message) {
|
|
840
|
-
this.updateConversation(message.conversationId, {
|
|
841
|
-
lastMessage: message,
|
|
842
|
-
lastMessageAt: message.timestamp,
|
|
843
|
-
updatedAt: message.timestamp,
|
|
844
|
-
});
|
|
845
|
-
}
|
|
846
|
-
/**
|
|
847
|
-
* Increment unread count
|
|
848
|
-
* @param conversationId - Conversation ID
|
|
849
|
-
*/
|
|
850
|
-
incrementUnreadCount(conversationId) {
|
|
851
|
-
const conversation = this._conversations().get(conversationId);
|
|
852
|
-
if (!conversation)
|
|
853
|
-
return;
|
|
854
|
-
this.updateConversation(conversationId, { unreadCount: conversation.unreadCount + 1 });
|
|
855
|
-
}
|
|
856
|
-
/**
|
|
857
|
-
* Reset unread count to zero
|
|
858
|
-
* @param conversationId - Conversation ID
|
|
859
|
-
*/
|
|
860
|
-
resetUnreadCount(conversationId) {
|
|
861
|
-
this.updateConversation(conversationId, { unreadCount: 0 });
|
|
862
|
-
}
|
|
863
|
-
/**
|
|
864
|
-
* Update conversation settings
|
|
865
|
-
* @param conversationId - Conversation ID
|
|
866
|
-
* @param settings - Settings to merge
|
|
867
|
-
*/
|
|
868
|
-
updateSettings(conversationId, settings) {
|
|
869
|
-
const conversation = this._conversations().get(conversationId);
|
|
870
|
-
if (!conversation)
|
|
871
|
-
return;
|
|
872
|
-
this.updateConversation(conversationId, {
|
|
873
|
-
settings: { ...conversation.settings, ...settings },
|
|
874
|
-
updatedAt: new Date(),
|
|
875
|
-
});
|
|
876
|
-
}
|
|
877
|
-
/**
|
|
878
|
-
* Update conversation title
|
|
879
|
-
* @param conversationId - Conversation ID
|
|
880
|
-
* @param title - New title
|
|
881
|
-
*/
|
|
882
|
-
updateTitle(conversationId, title) {
|
|
883
|
-
this.updateConversation(conversationId, { title, updatedAt: new Date() });
|
|
884
|
-
}
|
|
885
|
-
/**
|
|
886
|
-
* Update conversation metadata
|
|
887
|
-
* @param conversationId - Conversation ID
|
|
888
|
-
* @param metadata - Metadata to merge
|
|
889
|
-
*/
|
|
890
|
-
updateMetadata(conversationId, metadata) {
|
|
891
|
-
const conversation = this._conversations().get(conversationId);
|
|
892
|
-
if (!conversation)
|
|
893
|
-
return;
|
|
894
|
-
this.updateConversation(conversationId, {
|
|
895
|
-
metadata: { ...conversation.metadata, ...metadata },
|
|
896
|
-
updatedAt: new Date(),
|
|
897
|
-
});
|
|
898
|
-
}
|
|
899
|
-
/**
|
|
900
|
-
* Update typing indicator
|
|
901
|
-
* @param conversationId - Conversation ID
|
|
902
|
-
* @param userId - User ID
|
|
903
|
-
* @param isTyping - Whether user is typing
|
|
904
|
-
*/
|
|
905
|
-
updateTypingIndicator(conversationId, userId, isTyping) {
|
|
906
|
-
const conversation = this._conversations().get(conversationId);
|
|
907
|
-
if (!conversation)
|
|
908
|
-
return;
|
|
909
|
-
let typingUsers = [...conversation.status.typingUsers];
|
|
910
|
-
if (isTyping) {
|
|
911
|
-
if (!typingUsers.includes(userId)) {
|
|
912
|
-
typingUsers.push(userId);
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
else {
|
|
916
|
-
typingUsers = typingUsers.filter((id) => id !== userId);
|
|
917
|
-
}
|
|
918
|
-
this.updateConversation(conversationId, {
|
|
919
|
-
status: {
|
|
920
|
-
...conversation.status,
|
|
921
|
-
isTyping: typingUsers.length > 0,
|
|
922
|
-
typingUsers,
|
|
923
|
-
},
|
|
924
|
-
});
|
|
925
|
-
}
|
|
926
|
-
/**
|
|
927
|
-
* Update participant presence across all conversations
|
|
928
|
-
* @param userId - User ID
|
|
929
|
-
* @param status - Presence status
|
|
930
|
-
* @param lastSeen - Last seen date
|
|
931
|
-
*/
|
|
932
|
-
updateParticipantPresence(userId, status, lastSeen) {
|
|
933
|
-
this._conversations.update((conversations) => {
|
|
934
|
-
const newConversations = new Map(conversations);
|
|
935
|
-
for (const [id, conv] of conversations) {
|
|
936
|
-
const participant = conv.participants.find((p) => p.id === userId);
|
|
937
|
-
if (participant) {
|
|
938
|
-
const updatedConversation = {
|
|
939
|
-
...conv,
|
|
940
|
-
participants: conv.participants.map((p) => (p.id === userId ? { ...p, status, lastSeen } : p)),
|
|
941
|
-
status: conv.type === 'private' ? { ...conv.status, presence: status, lastSeen } : conv.status,
|
|
942
|
-
};
|
|
943
|
-
newConversations.set(id, updatedConversation);
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
return newConversations;
|
|
947
|
-
});
|
|
948
|
-
}
|
|
949
|
-
// =====================
|
|
950
|
-
// Message Operations
|
|
951
|
-
// =====================
|
|
952
|
-
/**
|
|
953
|
-
* Add a message to the store
|
|
954
|
-
* @param message - Message to add
|
|
955
|
-
*/
|
|
956
|
-
addMessage(message) {
|
|
957
|
-
// Add to messages map
|
|
958
|
-
this._messages.update((messages) => {
|
|
959
|
-
const newMessages = new Map(messages);
|
|
960
|
-
newMessages.set(message.id, message);
|
|
961
|
-
return newMessages;
|
|
962
|
-
});
|
|
963
|
-
// Update conversation messages list (sorted) — always produce new arrays
|
|
964
|
-
this._conversationMessages.update((map) => {
|
|
965
|
-
const newMap = new Map(map);
|
|
966
|
-
const existing = newMap.get(message.conversationId) || [];
|
|
967
|
-
if (existing.includes(message.id)) {
|
|
968
|
-
return newMap;
|
|
969
|
-
}
|
|
970
|
-
const updated = [...existing, message.id].sort((a, b) => {
|
|
971
|
-
const msgA = this._messages().get(a);
|
|
972
|
-
const msgB = this._messages().get(b);
|
|
973
|
-
if (!msgA || !msgB)
|
|
974
|
-
return 0;
|
|
975
|
-
return msgA.timestamp.getTime() - msgB.timestamp.getTime();
|
|
976
|
-
});
|
|
977
|
-
newMap.set(message.conversationId, updated);
|
|
978
|
-
return newMap;
|
|
979
|
-
});
|
|
980
|
-
}
|
|
981
|
-
/**
|
|
982
|
-
* Add multiple messages
|
|
983
|
-
* @param messages - Messages to add
|
|
984
|
-
*/
|
|
985
|
-
addMessages(messages) {
|
|
986
|
-
if (messages.length === 0)
|
|
987
|
-
return;
|
|
988
|
-
// Add all messages to map
|
|
989
|
-
this._messages.update((msgs) => {
|
|
990
|
-
const newMessages = new Map(msgs);
|
|
991
|
-
messages.forEach((msg) => newMessages.set(msg.id, msg));
|
|
992
|
-
return newMessages;
|
|
993
|
-
});
|
|
994
|
-
// Update conversation messages lists
|
|
995
|
-
const conversationGroups = new Map();
|
|
996
|
-
messages.forEach((msg) => {
|
|
997
|
-
const existing = conversationGroups.get(msg.conversationId) || [];
|
|
998
|
-
existing.push(msg.id);
|
|
999
|
-
conversationGroups.set(msg.conversationId, existing);
|
|
1000
|
-
});
|
|
1001
|
-
this._conversationMessages.update((map) => {
|
|
1002
|
-
const newMap = new Map(map);
|
|
1003
|
-
for (const [conversationId, newMsgIds] of conversationGroups) {
|
|
1004
|
-
const existing = newMap.get(conversationId) || [];
|
|
1005
|
-
const idSet = new Set(existing);
|
|
1006
|
-
const merged = [...existing];
|
|
1007
|
-
for (const id of newMsgIds) {
|
|
1008
|
-
if (!idSet.has(id)) {
|
|
1009
|
-
merged.push(id);
|
|
1010
|
-
idSet.add(id);
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
const sorted = merged.sort((a, b) => {
|
|
1014
|
-
const msgA = this._messages().get(a);
|
|
1015
|
-
const msgB = this._messages().get(b);
|
|
1016
|
-
if (!msgA || !msgB)
|
|
1017
|
-
return 0;
|
|
1018
|
-
return msgA.timestamp.getTime() - msgB.timestamp.getTime();
|
|
1019
|
-
});
|
|
1020
|
-
newMap.set(conversationId, sorted);
|
|
1021
|
-
}
|
|
1022
|
-
return newMap;
|
|
1023
|
-
});
|
|
1024
|
-
// Cleanup old messages to prevent memory leaks
|
|
1025
|
-
this.cleanupOldMessages();
|
|
1026
|
-
this.cleanupConversationMessages();
|
|
1027
|
-
}
|
|
1028
|
-
/**
|
|
1029
|
-
* Get a message by ID
|
|
1030
|
-
* @param messageId - Message ID
|
|
1031
|
-
* @returns Message or undefined
|
|
1032
|
-
*/
|
|
1033
|
-
getMessage(messageId) {
|
|
1034
|
-
return this._messages().get(messageId);
|
|
1035
|
-
}
|
|
1036
|
-
/**
|
|
1037
|
-
* Get messages for a conversation
|
|
1038
|
-
* @param conversationId - Conversation ID
|
|
1039
|
-
* @returns Array of messages sorted by timestamp
|
|
1040
|
-
*/
|
|
1041
|
-
getConversationMessages(conversationId) {
|
|
1042
|
-
const messageIds = this._conversationMessages().get(conversationId) || [];
|
|
1043
|
-
return messageIds.map((id) => this._messages().get(id)).filter((msg) => msg !== undefined);
|
|
1044
|
-
}
|
|
1045
|
-
/**
|
|
1046
|
-
* Get messages signal for a conversation
|
|
1047
|
-
* @param conversationId - Conversation ID
|
|
1048
|
-
* @returns Computed signal of messages
|
|
1049
|
-
*/
|
|
1050
|
-
getConversationMessagesSignal(conversationId) {
|
|
1051
|
-
return computed(() => this.getConversationMessages(conversationId));
|
|
1052
|
-
}
|
|
1053
|
-
/**
|
|
1054
|
-
* Update a message
|
|
1055
|
-
* @param messageId - Message ID
|
|
1056
|
-
* @param updates - Partial message updates
|
|
1057
|
-
*/
|
|
1058
|
-
updateMessage(messageId, updates) {
|
|
1059
|
-
const message = this._messages().get(messageId);
|
|
1060
|
-
if (!message)
|
|
1061
|
-
return;
|
|
1062
|
-
const updatedMessage = { ...message, ...updates };
|
|
1063
|
-
this.addMessage(updatedMessage);
|
|
1064
|
-
}
|
|
1065
|
-
/**
|
|
1066
|
-
* Delete a message
|
|
1067
|
-
* @param messageId - Message ID
|
|
1068
|
-
*/
|
|
1069
|
-
deleteMessage(messageId) {
|
|
1070
|
-
const message = this._messages().get(messageId);
|
|
1071
|
-
if (!message)
|
|
1072
|
-
return;
|
|
1073
|
-
// Remove from messages map
|
|
1074
|
-
this._messages.update((messages) => {
|
|
1075
|
-
const newMessages = new Map(messages);
|
|
1076
|
-
newMessages.delete(messageId);
|
|
1077
|
-
return newMessages;
|
|
1078
|
-
});
|
|
1079
|
-
// Remove from conversation messages list — produce a new array via filter
|
|
1080
|
-
this._conversationMessages.update((map) => {
|
|
1081
|
-
const newMap = new Map(map);
|
|
1082
|
-
const existing = newMap.get(message.conversationId);
|
|
1083
|
-
if (existing) {
|
|
1084
|
-
newMap.set(message.conversationId, existing.filter((id) => id !== messageId));
|
|
1085
|
-
}
|
|
1086
|
-
return newMap;
|
|
1087
|
-
});
|
|
1088
|
-
}
|
|
1089
|
-
/**
|
|
1090
|
-
* Clear messages for a conversation
|
|
1091
|
-
* @param conversationId - Conversation ID
|
|
1092
|
-
*/
|
|
1093
|
-
clearConversationMessages(conversationId) {
|
|
1094
|
-
const messageIds = this._conversationMessages().get(conversationId) || [];
|
|
1095
|
-
// Remove messages
|
|
1096
|
-
this._messages.update((messages) => {
|
|
1097
|
-
const newMessages = new Map(messages);
|
|
1098
|
-
messageIds.forEach((id) => newMessages.delete(id));
|
|
1099
|
-
return newMessages;
|
|
1100
|
-
});
|
|
1101
|
-
// Clear conversation messages list
|
|
1102
|
-
this._conversationMessages.update((map) => {
|
|
1103
|
-
const newMap = new Map(map);
|
|
1104
|
-
newMap.delete(conversationId);
|
|
1105
|
-
return newMap;
|
|
1106
|
-
});
|
|
1107
|
-
}
|
|
1108
|
-
/**
|
|
1109
|
-
* Clear all data
|
|
1110
|
-
*/
|
|
1111
|
-
clearAll() {
|
|
1112
|
-
this._conversations.set(new Map());
|
|
1113
|
-
this._messages.set(new Map());
|
|
1114
|
-
this._conversationMessages.set(new Map());
|
|
1115
|
-
}
|
|
1116
|
-
// =====================
|
|
1117
|
-
// Memory Management
|
|
1118
|
-
// =====================
|
|
1119
|
-
/**
|
|
1120
|
-
* Cleanup old messages to prevent unbounded memory growth
|
|
1121
|
-
* Keeps only the most recent messages up to MAX_TOTAL_MESSAGES
|
|
1122
|
-
*/
|
|
1123
|
-
cleanupOldMessages() {
|
|
1124
|
-
const totalMessages = this._messages().size;
|
|
1125
|
-
if (totalMessages > this.config.maxTotalMessages) {
|
|
1126
|
-
// Get all messages sorted by timestamp (newest first)
|
|
1127
|
-
const allMessages = Array.from(this._messages().values()).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
|
1128
|
-
// Keep only the newest messages
|
|
1129
|
-
const toKeep = allMessages.slice(0, this.config.maxTotalMessages);
|
|
1130
|
-
const toKeepIds = new Set(toKeep.map((m) => m.id));
|
|
1131
|
-
// Remove old messages
|
|
1132
|
-
this._messages.update(() => {
|
|
1133
|
-
const newMessages = new Map();
|
|
1134
|
-
toKeep.forEach((msg) => newMessages.set(msg.id, msg));
|
|
1135
|
-
return newMessages;
|
|
1136
|
-
});
|
|
1137
|
-
// Update conversation messages lists to remove deleted message IDs
|
|
1138
|
-
this._conversationMessages.update((map) => {
|
|
1139
|
-
const newMap = new Map(map);
|
|
1140
|
-
for (const [convId, messageIds] of newMap) {
|
|
1141
|
-
const filteredIds = messageIds.filter((id) => toKeepIds.has(id));
|
|
1142
|
-
newMap.set(convId, filteredIds);
|
|
1143
|
-
}
|
|
1144
|
-
return newMap;
|
|
1145
|
-
});
|
|
1146
|
-
}
|
|
1147
|
-
}
|
|
1148
|
-
/**
|
|
1149
|
-
* Cleanup messages per conversation to prevent memory leaks
|
|
1150
|
-
* Keeps only recent messages per conversation
|
|
1151
|
-
*/
|
|
1152
|
-
cleanupConversationMessages() {
|
|
1153
|
-
const maxMessages = this.config.maxMessagesPerConversation;
|
|
1154
|
-
const idsToRemove = [];
|
|
1155
|
-
this._conversationMessages.update((map) => {
|
|
1156
|
-
const newMap = new Map(map);
|
|
1157
|
-
for (const [convId, messageIds] of newMap) {
|
|
1158
|
-
if (messageIds.length > maxMessages) {
|
|
1159
|
-
idsToRemove.push(...messageIds.slice(0, messageIds.length - maxMessages));
|
|
1160
|
-
newMap.set(convId, messageIds.slice(-maxMessages));
|
|
1161
|
-
}
|
|
1162
|
-
}
|
|
1163
|
-
return newMap;
|
|
1164
|
-
});
|
|
1165
|
-
if (idsToRemove.length > 0) {
|
|
1166
|
-
this._messages.update((messages) => {
|
|
1167
|
-
const newMessages = new Map(messages);
|
|
1168
|
-
idsToRemove.forEach((id) => newMessages.delete(id));
|
|
1169
|
-
return newMessages;
|
|
1170
|
-
});
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
// =====================
|
|
1174
|
-
// Statistics
|
|
1175
|
-
// =====================
|
|
1176
|
-
/**
|
|
1177
|
-
* Get store statistics
|
|
1178
|
-
*/
|
|
1179
|
-
getStats() {
|
|
1180
|
-
return {
|
|
1181
|
-
conversationCount: this._conversations().size,
|
|
1182
|
-
messageCount: this._messages().size,
|
|
1183
|
-
conversationsWithMessages: this._conversationMessages().size,
|
|
1184
|
-
};
|
|
1185
|
-
}
|
|
1186
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationStoreService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
1187
|
-
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationStoreService }); }
|
|
1188
|
-
}
|
|
1189
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXConversationStoreService, decorators: [{
|
|
1190
|
-
type: Injectable
|
|
1191
|
-
}] });
|
|
1192
|
-
|
|
1193
|
-
/**
|
|
1194
|
-
* Validation Utilities
|
|
1195
|
-
* Centralized validation functions for messages, files, and user input
|
|
1196
|
-
*/
|
|
1197
|
-
/**
|
|
1198
|
-
* Validate message text content
|
|
1199
|
-
* @param text - Text to validate
|
|
1200
|
-
* @param config - Configuration for validation rules
|
|
1201
|
-
* @returns Validation result
|
|
1202
|
-
*/
|
|
1203
|
-
function validateMessageText(text, config) {
|
|
1204
|
-
// Check for empty text
|
|
1205
|
-
if (!text || text.trim().length === 0) {
|
|
1206
|
-
return {
|
|
1207
|
-
valid: false,
|
|
1208
|
-
error: 'Message text cannot be empty',
|
|
1209
|
-
errorCode: 'EMPTY_MESSAGE',
|
|
1210
|
-
};
|
|
1211
|
-
}
|
|
1212
|
-
// Check minimum length
|
|
1213
|
-
const minLength = config.minMessageLength ?? 1;
|
|
1214
|
-
if (text.trim().length < minLength) {
|
|
1215
|
-
return {
|
|
1216
|
-
valid: false,
|
|
1217
|
-
error: `Message must be at least ${minLength} character(s)`,
|
|
1218
|
-
errorCode: 'MESSAGE_TOO_SHORT',
|
|
1219
|
-
};
|
|
1220
|
-
}
|
|
1221
|
-
// Check maximum length
|
|
1222
|
-
const maxLength = config.maxMessageLength ?? 10000;
|
|
1223
|
-
if (text.length > maxLength) {
|
|
1224
|
-
return {
|
|
1225
|
-
valid: false,
|
|
1226
|
-
error: `Message exceeds ${maxLength} character limit`,
|
|
1227
|
-
errorCode: 'MESSAGE_TOO_LONG',
|
|
1228
|
-
};
|
|
1229
|
-
}
|
|
1230
|
-
return { valid: true };
|
|
1231
|
-
}
|
|
1232
|
-
/**
|
|
1233
|
-
* Validate file upload
|
|
1234
|
-
* @param file - File to validate
|
|
1235
|
-
* @param config - Configuration for validation rules
|
|
1236
|
-
* @returns File validation result
|
|
1237
|
-
*/
|
|
1238
|
-
function validateFile(file, config) {
|
|
1239
|
-
// Check file size
|
|
1240
|
-
const maxSize = config.maxFileSize ?? 10 * 1024 * 1024; // 10MB default
|
|
1241
|
-
if (file.size > maxSize) {
|
|
1242
|
-
return {
|
|
1243
|
-
valid: false,
|
|
1244
|
-
error: `File size exceeds ${formatFileSize(maxSize)} limit`,
|
|
1245
|
-
errorCode: 'FILE_TOO_LARGE',
|
|
1246
|
-
size: file.size,
|
|
1247
|
-
type: file.type,
|
|
1248
|
-
};
|
|
1249
|
-
}
|
|
1250
|
-
// Check file type if restrictions exist
|
|
1251
|
-
const allowedTypes = config.allowedFileTypes;
|
|
1252
|
-
if (allowedTypes && allowedTypes.length > 0) {
|
|
1253
|
-
const isAllowed = allowedTypes.some((pattern) => matchMimeType(file.type, pattern));
|
|
1254
|
-
if (!isAllowed) {
|
|
1255
|
-
return {
|
|
1256
|
-
valid: false,
|
|
1257
|
-
error: `File type "${file.type}" is not allowed`,
|
|
1258
|
-
errorCode: 'FILE_TYPE_NOT_ALLOWED',
|
|
1259
|
-
size: file.size,
|
|
1260
|
-
type: file.type,
|
|
1261
|
-
};
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1264
|
-
return {
|
|
1265
|
-
valid: true,
|
|
1266
|
-
size: file.size,
|
|
1267
|
-
type: file.type,
|
|
1268
|
-
};
|
|
1269
|
-
}
|
|
1270
|
-
/**
|
|
1271
|
-
* Validate conversation ID
|
|
1272
|
-
* @param conversationId - Conversation ID to validate
|
|
1273
|
-
* @returns Validation result
|
|
1274
|
-
*/
|
|
1275
|
-
function validateConversationId(conversationId) {
|
|
1276
|
-
if (!conversationId || typeof conversationId !== 'string' || conversationId.trim().length === 0) {
|
|
1277
|
-
return {
|
|
1278
|
-
valid: false,
|
|
1279
|
-
error: 'Conversation ID is required',
|
|
1280
|
-
errorCode: 'MISSING_CONVERSATION_ID',
|
|
1281
|
-
};
|
|
1282
|
-
}
|
|
1283
|
-
// Check for reasonable length
|
|
1284
|
-
if (conversationId.length > 255) {
|
|
1285
|
-
return {
|
|
1286
|
-
valid: false,
|
|
1287
|
-
error: 'Conversation ID is too long',
|
|
1288
|
-
errorCode: 'MISSING_CONVERSATION_ID',
|
|
511
|
+
// Check for reasonable length
|
|
512
|
+
if (conversationId.length > 255) {
|
|
513
|
+
return {
|
|
514
|
+
valid: false,
|
|
515
|
+
error: 'Conversation ID is too long',
|
|
516
|
+
errorCode: 'MISSING_CONVERSATION_ID',
|
|
1289
517
|
};
|
|
1290
518
|
}
|
|
1291
519
|
return { valid: true };
|
|
@@ -1356,242 +584,786 @@ function validateMessagePayload(payload, type) {
|
|
|
1356
584
|
}
|
|
1357
585
|
break;
|
|
1358
586
|
}
|
|
1359
|
-
return { valid: true };
|
|
587
|
+
return { valid: true };
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Validate user ID
|
|
591
|
+
* @param userId - User ID to validate
|
|
592
|
+
* @returns Validation result
|
|
593
|
+
*/
|
|
594
|
+
function validateUserId(userId) {
|
|
595
|
+
if (!userId || typeof userId !== 'string' || userId.trim().length === 0) {
|
|
596
|
+
return {
|
|
597
|
+
valid: false,
|
|
598
|
+
error: 'User ID is required',
|
|
599
|
+
errorCode: 'MISSING_USER_ID',
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
// Check for reasonable length (prevent extremely long IDs)
|
|
603
|
+
if (userId.length > 255) {
|
|
604
|
+
return {
|
|
605
|
+
valid: false,
|
|
606
|
+
error: 'User ID is too long',
|
|
607
|
+
errorCode: 'INVALID_USER_ID',
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
return { valid: true };
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Validate array of user IDs
|
|
614
|
+
* @param userIds - Array of user IDs to validate
|
|
615
|
+
* @param minCount - Minimum number of users required
|
|
616
|
+
* @param maxCount - Maximum number of users allowed
|
|
617
|
+
* @returns Validation result
|
|
618
|
+
*/
|
|
619
|
+
function validateUserIds(userIds, minCount = 1, maxCount) {
|
|
620
|
+
if (!userIds || !Array.isArray(userIds)) {
|
|
621
|
+
return {
|
|
622
|
+
valid: false,
|
|
623
|
+
error: 'User IDs must be an array',
|
|
624
|
+
errorCode: 'INVALID_USER_IDS',
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
if (userIds.length < minCount) {
|
|
628
|
+
return {
|
|
629
|
+
valid: false,
|
|
630
|
+
error: `At least ${minCount} user(s) required`,
|
|
631
|
+
errorCode: 'TOO_FEW_USERS',
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
if (maxCount && userIds.length > maxCount) {
|
|
635
|
+
return {
|
|
636
|
+
valid: false,
|
|
637
|
+
error: `Maximum ${maxCount} user(s) allowed`,
|
|
638
|
+
errorCode: 'TOO_MANY_USERS',
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
// Check for empty or invalid IDs
|
|
642
|
+
const invalidIds = userIds.filter((id) => !id || id.trim().length === 0);
|
|
643
|
+
if (invalidIds.length > 0) {
|
|
644
|
+
return {
|
|
645
|
+
valid: false,
|
|
646
|
+
error: 'All user IDs must be non-empty strings',
|
|
647
|
+
errorCode: 'INVALID_USER_ID',
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
return { valid: true };
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Validate email address
|
|
654
|
+
* @param email - Email to validate
|
|
655
|
+
* @returns Validation result
|
|
656
|
+
*/
|
|
657
|
+
function validateEmail(email) {
|
|
658
|
+
if (!email || email.trim().length === 0) {
|
|
659
|
+
return {
|
|
660
|
+
valid: false,
|
|
661
|
+
error: 'Email is required',
|
|
662
|
+
errorCode: 'MISSING_EMAIL',
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
// Trim whitespace
|
|
666
|
+
const trimmedEmail = email.trim();
|
|
667
|
+
// Check length constraints
|
|
668
|
+
if (trimmedEmail.length > 254) {
|
|
669
|
+
return {
|
|
670
|
+
valid: false,
|
|
671
|
+
error: 'Email is too long',
|
|
672
|
+
errorCode: 'INVALID_EMAIL',
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
// Enhanced email regex with better validation
|
|
676
|
+
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])?)*$/;
|
|
677
|
+
if (!emailRegex.test(trimmedEmail)) {
|
|
678
|
+
return {
|
|
679
|
+
valid: false,
|
|
680
|
+
error: 'Invalid email format',
|
|
681
|
+
errorCode: 'INVALID_EMAIL',
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
return { valid: true };
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Validate URL
|
|
688
|
+
* @param url - URL to validate
|
|
689
|
+
* @returns Validation result
|
|
690
|
+
*/
|
|
691
|
+
function validateUrl(url) {
|
|
692
|
+
if (!url || url.trim().length === 0) {
|
|
693
|
+
return {
|
|
694
|
+
valid: false,
|
|
695
|
+
error: 'URL is required',
|
|
696
|
+
errorCode: 'MISSING_URL',
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
const trimmedUrl = url.trim();
|
|
700
|
+
// Check for common URL issues
|
|
701
|
+
if (trimmedUrl.length > 2048) {
|
|
702
|
+
return {
|
|
703
|
+
valid: false,
|
|
704
|
+
error: 'URL is too long',
|
|
705
|
+
errorCode: 'INVALID_URL',
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
try {
|
|
709
|
+
const urlObj = new URL(trimmedUrl);
|
|
710
|
+
// Validate protocol
|
|
711
|
+
if (!['http:', 'https:', 'ftp:', 'ftps:'].includes(urlObj.protocol)) {
|
|
712
|
+
return {
|
|
713
|
+
valid: false,
|
|
714
|
+
error: 'Invalid URL protocol',
|
|
715
|
+
errorCode: 'INVALID_URL',
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
return { valid: true };
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
return {
|
|
722
|
+
valid: false,
|
|
723
|
+
error: 'Invalid URL format',
|
|
724
|
+
errorCode: 'INVALID_URL',
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
// =====================
|
|
729
|
+
// Helper Functions
|
|
730
|
+
// =====================
|
|
731
|
+
/**
|
|
732
|
+
* Match MIME type against a pattern (supports wildcards)
|
|
733
|
+
* @param mimeType - MIME type to check
|
|
734
|
+
* @param pattern - Pattern to match (e.g., "image/*", "video/mp4")
|
|
735
|
+
* @returns Whether the MIME type matches the pattern
|
|
736
|
+
*/
|
|
737
|
+
function matchMimeType(mimeType, pattern) {
|
|
738
|
+
if (pattern === '*/*' || pattern === '*') {
|
|
739
|
+
return true;
|
|
740
|
+
}
|
|
741
|
+
if (pattern.endsWith('/*')) {
|
|
742
|
+
const prefix = pattern.slice(0, -2);
|
|
743
|
+
return mimeType.startsWith(prefix);
|
|
744
|
+
}
|
|
745
|
+
return mimeType === pattern;
|
|
1360
746
|
}
|
|
1361
747
|
/**
|
|
1362
|
-
*
|
|
1363
|
-
* @param
|
|
748
|
+
* Format file size in human-readable format
|
|
749
|
+
* @param bytes - File size in bytes
|
|
750
|
+
* @returns Formatted file size string
|
|
751
|
+
*/
|
|
752
|
+
function formatFileSize(bytes) {
|
|
753
|
+
if (bytes === 0)
|
|
754
|
+
return '0 Bytes';
|
|
755
|
+
const k = 1024;
|
|
756
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
757
|
+
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
|
758
|
+
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Sanitize user input to prevent XSS
|
|
762
|
+
* Note: Angular provides built-in sanitization, but this is an additional layer
|
|
763
|
+
* @param input - User input to sanitize
|
|
764
|
+
* @returns Sanitized input
|
|
765
|
+
*/
|
|
766
|
+
function sanitizeInput(input) {
|
|
767
|
+
if (!input)
|
|
768
|
+
return '';
|
|
769
|
+
return input
|
|
770
|
+
.replace(/&/g, '&')
|
|
771
|
+
.replace(/</g, '<')
|
|
772
|
+
.replace(/>/g, '>')
|
|
773
|
+
.replace(/"/g, '"')
|
|
774
|
+
.replace(/'/g, ''')
|
|
775
|
+
.replace(/\//g, '/')
|
|
776
|
+
.replace(/`/g, '`')
|
|
777
|
+
.replace(/=/g, '=');
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Validate latitude coordinate
|
|
781
|
+
* @param latitude - Latitude to validate
|
|
1364
782
|
* @returns Validation result
|
|
1365
783
|
*/
|
|
1366
|
-
function
|
|
1367
|
-
if (
|
|
784
|
+
function validateLatitude(latitude) {
|
|
785
|
+
if (latitude === undefined || latitude === null || typeof latitude !== 'number' || isNaN(latitude)) {
|
|
1368
786
|
return {
|
|
1369
787
|
valid: false,
|
|
1370
|
-
error: '
|
|
1371
|
-
errorCode: '
|
|
788
|
+
error: 'Latitude is required',
|
|
789
|
+
errorCode: 'MISSING_LATITUDE',
|
|
1372
790
|
};
|
|
1373
791
|
}
|
|
1374
|
-
|
|
1375
|
-
if (userId.length > 255) {
|
|
792
|
+
if (latitude < -90 || latitude > 90) {
|
|
1376
793
|
return {
|
|
1377
794
|
valid: false,
|
|
1378
|
-
error: '
|
|
1379
|
-
errorCode: '
|
|
795
|
+
error: 'Latitude must be between -90 and 90',
|
|
796
|
+
errorCode: 'INVALID_LATITUDE',
|
|
1380
797
|
};
|
|
1381
798
|
}
|
|
1382
799
|
return { valid: true };
|
|
1383
800
|
}
|
|
1384
801
|
/**
|
|
1385
|
-
* Validate
|
|
1386
|
-
* @param
|
|
1387
|
-
* @param minCount - Minimum number of users required
|
|
1388
|
-
* @param maxCount - Maximum number of users allowed
|
|
802
|
+
* Validate longitude coordinate
|
|
803
|
+
* @param longitude - Longitude to validate
|
|
1389
804
|
* @returns Validation result
|
|
1390
805
|
*/
|
|
1391
|
-
function
|
|
1392
|
-
if (
|
|
806
|
+
function validateLongitude(longitude) {
|
|
807
|
+
if (longitude === undefined || longitude === null || typeof longitude !== 'number' || isNaN(longitude)) {
|
|
1393
808
|
return {
|
|
1394
809
|
valid: false,
|
|
1395
|
-
error: '
|
|
1396
|
-
errorCode: '
|
|
810
|
+
error: 'Longitude is required',
|
|
811
|
+
errorCode: 'MISSING_LONGITUDE',
|
|
1397
812
|
};
|
|
1398
813
|
}
|
|
1399
|
-
if (
|
|
814
|
+
if (longitude < -180 || longitude > 180) {
|
|
1400
815
|
return {
|
|
1401
816
|
valid: false,
|
|
1402
|
-
error:
|
|
1403
|
-
errorCode: '
|
|
817
|
+
error: 'Longitude must be between -180 and 180',
|
|
818
|
+
errorCode: 'INVALID_LONGITUDE',
|
|
1404
819
|
};
|
|
1405
820
|
}
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
821
|
+
return { valid: true };
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* In-memory conversation and message graph (signal-based).
|
|
826
|
+
* Plain class — not DI-registered; instantiated by `AXConversationService`.
|
|
827
|
+
*/
|
|
828
|
+
class ConversationState {
|
|
829
|
+
constructor(config) {
|
|
830
|
+
this.config = config;
|
|
831
|
+
this._conversations = signal(new Map(), ...(ngDevMode ? [{ debugName: "_conversations" }] : []));
|
|
832
|
+
this._messages = signal(new Map(), ...(ngDevMode ? [{ debugName: "_messages" }] : []));
|
|
833
|
+
this._conversationMessages = signal(new Map(), ...(ngDevMode ? [{ debugName: "_conversationMessages" }] : []));
|
|
834
|
+
this.conversations = computed(() => {
|
|
835
|
+
const convMap = this._conversations();
|
|
836
|
+
return Array.from(convMap.values());
|
|
837
|
+
}, ...(ngDevMode ? [{ debugName: "conversations", equal: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]) }] : [{
|
|
838
|
+
equal: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]),
|
|
839
|
+
}]));
|
|
1412
840
|
}
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
841
|
+
setConversations(conversations) {
|
|
842
|
+
const convMap = new Map();
|
|
843
|
+
conversations.forEach((conv) => convMap.set(conv.id, conv));
|
|
844
|
+
this._conversations.set(convMap);
|
|
845
|
+
}
|
|
846
|
+
addConversations(conversations) {
|
|
847
|
+
this._conversations.update((existingConversations) => {
|
|
848
|
+
const newConversations = new Map(existingConversations);
|
|
849
|
+
conversations.forEach((conv) => newConversations.set(conv.id, conv));
|
|
850
|
+
const maxCached = this.config.maxCachedConversations;
|
|
851
|
+
if (newConversations.size > maxCached) {
|
|
852
|
+
const sorted = Array.from(newConversations.values()).sort((a, b) => (b.lastMessageAt?.getTime() ?? 0) - (a.lastMessageAt?.getTime() ?? 0));
|
|
853
|
+
const toKeep = sorted.slice(0, maxCached);
|
|
854
|
+
const cleanedMap = new Map();
|
|
855
|
+
toKeep.forEach((conv) => cleanedMap.set(conv.id, conv));
|
|
856
|
+
return cleanedMap;
|
|
857
|
+
}
|
|
858
|
+
return newConversations;
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
setConversation(conversation) {
|
|
862
|
+
this._conversations.update((conversations) => {
|
|
863
|
+
const newConversations = new Map(conversations);
|
|
864
|
+
newConversations.set(conversation.id, conversation);
|
|
865
|
+
return newConversations;
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
getConversation(conversationId) {
|
|
869
|
+
return this._conversations().get(conversationId);
|
|
870
|
+
}
|
|
871
|
+
updateConversation(conversationId, updates) {
|
|
872
|
+
const conversation = this._conversations().get(conversationId);
|
|
873
|
+
if (!conversation)
|
|
874
|
+
return;
|
|
875
|
+
this.setConversation({ ...conversation, ...updates });
|
|
876
|
+
}
|
|
877
|
+
deleteConversation(conversationId) {
|
|
878
|
+
this._conversations.update((conversations) => {
|
|
879
|
+
const newConversations = new Map(conversations);
|
|
880
|
+
newConversations.delete(conversationId);
|
|
881
|
+
return newConversations;
|
|
882
|
+
});
|
|
883
|
+
const messageIds = this._conversationMessages().get(conversationId) || [];
|
|
884
|
+
this._messages.update((messages) => {
|
|
885
|
+
const newMessages = new Map(messages);
|
|
886
|
+
messageIds.forEach((id) => newMessages.delete(id));
|
|
887
|
+
return newMessages;
|
|
888
|
+
});
|
|
889
|
+
this._conversationMessages.update((map) => {
|
|
890
|
+
const newMap = new Map(map);
|
|
891
|
+
newMap.delete(conversationId);
|
|
892
|
+
return newMap;
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
updateLastMessage(message) {
|
|
896
|
+
this.updateConversation(message.conversationId, {
|
|
897
|
+
lastMessage: message,
|
|
898
|
+
lastMessageAt: message.timestamp,
|
|
899
|
+
updatedAt: message.timestamp,
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
incrementUnreadCount(conversationId) {
|
|
903
|
+
const conversation = this._conversations().get(conversationId);
|
|
904
|
+
if (!conversation)
|
|
905
|
+
return;
|
|
906
|
+
this.updateConversation(conversationId, { unreadCount: conversation.unreadCount + 1 });
|
|
907
|
+
}
|
|
908
|
+
resetUnreadCount(conversationId) {
|
|
909
|
+
this.updateConversation(conversationId, { unreadCount: 0 });
|
|
910
|
+
}
|
|
911
|
+
updateSettings(conversationId, settings) {
|
|
912
|
+
const conversation = this._conversations().get(conversationId);
|
|
913
|
+
if (!conversation)
|
|
914
|
+
return;
|
|
915
|
+
this.updateConversation(conversationId, {
|
|
916
|
+
settings: { ...conversation.settings, ...settings },
|
|
917
|
+
updatedAt: new Date(),
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
updateTitle(conversationId, title) {
|
|
921
|
+
this.updateConversation(conversationId, { title, updatedAt: new Date() });
|
|
922
|
+
}
|
|
923
|
+
updateMetadata(conversationId, metadata) {
|
|
924
|
+
const conversation = this._conversations().get(conversationId);
|
|
925
|
+
if (!conversation)
|
|
926
|
+
return;
|
|
927
|
+
this.updateConversation(conversationId, {
|
|
928
|
+
metadata: { ...conversation.metadata, ...metadata },
|
|
929
|
+
updatedAt: new Date(),
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
updateTypingIndicator(conversationId, userId, isTyping) {
|
|
933
|
+
const conversation = this._conversations().get(conversationId);
|
|
934
|
+
if (!conversation)
|
|
935
|
+
return;
|
|
936
|
+
let typingUsers = [...conversation.status.typingUsers];
|
|
937
|
+
if (isTyping) {
|
|
938
|
+
if (!typingUsers.includes(userId)) {
|
|
939
|
+
typingUsers.push(userId);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
else {
|
|
943
|
+
typingUsers = typingUsers.filter((id) => id !== userId);
|
|
944
|
+
}
|
|
945
|
+
this.updateConversation(conversationId, {
|
|
946
|
+
status: {
|
|
947
|
+
...conversation.status,
|
|
948
|
+
isTyping: typingUsers.length > 0,
|
|
949
|
+
typingUsers,
|
|
950
|
+
},
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
updateParticipantPresence(userId, status, lastSeen) {
|
|
954
|
+
this._conversations.update((conversations) => {
|
|
955
|
+
const newConversations = new Map(conversations);
|
|
956
|
+
for (const [id, conv] of conversations) {
|
|
957
|
+
const participant = conv.participants.find((p) => p.id === userId);
|
|
958
|
+
if (participant) {
|
|
959
|
+
const updatedConversation = {
|
|
960
|
+
...conv,
|
|
961
|
+
participants: conv.participants.map((p) => (p.id === userId ? { ...p, status, lastSeen } : p)),
|
|
962
|
+
status: conv.type === 'private' ? { ...conv.status, presence: status, lastSeen } : conv.status,
|
|
963
|
+
};
|
|
964
|
+
newConversations.set(id, updatedConversation);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
return newConversations;
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
addMessage(message) {
|
|
971
|
+
this._messages.update((messages) => {
|
|
972
|
+
const newMessages = new Map(messages);
|
|
973
|
+
newMessages.set(message.id, message);
|
|
974
|
+
return newMessages;
|
|
975
|
+
});
|
|
976
|
+
this._conversationMessages.update((map) => {
|
|
977
|
+
const newMap = new Map(map);
|
|
978
|
+
const existing = newMap.get(message.conversationId) || [];
|
|
979
|
+
if (existing.includes(message.id)) {
|
|
980
|
+
return newMap;
|
|
981
|
+
}
|
|
982
|
+
const updated = [...existing, message.id].sort((a, b) => {
|
|
983
|
+
const msgA = this._messages().get(a);
|
|
984
|
+
const msgB = this._messages().get(b);
|
|
985
|
+
if (!msgA || !msgB)
|
|
986
|
+
return 0;
|
|
987
|
+
return msgA.timestamp.getTime() - msgB.timestamp.getTime();
|
|
988
|
+
});
|
|
989
|
+
newMap.set(message.conversationId, updated);
|
|
990
|
+
return newMap;
|
|
991
|
+
});
|
|
1421
992
|
}
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
};
|
|
993
|
+
addMessages(messages) {
|
|
994
|
+
if (messages.length === 0)
|
|
995
|
+
return;
|
|
996
|
+
this._messages.update((msgs) => {
|
|
997
|
+
const newMessages = new Map(msgs);
|
|
998
|
+
messages.forEach((msg) => newMessages.set(msg.id, msg));
|
|
999
|
+
return newMessages;
|
|
1000
|
+
});
|
|
1001
|
+
const conversationGroups = new Map();
|
|
1002
|
+
messages.forEach((msg) => {
|
|
1003
|
+
const existing = conversationGroups.get(msg.conversationId) || [];
|
|
1004
|
+
existing.push(msg.id);
|
|
1005
|
+
conversationGroups.set(msg.conversationId, existing);
|
|
1006
|
+
});
|
|
1007
|
+
this._conversationMessages.update((map) => {
|
|
1008
|
+
const newMap = new Map(map);
|
|
1009
|
+
for (const [conversationId, newMsgIds] of conversationGroups) {
|
|
1010
|
+
const existing = newMap.get(conversationId) || [];
|
|
1011
|
+
const idSet = new Set(existing);
|
|
1012
|
+
const merged = [...existing];
|
|
1013
|
+
for (const id of newMsgIds) {
|
|
1014
|
+
if (!idSet.has(id)) {
|
|
1015
|
+
merged.push(id);
|
|
1016
|
+
idSet.add(id);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
const sorted = merged.sort((a, b) => {
|
|
1020
|
+
const msgA = this._messages().get(a);
|
|
1021
|
+
const msgB = this._messages().get(b);
|
|
1022
|
+
if (!msgA || !msgB)
|
|
1023
|
+
return 0;
|
|
1024
|
+
return msgA.timestamp.getTime() - msgB.timestamp.getTime();
|
|
1025
|
+
});
|
|
1026
|
+
newMap.set(conversationId, sorted);
|
|
1027
|
+
}
|
|
1028
|
+
return newMap;
|
|
1029
|
+
});
|
|
1030
|
+
this.cleanupOldMessages();
|
|
1031
|
+
this.cleanupConversationMessages();
|
|
1436
1032
|
}
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
// Check length constraints
|
|
1440
|
-
if (trimmedEmail.length > 254) {
|
|
1441
|
-
return {
|
|
1442
|
-
valid: false,
|
|
1443
|
-
error: 'Email is too long',
|
|
1444
|
-
errorCode: 'INVALID_EMAIL',
|
|
1445
|
-
};
|
|
1033
|
+
getMessage(messageId) {
|
|
1034
|
+
return this._messages().get(messageId);
|
|
1446
1035
|
}
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
return {
|
|
1451
|
-
valid: false,
|
|
1452
|
-
error: 'Invalid email format',
|
|
1453
|
-
errorCode: 'INVALID_EMAIL',
|
|
1454
|
-
};
|
|
1036
|
+
getConversationMessages(conversationId) {
|
|
1037
|
+
const messageIds = this._conversationMessages().get(conversationId) || [];
|
|
1038
|
+
return messageIds.map((id) => this._messages().get(id)).filter((msg) => msg !== undefined);
|
|
1455
1039
|
}
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
* @returns Validation result
|
|
1462
|
-
*/
|
|
1463
|
-
function validateUrl(url) {
|
|
1464
|
-
if (!url || url.trim().length === 0) {
|
|
1465
|
-
return {
|
|
1466
|
-
valid: false,
|
|
1467
|
-
error: 'URL is required',
|
|
1468
|
-
errorCode: 'MISSING_URL',
|
|
1469
|
-
};
|
|
1040
|
+
updateMessage(messageId, updates) {
|
|
1041
|
+
const message = this._messages().get(messageId);
|
|
1042
|
+
if (!message)
|
|
1043
|
+
return;
|
|
1044
|
+
this.addMessage({ ...message, ...updates });
|
|
1470
1045
|
}
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1046
|
+
deleteMessage(messageId) {
|
|
1047
|
+
const message = this._messages().get(messageId);
|
|
1048
|
+
if (!message)
|
|
1049
|
+
return;
|
|
1050
|
+
this._messages.update((messages) => {
|
|
1051
|
+
const newMessages = new Map(messages);
|
|
1052
|
+
newMessages.delete(messageId);
|
|
1053
|
+
return newMessages;
|
|
1054
|
+
});
|
|
1055
|
+
this._conversationMessages.update((map) => {
|
|
1056
|
+
const newMap = new Map(map);
|
|
1057
|
+
const existing = newMap.get(message.conversationId);
|
|
1058
|
+
if (existing) {
|
|
1059
|
+
newMap.set(message.conversationId, existing.filter((id) => id !== messageId));
|
|
1060
|
+
}
|
|
1061
|
+
return newMap;
|
|
1062
|
+
});
|
|
1479
1063
|
}
|
|
1480
|
-
|
|
1481
|
-
const
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1064
|
+
cleanupOldMessages() {
|
|
1065
|
+
const totalMessages = this._messages().size;
|
|
1066
|
+
if (totalMessages > this.config.maxTotalMessages) {
|
|
1067
|
+
const allMessages = Array.from(this._messages().values()).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
|
1068
|
+
const toKeep = allMessages.slice(0, this.config.maxTotalMessages);
|
|
1069
|
+
const toKeepIds = new Set(toKeep.map((m) => m.id));
|
|
1070
|
+
this._messages.update(() => {
|
|
1071
|
+
const newMessages = new Map();
|
|
1072
|
+
toKeep.forEach((msg) => newMessages.set(msg.id, msg));
|
|
1073
|
+
return newMessages;
|
|
1074
|
+
});
|
|
1075
|
+
this._conversationMessages.update((map) => {
|
|
1076
|
+
const newMap = new Map(map);
|
|
1077
|
+
for (const [convId, messageIds] of newMap) {
|
|
1078
|
+
const filteredIds = messageIds.filter((id) => toKeepIds.has(id));
|
|
1079
|
+
newMap.set(convId, filteredIds);
|
|
1080
|
+
}
|
|
1081
|
+
return newMap;
|
|
1082
|
+
});
|
|
1489
1083
|
}
|
|
1490
|
-
return { valid: true };
|
|
1491
|
-
}
|
|
1492
|
-
catch {
|
|
1493
|
-
return {
|
|
1494
|
-
valid: false,
|
|
1495
|
-
error: 'Invalid URL format',
|
|
1496
|
-
errorCode: 'INVALID_URL',
|
|
1497
|
-
};
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1500
|
-
// =====================
|
|
1501
|
-
// Helper Functions
|
|
1502
|
-
// =====================
|
|
1503
|
-
/**
|
|
1504
|
-
* Match MIME type against a pattern (supports wildcards)
|
|
1505
|
-
* @param mimeType - MIME type to check
|
|
1506
|
-
* @param pattern - Pattern to match (e.g., "image/*", "video/mp4")
|
|
1507
|
-
* @returns Whether the MIME type matches the pattern
|
|
1508
|
-
*/
|
|
1509
|
-
function matchMimeType(mimeType, pattern) {
|
|
1510
|
-
if (pattern === '*/*' || pattern === '*') {
|
|
1511
|
-
return true;
|
|
1512
1084
|
}
|
|
1513
|
-
|
|
1514
|
-
const
|
|
1515
|
-
|
|
1085
|
+
cleanupConversationMessages() {
|
|
1086
|
+
const maxMessages = this.config.maxMessagesPerConversation;
|
|
1087
|
+
const idsToRemove = [];
|
|
1088
|
+
this._conversationMessages.update((map) => {
|
|
1089
|
+
const newMap = new Map(map);
|
|
1090
|
+
for (const [convId, messageIds] of newMap) {
|
|
1091
|
+
if (messageIds.length > maxMessages) {
|
|
1092
|
+
idsToRemove.push(...messageIds.slice(0, messageIds.length - maxMessages));
|
|
1093
|
+
newMap.set(convId, messageIds.slice(-maxMessages));
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
return newMap;
|
|
1097
|
+
});
|
|
1098
|
+
if (idsToRemove.length > 0) {
|
|
1099
|
+
this._messages.update((messages) => {
|
|
1100
|
+
const newMessages = new Map(messages);
|
|
1101
|
+
idsToRemove.forEach((id) => newMessages.delete(id));
|
|
1102
|
+
return newMessages;
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1516
1105
|
}
|
|
1517
|
-
return mimeType === pattern;
|
|
1518
|
-
}
|
|
1519
|
-
/**
|
|
1520
|
-
* Format file size in human-readable format
|
|
1521
|
-
* @param bytes - File size in bytes
|
|
1522
|
-
* @returns Formatted file size string
|
|
1523
|
-
*/
|
|
1524
|
-
function formatFileSize(bytes) {
|
|
1525
|
-
if (bytes === 0)
|
|
1526
|
-
return '0 Bytes';
|
|
1527
|
-
const k = 1024;
|
|
1528
|
-
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
1529
|
-
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
|
1530
|
-
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
|
1531
1106
|
}
|
|
1107
|
+
|
|
1532
1108
|
/**
|
|
1533
|
-
*
|
|
1534
|
-
*
|
|
1535
|
-
* @param input - User input to sanitize
|
|
1536
|
-
* @returns Sanitized input
|
|
1109
|
+
* Error Handler Service
|
|
1110
|
+
* Centralized error handling and logging
|
|
1537
1111
|
*/
|
|
1538
|
-
function sanitizeInput(input) {
|
|
1539
|
-
if (!input)
|
|
1540
|
-
return '';
|
|
1541
|
-
return input
|
|
1542
|
-
.replace(/&/g, '&')
|
|
1543
|
-
.replace(/</g, '<')
|
|
1544
|
-
.replace(/>/g, '>')
|
|
1545
|
-
.replace(/"/g, '"')
|
|
1546
|
-
.replace(/'/g, ''')
|
|
1547
|
-
.replace(/\//g, '/')
|
|
1548
|
-
.replace(/`/g, '`')
|
|
1549
|
-
.replace(/=/g, '=');
|
|
1550
|
-
}
|
|
1551
1112
|
/**
|
|
1552
|
-
*
|
|
1553
|
-
* @param latitude - Latitude to validate
|
|
1554
|
-
* @returns Validation result
|
|
1113
|
+
* Error Handler Service
|
|
1555
1114
|
*/
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1115
|
+
class AXErrorHandlerService {
|
|
1116
|
+
constructor() {
|
|
1117
|
+
this.injectedConfig = inject(ERROR_HANDLER_CONFIG);
|
|
1118
|
+
this._errors$ = new Subject();
|
|
1119
|
+
this._config = {
|
|
1120
|
+
logToConsole: true,
|
|
1121
|
+
showUserMessages: true,
|
|
1122
|
+
autoRetry: false,
|
|
1123
|
+
maxRetries: 3,
|
|
1562
1124
|
};
|
|
1125
|
+
/** Error stream */
|
|
1126
|
+
this.errors$ = this._errors$.asObservable();
|
|
1127
|
+
this.configure(this.injectedConfig);
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Configure error handler
|
|
1131
|
+
*/
|
|
1132
|
+
configure(config) {
|
|
1133
|
+
Object.assign(this._config, config);
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Handle an error
|
|
1137
|
+
*/
|
|
1138
|
+
handle(error, operation, context) {
|
|
1139
|
+
const conversationError = this.normalizeError(error, operation, context);
|
|
1140
|
+
this.publish(conversationError);
|
|
1141
|
+
return conversationError;
|
|
1563
1142
|
}
|
|
1564
|
-
|
|
1143
|
+
/**
|
|
1144
|
+
* Handle API error (same pipeline as {@link handle}, for typed API failures)
|
|
1145
|
+
*/
|
|
1146
|
+
handleApiError(apiError, operation, context) {
|
|
1147
|
+
const conversationError = this.conversationErrorFromApi(apiError, operation, context);
|
|
1148
|
+
this.publish(conversationError);
|
|
1149
|
+
return conversationError;
|
|
1150
|
+
}
|
|
1151
|
+
publish(conversationError) {
|
|
1152
|
+
this._errors$.next(conversationError);
|
|
1153
|
+
if (this._config.logToConsole) {
|
|
1154
|
+
this.logError(conversationError);
|
|
1155
|
+
}
|
|
1156
|
+
if (this._config.customHandler) {
|
|
1157
|
+
this._config.customHandler(conversationError);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Normalize any error to conversation error format (does not publish — use {@link handle})
|
|
1162
|
+
*/
|
|
1163
|
+
normalizeError(error, operation, context) {
|
|
1164
|
+
if (this.isApiError(error)) {
|
|
1165
|
+
return this.conversationErrorFromApi(error, operation, context);
|
|
1166
|
+
}
|
|
1167
|
+
const errorObj = error;
|
|
1168
|
+
const message = (typeof errorObj['message'] === 'string' && errorObj['message']) ||
|
|
1169
|
+
(error instanceof Error ? error.message : String(error)) ||
|
|
1170
|
+
'An unknown error occurred';
|
|
1171
|
+
const code = (typeof errorObj['code'] === 'string' && errorObj['code']) || 'UNKNOWN_ERROR';
|
|
1172
|
+
const statusCodeRaw = errorObj['statusCode'] ?? errorObj['status'];
|
|
1173
|
+
const statusCode = typeof statusCodeRaw === 'number' ? statusCodeRaw : undefined;
|
|
1565
1174
|
return {
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1175
|
+
code,
|
|
1176
|
+
message,
|
|
1177
|
+
severity: 'error',
|
|
1178
|
+
operation,
|
|
1179
|
+
originalError: error,
|
|
1180
|
+
statusCode,
|
|
1181
|
+
context,
|
|
1182
|
+
timestamp: new Date(),
|
|
1183
|
+
handled: false,
|
|
1184
|
+
recoverySuggestions: this.getDefaultRecoverySuggestions(code),
|
|
1569
1185
|
};
|
|
1570
1186
|
}
|
|
1571
|
-
|
|
1572
|
-
}
|
|
1573
|
-
/**
|
|
1574
|
-
* Validate longitude coordinate
|
|
1575
|
-
* @param longitude - Longitude to validate
|
|
1576
|
-
* @returns Validation result
|
|
1577
|
-
*/
|
|
1578
|
-
function validateLongitude(longitude) {
|
|
1579
|
-
if (longitude === undefined || longitude === null || typeof longitude !== 'number' || isNaN(longitude)) {
|
|
1187
|
+
conversationErrorFromApi(apiError, operation, context) {
|
|
1580
1188
|
return {
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1189
|
+
code: apiError.code,
|
|
1190
|
+
message: apiError.message,
|
|
1191
|
+
severity: this.determineSeverity(apiError.statusCode),
|
|
1192
|
+
operation,
|
|
1193
|
+
originalError: apiError,
|
|
1194
|
+
statusCode: apiError.statusCode,
|
|
1195
|
+
context,
|
|
1196
|
+
timestamp: apiError.timestamp ?? new Date(),
|
|
1197
|
+
handled: false,
|
|
1198
|
+
recoverySuggestions: this.getRecoverySuggestions(apiError),
|
|
1584
1199
|
};
|
|
1585
1200
|
}
|
|
1586
|
-
|
|
1587
|
-
return
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1201
|
+
isApiError(error) {
|
|
1202
|
+
return (typeof error === 'object' &&
|
|
1203
|
+
error !== null &&
|
|
1204
|
+
'code' in error &&
|
|
1205
|
+
'message' in error &&
|
|
1206
|
+
typeof error.code === 'string' &&
|
|
1207
|
+
typeof error.message === 'string');
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Determine severity based on status code
|
|
1211
|
+
*/
|
|
1212
|
+
determineSeverity(statusCode) {
|
|
1213
|
+
if (!statusCode)
|
|
1214
|
+
return 'error';
|
|
1215
|
+
if (statusCode >= 500)
|
|
1216
|
+
return 'critical';
|
|
1217
|
+
if (statusCode >= 400)
|
|
1218
|
+
return 'error';
|
|
1219
|
+
if (statusCode >= 300)
|
|
1220
|
+
return 'warning';
|
|
1221
|
+
return 'info';
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Get recovery suggestions based on error
|
|
1225
|
+
*/
|
|
1226
|
+
getRecoverySuggestions(error) {
|
|
1227
|
+
const suggestions = [];
|
|
1228
|
+
if (error.statusCode === 401 || error.code === 'UNAUTHORIZED') {
|
|
1229
|
+
suggestions.push('Please log in again');
|
|
1230
|
+
suggestions.push('Check if your session has expired');
|
|
1231
|
+
}
|
|
1232
|
+
else if (error.statusCode === 403 || error.code === 'FORBIDDEN') {
|
|
1233
|
+
suggestions.push('You do not have permission for this action');
|
|
1234
|
+
suggestions.push('Ask your administrator for access');
|
|
1235
|
+
}
|
|
1236
|
+
else if (error.statusCode === 404 || error.code === 'NOT_FOUND') {
|
|
1237
|
+
suggestions.push('The requested resource was not found');
|
|
1238
|
+
suggestions.push('It may have been deleted or moved');
|
|
1239
|
+
}
|
|
1240
|
+
else if (error.statusCode === 429 || error.code === 'RATE_LIMIT_EXCEEDED') {
|
|
1241
|
+
suggestions.push('Too many requests. Please wait and try again');
|
|
1242
|
+
}
|
|
1243
|
+
else if (error.statusCode && error.statusCode >= 500) {
|
|
1244
|
+
suggestions.push('Server error occurred');
|
|
1245
|
+
suggestions.push('Please try again later');
|
|
1246
|
+
suggestions.push('If the problem persists, reach out to support');
|
|
1247
|
+
}
|
|
1248
|
+
else if (error.code === 'NETWORK_ERROR') {
|
|
1249
|
+
suggestions.push('Check your internet connection');
|
|
1250
|
+
suggestions.push('Try refreshing the page');
|
|
1251
|
+
}
|
|
1252
|
+
return suggestions;
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Get default recovery suggestions
|
|
1256
|
+
*/
|
|
1257
|
+
getDefaultRecoverySuggestions(code) {
|
|
1258
|
+
const suggestions = [];
|
|
1259
|
+
if (code.includes('NETWORK') || code.includes('CONNECTION')) {
|
|
1260
|
+
suggestions.push('Check your internet connection');
|
|
1261
|
+
suggestions.push('Try refreshing the page');
|
|
1262
|
+
}
|
|
1263
|
+
else if (code.includes('TIMEOUT')) {
|
|
1264
|
+
suggestions.push('The operation took too long');
|
|
1265
|
+
suggestions.push('Please try again');
|
|
1266
|
+
}
|
|
1267
|
+
else {
|
|
1268
|
+
suggestions.push('Please try again');
|
|
1269
|
+
suggestions.push('If the problem persists, reach out to support');
|
|
1270
|
+
}
|
|
1271
|
+
return suggestions;
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* Log error to console
|
|
1275
|
+
*/
|
|
1276
|
+
logError(error) {
|
|
1277
|
+
const isError = error.severity === 'critical' || error.severity === 'error';
|
|
1278
|
+
const header = `[Conversation ${error.severity.toUpperCase()}] ${error.operation}:`;
|
|
1279
|
+
if (isError) {
|
|
1280
|
+
console.error(header, error.message, error.context || '');
|
|
1281
|
+
}
|
|
1282
|
+
else {
|
|
1283
|
+
console.warn(header, error.message, error.context || '');
|
|
1284
|
+
}
|
|
1285
|
+
if (error.originalError && error.severity !== 'info') {
|
|
1286
|
+
console.error('Original error:', error.originalError);
|
|
1287
|
+
}
|
|
1288
|
+
if (error.recoverySuggestions && error.recoverySuggestions.length > 0) {
|
|
1289
|
+
console.info('Recovery suggestions:', error.recoverySuggestions);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Get user-friendly error message
|
|
1294
|
+
*/
|
|
1295
|
+
getUserFriendlyMessage(error) {
|
|
1296
|
+
// Map technical errors to user-friendly messages
|
|
1297
|
+
const messageMap = {
|
|
1298
|
+
NETWORK_ERROR: 'Unable to connect. Please check your internet connection.',
|
|
1299
|
+
UNAUTHORIZED: 'You are not authorized. Please log in again.',
|
|
1300
|
+
FORBIDDEN: 'You do not have permission to perform this action.',
|
|
1301
|
+
NOT_FOUND: 'The requested item could not be found.',
|
|
1302
|
+
RATE_LIMIT_EXCEEDED: 'Too many requests. Please slow down.',
|
|
1303
|
+
VALIDATION_ERROR: 'The provided data is invalid.',
|
|
1304
|
+
SERVER_ERROR: 'A server error occurred. Please try again later.',
|
|
1305
|
+
TIMEOUT: 'The operation timed out. Please try again.',
|
|
1591
1306
|
};
|
|
1307
|
+
return messageMap[error.code] || error.message || 'An unexpected error occurred.';
|
|
1592
1308
|
}
|
|
1593
|
-
|
|
1309
|
+
/**
|
|
1310
|
+
* Check if error is retryable
|
|
1311
|
+
*/
|
|
1312
|
+
isRetryable(error) {
|
|
1313
|
+
const retryableCodes = ['NETWORK_ERROR', 'TIMEOUT', 'RATE_LIMIT_EXCEEDED', 'SERVER_ERROR'];
|
|
1314
|
+
const retryableStatusCodes = [408, 429, 500, 502, 503, 504];
|
|
1315
|
+
return (retryableCodes.includes(error.code) ||
|
|
1316
|
+
(error.statusCode !== undefined && retryableStatusCodes.includes(error.statusCode)));
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Execute an operation with automatic retry logic
|
|
1320
|
+
* @param operation - The async operation to execute
|
|
1321
|
+
* @param operationName - Name of the operation for error tracking
|
|
1322
|
+
* @param context - Additional context for error handling
|
|
1323
|
+
* @returns Promise resolving to the operation result
|
|
1324
|
+
* @throws {AXConversationError} If all retries fail
|
|
1325
|
+
*/
|
|
1326
|
+
async executeWithRetry(operation, operationName, context) {
|
|
1327
|
+
if (!this._config.autoRetry) {
|
|
1328
|
+
// If auto-retry is disabled, just execute once
|
|
1329
|
+
return operation();
|
|
1330
|
+
}
|
|
1331
|
+
const maxRetries = this._config.maxRetries ?? 3;
|
|
1332
|
+
let lastError;
|
|
1333
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1334
|
+
try {
|
|
1335
|
+
return await operation();
|
|
1336
|
+
}
|
|
1337
|
+
catch (error) {
|
|
1338
|
+
lastError = error;
|
|
1339
|
+
const conversationError = this.handle(error, operationName, { ...context, attempt });
|
|
1340
|
+
// Don't retry if error is not retryable or if this was the last attempt
|
|
1341
|
+
if (!this.isRetryable(conversationError) || attempt >= maxRetries) {
|
|
1342
|
+
throw conversationError;
|
|
1343
|
+
}
|
|
1344
|
+
// Exponential backoff: 1s, 2s, 4s, 8s...
|
|
1345
|
+
const delayMs = Math.pow(2, attempt) * 1000;
|
|
1346
|
+
await this.delay(delayMs);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
throw this.handle(lastError, operationName, context);
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* Delay helper for retry backoff
|
|
1353
|
+
* @param ms - Milliseconds to delay
|
|
1354
|
+
*/
|
|
1355
|
+
delay(ms) {
|
|
1356
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1357
|
+
}
|
|
1358
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXErrorHandlerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
1359
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXErrorHandlerService, providedIn: 'root' }); }
|
|
1594
1360
|
}
|
|
1361
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImport: i0, type: AXErrorHandlerService, decorators: [{
|
|
1362
|
+
type: Injectable,
|
|
1363
|
+
args: [{
|
|
1364
|
+
providedIn: 'root',
|
|
1365
|
+
}]
|
|
1366
|
+
}], ctorParameters: () => [] });
|
|
1595
1367
|
|
|
1596
1368
|
/**
|
|
1597
1369
|
* Composer Action Registry
|
|
@@ -4970,24 +4742,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImpor
|
|
|
4970
4742
|
*/
|
|
4971
4743
|
|
|
4972
4744
|
/**
|
|
4973
|
-
* File
|
|
4974
|
-
* Centralized service for handling file uploads and processing
|
|
4745
|
+
* File helpers for composer pickers: previews, validation, and metadata.
|
|
4975
4746
|
*/
|
|
4976
4747
|
class AXFileUploadService {
|
|
4977
4748
|
constructor() {
|
|
4978
4749
|
this.config = inject(CONVERSATION_CONFIG);
|
|
4979
4750
|
this.platformId = inject(PLATFORM_ID);
|
|
4980
|
-
this.uploadProgress$ = new Subject();
|
|
4981
|
-
}
|
|
4982
|
-
/**
|
|
4983
|
-
* Get upload progress observable
|
|
4984
|
-
*/
|
|
4985
|
-
getUploadProgress() {
|
|
4986
|
-
return this.uploadProgress$.asObservable();
|
|
4987
4751
|
}
|
|
4988
|
-
/**
|
|
4989
|
-
* Read file as data URL for preview
|
|
4990
|
-
*/
|
|
4991
4752
|
async readFileAsDataURL(file) {
|
|
4992
4753
|
return new Promise((resolve, reject) => {
|
|
4993
4754
|
const reader = new FileReader();
|
|
@@ -4996,9 +4757,6 @@ class AXFileUploadService {
|
|
|
4996
4757
|
reader.readAsDataURL(file);
|
|
4997
4758
|
});
|
|
4998
4759
|
}
|
|
4999
|
-
/**
|
|
5000
|
-
* Get file type category
|
|
5001
|
-
*/
|
|
5002
4760
|
getFileType(file) {
|
|
5003
4761
|
if (file.type.startsWith('image/'))
|
|
5004
4762
|
return 'image';
|
|
@@ -5008,9 +4766,6 @@ class AXFileUploadService {
|
|
|
5008
4766
|
return 'audio';
|
|
5009
4767
|
return 'file';
|
|
5010
4768
|
}
|
|
5011
|
-
/**
|
|
5012
|
-
* Generate file preview
|
|
5013
|
-
*/
|
|
5014
4769
|
async generatePreview(file) {
|
|
5015
4770
|
const type = this.getFileType(file);
|
|
5016
4771
|
let preview;
|
|
@@ -5024,15 +4779,9 @@ class AXFileUploadService {
|
|
|
5024
4779
|
}
|
|
5025
4780
|
return { file, preview, type };
|
|
5026
4781
|
}
|
|
5027
|
-
/**
|
|
5028
|
-
* Generate previews for multiple files
|
|
5029
|
-
*/
|
|
5030
4782
|
async generatePreviews(files) {
|
|
5031
4783
|
return Promise.all(files.map((file) => this.generatePreview(file)));
|
|
5032
4784
|
}
|
|
5033
|
-
/**
|
|
5034
|
-
* Get video duration
|
|
5035
|
-
*/
|
|
5036
4785
|
async getVideoDuration(file) {
|
|
5037
4786
|
if (!isPlatformBrowser(this.platformId)) {
|
|
5038
4787
|
return 0;
|
|
@@ -5051,9 +4800,6 @@ class AXFileUploadService {
|
|
|
5051
4800
|
video.src = URL.createObjectURL(file);
|
|
5052
4801
|
});
|
|
5053
4802
|
}
|
|
5054
|
-
/**
|
|
5055
|
-
* Get audio duration
|
|
5056
|
-
*/
|
|
5057
4803
|
async getAudioDuration(file) {
|
|
5058
4804
|
if (!isPlatformBrowser(this.platformId)) {
|
|
5059
4805
|
return 0;
|
|
@@ -5071,9 +4817,6 @@ class AXFileUploadService {
|
|
|
5071
4817
|
audio.src = URL.createObjectURL(file);
|
|
5072
4818
|
});
|
|
5073
4819
|
}
|
|
5074
|
-
/**
|
|
5075
|
-
* Format file size
|
|
5076
|
-
*/
|
|
5077
4820
|
formatFileSize(bytes) {
|
|
5078
4821
|
if (bytes < 1024)
|
|
5079
4822
|
return `${bytes} B`;
|
|
@@ -5083,17 +4826,11 @@ class AXFileUploadService {
|
|
|
5083
4826
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
5084
4827
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
5085
4828
|
}
|
|
5086
|
-
/**
|
|
5087
|
-
* Format duration (seconds to mm:ss)
|
|
5088
|
-
*/
|
|
5089
4829
|
formatDuration(seconds) {
|
|
5090
4830
|
const mins = Math.floor(seconds / 60);
|
|
5091
4831
|
const secs = Math.floor(seconds % 60);
|
|
5092
4832
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
5093
4833
|
}
|
|
5094
|
-
/**
|
|
5095
|
-
* Validate file type
|
|
5096
|
-
*/
|
|
5097
4834
|
validateFileType(file, allowedTypes) {
|
|
5098
4835
|
return allowedTypes.some((type) => {
|
|
5099
4836
|
if (type.endsWith('/*')) {
|
|
@@ -5103,34 +4840,19 @@ class AXFileUploadService {
|
|
|
5103
4840
|
return file.type === type;
|
|
5104
4841
|
});
|
|
5105
4842
|
}
|
|
5106
|
-
/**
|
|
5107
|
-
* Validate file size
|
|
5108
|
-
*/
|
|
5109
4843
|
validateFileSize(file, maxSizeInMB) {
|
|
5110
4844
|
const maxSizeInBytes = maxSizeInMB * 1024 * 1024;
|
|
5111
4845
|
return file.size <= maxSizeInBytes;
|
|
5112
4846
|
}
|
|
5113
|
-
/**
|
|
5114
|
-
* Filter files by type
|
|
5115
|
-
*/
|
|
5116
4847
|
filterFilesByType(files, allowedTypes) {
|
|
5117
4848
|
return files.filter((file) => this.validateFileType(file, allowedTypes));
|
|
5118
4849
|
}
|
|
5119
|
-
/**
|
|
5120
|
-
* Validate file type against conversation config
|
|
5121
|
-
*/
|
|
5122
4850
|
isFileTypeAllowed(file) {
|
|
5123
4851
|
return this.validateFileType(file, this.config.allowedFileTypes);
|
|
5124
4852
|
}
|
|
5125
|
-
/**
|
|
5126
|
-
* Validate file size against conversation config
|
|
5127
|
-
*/
|
|
5128
4853
|
isFileSizeAllowed(file) {
|
|
5129
4854
|
return file.size <= this.config.maxFileSize;
|
|
5130
4855
|
}
|
|
5131
|
-
/**
|
|
5132
|
-
* Validate a set of files using conversation config
|
|
5133
|
-
*/
|
|
5134
4856
|
validateFilesWithConfig(files) {
|
|
5135
4857
|
const accepted = [];
|
|
5136
4858
|
const rejected = [];
|
|
@@ -11895,10 +11617,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.3", ngImpor
|
|
|
11895
11617
|
type: Injectable
|
|
11896
11618
|
}], ctorParameters: () => [] });
|
|
11897
11619
|
|
|
11898
|
-
/**
|
|
11899
|
-
* Registry Service
|
|
11900
|
-
* Central service for managing all registries and extensions
|
|
11901
|
-
*/
|
|
11620
|
+
/** Unified access to conversation2 registries (renderers, actions, tabs). */
|
|
11902
11621
|
/**
|
|
11903
11622
|
* Central Registry Service
|
|
11904
11623
|
* Provides unified access to all registries
|
|
@@ -11967,12 +11686,12 @@ class AXConversationService {
|
|
|
11967
11686
|
}
|
|
11968
11687
|
constructor() {
|
|
11969
11688
|
this.config = inject(CONVERSATION_CONFIG);
|
|
11689
|
+
this.state = new ConversationState(this.config);
|
|
11970
11690
|
// New separated APIs
|
|
11971
11691
|
this.userApi = inject(AXUserApi);
|
|
11972
11692
|
this.conversationApi = inject(AXConversationApi);
|
|
11973
11693
|
this.messageApi = inject(AXMessageApi);
|
|
11974
11694
|
this.realtimeApi = inject(AXRealtimeApi, { optional: true });
|
|
11975
|
-
this.store = inject(AXConversationStoreService);
|
|
11976
11695
|
this.errorHandler = inject(AXErrorHandlerService);
|
|
11977
11696
|
this.dialogService = inject(AXDialogService);
|
|
11978
11697
|
this.popupService = inject(AXPopupService);
|
|
@@ -11994,18 +11713,18 @@ class AXConversationService {
|
|
|
11994
11713
|
this._typingIndicator$ = new Subject();
|
|
11995
11714
|
this._presenceUpdate$ = new Subject();
|
|
11996
11715
|
/** All conversations */
|
|
11997
|
-
this.conversations = this.
|
|
11716
|
+
this.conversations = this.state.conversations;
|
|
11998
11717
|
/** Active conversation ID */
|
|
11999
11718
|
this.activeConversationId = this._activeConversationId.asReadonly();
|
|
12000
11719
|
/** Active conversation */
|
|
12001
11720
|
this.activeConversation = computed(() => {
|
|
12002
11721
|
const id = this._activeConversationId();
|
|
12003
|
-
return id ? this.
|
|
11722
|
+
return id ? this.state.getConversation(id) : null;
|
|
12004
11723
|
}, ...(ngDevMode ? [{ debugName: "activeConversation" }] : []));
|
|
12005
11724
|
/** Messages for active conversation */
|
|
12006
11725
|
this.activeMessages = computed(() => {
|
|
12007
11726
|
const convId = this._activeConversationId();
|
|
12008
|
-
return convId ? this.
|
|
11727
|
+
return convId ? this.state.getConversationMessages(convId) : [];
|
|
12009
11728
|
}, ...(ngDevMode ? [{ debugName: "activeMessages" }] : []));
|
|
12010
11729
|
/** Loading state */
|
|
12011
11730
|
this.loading = this._loading.asReadonly();
|
|
@@ -12041,8 +11760,6 @@ class AXConversationService {
|
|
|
12041
11760
|
*/
|
|
12042
11761
|
async initializeService() {
|
|
12043
11762
|
try {
|
|
12044
|
-
// Initialize store
|
|
12045
|
-
await this.store.initialize();
|
|
12046
11763
|
// Connect to real-time API (optional)
|
|
12047
11764
|
if (this.realtimeApi) {
|
|
12048
11765
|
await this.realtimeApi.connect();
|
|
@@ -12139,7 +11856,7 @@ class AXConversationService {
|
|
|
12139
11856
|
pageSize: this.config.conversationPageSize,
|
|
12140
11857
|
}, undefined);
|
|
12141
11858
|
// Store conversations
|
|
12142
|
-
this.
|
|
11859
|
+
this.state.setConversations(result.items);
|
|
12143
11860
|
}
|
|
12144
11861
|
catch (error) {
|
|
12145
11862
|
const handledError = this.errorHandler.handle(error, 'loadConversations');
|
|
@@ -12161,7 +11878,7 @@ class AXConversationService {
|
|
|
12161
11878
|
}, undefined);
|
|
12162
11879
|
// Append new conversations to existing ones
|
|
12163
11880
|
if (result.items.length > 0) {
|
|
12164
|
-
this.
|
|
11881
|
+
this.state.addConversations(result.items);
|
|
12165
11882
|
}
|
|
12166
11883
|
return result.hasMore;
|
|
12167
11884
|
}
|
|
@@ -12216,7 +11933,7 @@ class AXConversationService {
|
|
|
12216
11933
|
page,
|
|
12217
11934
|
pageSize: this.config.messagePageSize,
|
|
12218
11935
|
});
|
|
12219
|
-
this.
|
|
11936
|
+
this.state.addMessages(result.items);
|
|
12220
11937
|
return result.items;
|
|
12221
11938
|
}
|
|
12222
11939
|
catch (error) {
|
|
@@ -12254,12 +11971,12 @@ class AXConversationService {
|
|
|
12254
11971
|
metadata: command.metadata,
|
|
12255
11972
|
};
|
|
12256
11973
|
// Add to store immediately (optimistic update)
|
|
12257
|
-
this.
|
|
11974
|
+
this.state.addMessage(tempMessage);
|
|
12258
11975
|
// Send to server
|
|
12259
11976
|
const sentMessage = await this.messageApi.sendMessage(command);
|
|
12260
11977
|
// Replace temporary message with real one
|
|
12261
|
-
this.
|
|
12262
|
-
this.
|
|
11978
|
+
this.state.deleteMessage(tempMessageId);
|
|
11979
|
+
this.state.addMessage(sentMessage);
|
|
12263
11980
|
// Update conversation's last message
|
|
12264
11981
|
this.updateConversationLastMessage(sentMessage);
|
|
12265
11982
|
// Emit forward count update if this is a forwarded message
|
|
@@ -12272,7 +11989,7 @@ class AXConversationService {
|
|
|
12272
11989
|
}
|
|
12273
11990
|
catch (error) {
|
|
12274
11991
|
// Mark temp message as failed
|
|
12275
|
-
this.
|
|
11992
|
+
this.state.updateMessage(tempMessageId, { status: 'failed' });
|
|
12276
11993
|
this.errorHandler.handle(error, 'sendMessage', {
|
|
12277
11994
|
conversationId: command.conversationId,
|
|
12278
11995
|
type: command.type,
|
|
@@ -12284,12 +12001,12 @@ class AXConversationService {
|
|
|
12284
12001
|
* Retry a failed message
|
|
12285
12002
|
*/
|
|
12286
12003
|
async retryFailedMessage(messageId) {
|
|
12287
|
-
const message = this.
|
|
12004
|
+
const message = this.state.getMessage(messageId);
|
|
12288
12005
|
if (!message || message.status !== 'failed') {
|
|
12289
12006
|
return;
|
|
12290
12007
|
}
|
|
12291
12008
|
// Update status to sending
|
|
12292
|
-
this.
|
|
12009
|
+
this.state.updateMessage(messageId, { status: 'sending' });
|
|
12293
12010
|
try {
|
|
12294
12011
|
// Recreate command from message
|
|
12295
12012
|
const command = {
|
|
@@ -12304,14 +12021,14 @@ class AXConversationService {
|
|
|
12304
12021
|
// Send to server
|
|
12305
12022
|
const sentMessage = await this.messageApi.sendMessage(command);
|
|
12306
12023
|
// Replace with real message
|
|
12307
|
-
this.
|
|
12308
|
-
this.
|
|
12024
|
+
this.state.deleteMessage(messageId);
|
|
12025
|
+
this.state.addMessage(sentMessage);
|
|
12309
12026
|
// Update conversation's last message
|
|
12310
12027
|
this.updateConversationLastMessage(sentMessage);
|
|
12311
12028
|
}
|
|
12312
12029
|
catch (error) {
|
|
12313
12030
|
// Mark as failed again
|
|
12314
|
-
this.
|
|
12031
|
+
this.state.updateMessage(messageId, { status: 'failed' });
|
|
12315
12032
|
this.errorHandler.handle(error, 'retryFailedMessage', { messageId });
|
|
12316
12033
|
throw error;
|
|
12317
12034
|
}
|
|
@@ -12322,10 +12039,10 @@ class AXConversationService {
|
|
|
12322
12039
|
*/
|
|
12323
12040
|
async editMessage(messageId, payload) {
|
|
12324
12041
|
// Store original message for rollback
|
|
12325
|
-
const originalMessage = this.
|
|
12042
|
+
const originalMessage = this.state.getMessage(messageId);
|
|
12326
12043
|
try {
|
|
12327
12044
|
// Optimistic update
|
|
12328
|
-
this.
|
|
12045
|
+
this.state.updateMessage(messageId, {
|
|
12329
12046
|
payload,
|
|
12330
12047
|
editedAt: new Date(),
|
|
12331
12048
|
});
|
|
@@ -12335,7 +12052,7 @@ class AXConversationService {
|
|
|
12335
12052
|
catch (error) {
|
|
12336
12053
|
// Rollback on failure
|
|
12337
12054
|
if (originalMessage) {
|
|
12338
|
-
this.
|
|
12055
|
+
this.state.updateMessage(messageId, {
|
|
12339
12056
|
payload: originalMessage.payload,
|
|
12340
12057
|
editedAt: originalMessage.editedAt,
|
|
12341
12058
|
});
|
|
@@ -12358,17 +12075,17 @@ class AXConversationService {
|
|
|
12358
12075
|
return;
|
|
12359
12076
|
}
|
|
12360
12077
|
// Store message for rollback
|
|
12361
|
-
const message = this.
|
|
12078
|
+
const message = this.state.getMessage(messageId);
|
|
12362
12079
|
try {
|
|
12363
12080
|
// Optimistic delete
|
|
12364
|
-
this.
|
|
12081
|
+
this.state.deleteMessage(messageId);
|
|
12365
12082
|
// Sync with server
|
|
12366
12083
|
await this.messageApi.deleteMessage(messageId, forEveryone);
|
|
12367
12084
|
}
|
|
12368
12085
|
catch (error) {
|
|
12369
12086
|
// Rollback on failure
|
|
12370
12087
|
if (message) {
|
|
12371
|
-
this.
|
|
12088
|
+
this.state.addMessage(message);
|
|
12372
12089
|
}
|
|
12373
12090
|
this.errorHandler.handle(error, 'deleteMessage', { messageId, forEveryone });
|
|
12374
12091
|
throw error;
|
|
@@ -12407,25 +12124,25 @@ class AXConversationService {
|
|
|
12407
12124
|
* Updates message status locally and syncs with server
|
|
12408
12125
|
*/
|
|
12409
12126
|
async markMessageAsRead(messageId) {
|
|
12410
|
-
const message = this.
|
|
12127
|
+
const message = this.state.getMessage(messageId);
|
|
12411
12128
|
if (!message || message.status === 'read')
|
|
12412
12129
|
return;
|
|
12413
12130
|
try {
|
|
12414
12131
|
// Optimistic update
|
|
12415
|
-
this.
|
|
12132
|
+
this.state.updateMessage(messageId, { status: 'read' });
|
|
12416
12133
|
// Sync with server
|
|
12417
12134
|
await this.messageApi.markAsRead(message.conversationId, [messageId]);
|
|
12418
12135
|
// Update conversation unread count
|
|
12419
|
-
const conversation = this.
|
|
12136
|
+
const conversation = this.state.getConversation(message.conversationId);
|
|
12420
12137
|
if (conversation && conversation.unreadCount > 0) {
|
|
12421
|
-
this.
|
|
12138
|
+
this.state.updateConversation(message.conversationId, {
|
|
12422
12139
|
unreadCount: Math.max(0, conversation.unreadCount - 1),
|
|
12423
12140
|
});
|
|
12424
12141
|
}
|
|
12425
12142
|
}
|
|
12426
12143
|
catch (error) {
|
|
12427
12144
|
// Rollback on failure
|
|
12428
|
-
this.
|
|
12145
|
+
this.state.updateMessage(messageId, { status: message.status });
|
|
12429
12146
|
this.errorHandler.handle(error, 'markMessageAsRead', { messageId });
|
|
12430
12147
|
// Don't throw - marking as read failure shouldn't break the app
|
|
12431
12148
|
}
|
|
@@ -12435,20 +12152,20 @@ class AXConversationService {
|
|
|
12435
12152
|
* Marks messages locally and syncs with server
|
|
12436
12153
|
*/
|
|
12437
12154
|
async markAsRead(conversationId) {
|
|
12438
|
-
const messages = this.
|
|
12155
|
+
const messages = this.state.getConversationMessages(conversationId);
|
|
12439
12156
|
const currentId = this._currentUser()?.id ?? 'current-user';
|
|
12440
12157
|
const unreadMessageIds = messages.filter((m) => m.status !== 'read' && m.senderId !== currentId).map((m) => m.id);
|
|
12441
12158
|
if (unreadMessageIds.length === 0)
|
|
12442
12159
|
return;
|
|
12443
12160
|
try {
|
|
12444
12161
|
// Optimistic update
|
|
12445
|
-
this.
|
|
12162
|
+
this.state.resetUnreadCount(conversationId);
|
|
12446
12163
|
// Sync with server
|
|
12447
12164
|
await this.messageApi.markAsRead(conversationId, unreadMessageIds);
|
|
12448
12165
|
}
|
|
12449
12166
|
catch (error) {
|
|
12450
12167
|
// Rollback on failure
|
|
12451
|
-
this.
|
|
12168
|
+
this.state.updateConversation(conversationId, {
|
|
12452
12169
|
unreadCount: unreadMessageIds.length,
|
|
12453
12170
|
});
|
|
12454
12171
|
this.errorHandler.handle(error, 'markAsRead', { conversationId });
|
|
@@ -12485,21 +12202,21 @@ class AXConversationService {
|
|
|
12485
12202
|
* Handle new message received
|
|
12486
12203
|
*/
|
|
12487
12204
|
handleNewMessage(message) {
|
|
12488
|
-
this.
|
|
12489
|
-
this.
|
|
12205
|
+
this.state.addMessage(message);
|
|
12206
|
+
this.state.updateLastMessage(message);
|
|
12490
12207
|
// Increment unread count for messages from other users
|
|
12491
12208
|
// The intersection observer will mark them as read when they become visible
|
|
12492
12209
|
const currentId = this._currentUser()?.id ?? 'current-user';
|
|
12493
12210
|
const isFromOtherUser = message.senderId !== currentId;
|
|
12494
12211
|
if (isFromOtherUser) {
|
|
12495
|
-
this.
|
|
12212
|
+
this.state.incrementUnreadCount(message.conversationId);
|
|
12496
12213
|
}
|
|
12497
12214
|
}
|
|
12498
12215
|
/**
|
|
12499
12216
|
* Handle message update
|
|
12500
12217
|
*/
|
|
12501
12218
|
handleMessageUpdate(message) {
|
|
12502
|
-
this.
|
|
12219
|
+
this.state.updateMessage(message.id, message);
|
|
12503
12220
|
}
|
|
12504
12221
|
/**
|
|
12505
12222
|
* Handle message count update
|
|
@@ -12519,7 +12236,7 @@ class AXConversationService {
|
|
|
12519
12236
|
* Handle message deletion
|
|
12520
12237
|
*/
|
|
12521
12238
|
handleMessageDeletion(messageId) {
|
|
12522
|
-
this.
|
|
12239
|
+
this.state.deleteMessage(messageId);
|
|
12523
12240
|
}
|
|
12524
12241
|
/**
|
|
12525
12242
|
* Handle typing indicator
|
|
@@ -12530,30 +12247,30 @@ class AXConversationService {
|
|
|
12530
12247
|
if (currentUser && indicator.userId === currentUser.id) {
|
|
12531
12248
|
return;
|
|
12532
12249
|
}
|
|
12533
|
-
this.
|
|
12250
|
+
this.state.updateTypingIndicator(indicator.conversationId, indicator.userId, true);
|
|
12534
12251
|
// Clear typing indicator after timeout
|
|
12535
12252
|
setTimeout(() => {
|
|
12536
|
-
this.
|
|
12253
|
+
this.state.updateTypingIndicator(indicator.conversationId, indicator.userId, false);
|
|
12537
12254
|
}, this.config.typingIndicatorTimeout ?? 3000);
|
|
12538
12255
|
}
|
|
12539
12256
|
/**
|
|
12540
12257
|
* Handle presence update
|
|
12541
12258
|
*/
|
|
12542
12259
|
handlePresenceUpdate(update) {
|
|
12543
|
-
this.
|
|
12260
|
+
this.state.updateParticipantPresence(update.userId, update.status, update.lastSeen);
|
|
12544
12261
|
}
|
|
12545
12262
|
/**
|
|
12546
12263
|
* Handle conversation update
|
|
12547
12264
|
* Updates conversation metadata including unread count, last message, etc.
|
|
12548
12265
|
*/
|
|
12549
12266
|
handleConversationUpdate(conversation) {
|
|
12550
|
-
this.
|
|
12267
|
+
this.state.setConversation(conversation);
|
|
12551
12268
|
}
|
|
12552
12269
|
/**
|
|
12553
12270
|
* Update conversation's last message
|
|
12554
12271
|
*/
|
|
12555
12272
|
updateConversationLastMessage(message) {
|
|
12556
|
-
this.
|
|
12273
|
+
this.state.updateLastMessage(message);
|
|
12557
12274
|
}
|
|
12558
12275
|
// =====================
|
|
12559
12276
|
// Conversation Update APIs
|
|
@@ -12565,7 +12282,7 @@ class AXConversationService {
|
|
|
12565
12282
|
*/
|
|
12566
12283
|
async updateConversation(conversationId, updates) {
|
|
12567
12284
|
try {
|
|
12568
|
-
this.
|
|
12285
|
+
this.state.updateConversation(conversationId, updates);
|
|
12569
12286
|
}
|
|
12570
12287
|
catch (error) {
|
|
12571
12288
|
this.errorHandler.handle(error, 'updateConversation', { conversationId });
|
|
@@ -12579,7 +12296,7 @@ class AXConversationService {
|
|
|
12579
12296
|
*/
|
|
12580
12297
|
async updateConversationSettings(conversationId, settings) {
|
|
12581
12298
|
try {
|
|
12582
|
-
this.
|
|
12299
|
+
this.state.updateSettings(conversationId, settings);
|
|
12583
12300
|
}
|
|
12584
12301
|
catch (error) {
|
|
12585
12302
|
this.errorHandler.handle(error, 'updateConversationSettings', { conversationId });
|
|
@@ -12593,7 +12310,7 @@ class AXConversationService {
|
|
|
12593
12310
|
*/
|
|
12594
12311
|
async updateConversationTitle(conversationId, title) {
|
|
12595
12312
|
try {
|
|
12596
|
-
this.
|
|
12313
|
+
this.state.updateTitle(conversationId, title);
|
|
12597
12314
|
}
|
|
12598
12315
|
catch (error) {
|
|
12599
12316
|
this.errorHandler.handle(error, 'updateConversationTitle', { conversationId, title });
|
|
@@ -12607,7 +12324,7 @@ class AXConversationService {
|
|
|
12607
12324
|
*/
|
|
12608
12325
|
async updateConversationMetadata(conversationId, metadata) {
|
|
12609
12326
|
try {
|
|
12610
|
-
this.
|
|
12327
|
+
this.state.updateMetadata(conversationId, metadata);
|
|
12611
12328
|
}
|
|
12612
12329
|
catch (error) {
|
|
12613
12330
|
this.errorHandler.handle(error, 'updateConversationMetadata', { conversationId });
|
|
@@ -12620,7 +12337,7 @@ class AXConversationService {
|
|
|
12620
12337
|
* @returns Conversation or null
|
|
12621
12338
|
*/
|
|
12622
12339
|
getConversation(conversationId) {
|
|
12623
|
-
return this.
|
|
12340
|
+
return this.state.getConversation(conversationId) || null;
|
|
12624
12341
|
}
|
|
12625
12342
|
/**
|
|
12626
12343
|
* Create a new conversation
|
|
@@ -12640,7 +12357,7 @@ class AXConversationService {
|
|
|
12640
12357
|
icon: AXConversationService.normalizeOptionalString(metadata?.['icon']),
|
|
12641
12358
|
metadata,
|
|
12642
12359
|
});
|
|
12643
|
-
this.
|
|
12360
|
+
this.state.setConversation(conversation);
|
|
12644
12361
|
return conversation;
|
|
12645
12362
|
}
|
|
12646
12363
|
catch (error) {
|
|
@@ -12669,7 +12386,7 @@ class AXConversationService {
|
|
|
12669
12386
|
async markConversationAsRead(conversationId) {
|
|
12670
12387
|
try {
|
|
12671
12388
|
await this.conversationApi.markConversationAsRead(conversationId);
|
|
12672
|
-
this.
|
|
12389
|
+
this.state.resetUnreadCount(conversationId);
|
|
12673
12390
|
}
|
|
12674
12391
|
catch (error) {
|
|
12675
12392
|
this.errorHandler.handle(error, 'markConversationAsRead', { conversationId });
|
|
@@ -12683,7 +12400,7 @@ class AXConversationService {
|
|
|
12683
12400
|
*/
|
|
12684
12401
|
async deleteConversation(conversationId) {
|
|
12685
12402
|
// Get conversation title for confirmation message
|
|
12686
|
-
const conversation = this.
|
|
12403
|
+
const conversation = this.state.getConversation(conversationId);
|
|
12687
12404
|
const conversationTitle = conversation?.title || this.translation.translateSync('@acorex:chat.fallbacks.this-conversation');
|
|
12688
12405
|
// Show confirmation dialog
|
|
12689
12406
|
const result = await this.dialogService.confirm(this.translation.translateSync('@acorex:chat.dialog.delete-conversation.title'), this.translation.translateSync('@acorex:chat.dialog.delete-conversation.message', { params: { conversationTitle } }), 'danger', 'vertical', false);
|
|
@@ -12697,7 +12414,7 @@ class AXConversationService {
|
|
|
12697
12414
|
if (!result)
|
|
12698
12415
|
return false;
|
|
12699
12416
|
// Delete from store
|
|
12700
|
-
this.
|
|
12417
|
+
this.state.deleteConversation(conversationId);
|
|
12701
12418
|
// If this was the active conversation, clear selection
|
|
12702
12419
|
if (this._activeConversationId() === conversationId) {
|
|
12703
12420
|
this._activeConversationId.set(null);
|
|
@@ -12762,7 +12479,7 @@ class AXConversationService {
|
|
|
12762
12479
|
async archiveConversation(conversationId) {
|
|
12763
12480
|
try {
|
|
12764
12481
|
await this.conversationApi.archiveConversation(conversationId);
|
|
12765
|
-
this.
|
|
12482
|
+
this.state.updateConversation(conversationId, { archived: true });
|
|
12766
12483
|
}
|
|
12767
12484
|
catch (error) {
|
|
12768
12485
|
this.errorHandler.handle(error, 'archiveConversation', { conversationId });
|
|
@@ -12776,7 +12493,7 @@ class AXConversationService {
|
|
|
12776
12493
|
async unarchiveConversation(conversationId) {
|
|
12777
12494
|
try {
|
|
12778
12495
|
await this.conversationApi.unarchiveConversation(conversationId);
|
|
12779
|
-
this.
|
|
12496
|
+
this.state.updateConversation(conversationId, { archived: false });
|
|
12780
12497
|
}
|
|
12781
12498
|
catch (error) {
|
|
12782
12499
|
this.errorHandler.handle(error, 'unarchiveConversation', { conversationId });
|
|
@@ -12790,7 +12507,7 @@ class AXConversationService {
|
|
|
12790
12507
|
async pinConversation(conversationId) {
|
|
12791
12508
|
try {
|
|
12792
12509
|
await this.conversationApi.pinConversation(conversationId);
|
|
12793
|
-
this.
|
|
12510
|
+
this.state.updateConversation(conversationId, { pinned: true });
|
|
12794
12511
|
}
|
|
12795
12512
|
catch (error) {
|
|
12796
12513
|
this.errorHandler.handle(error, 'pinConversation', { conversationId });
|
|
@@ -12804,7 +12521,7 @@ class AXConversationService {
|
|
|
12804
12521
|
async unpinConversation(conversationId) {
|
|
12805
12522
|
try {
|
|
12806
12523
|
await this.conversationApi.unpinConversation(conversationId);
|
|
12807
|
-
this.
|
|
12524
|
+
this.state.updateConversation(conversationId, { pinned: false });
|
|
12808
12525
|
}
|
|
12809
12526
|
catch (error) {
|
|
12810
12527
|
this.errorHandler.handle(error, 'unpinConversation', { conversationId });
|
|
@@ -12820,7 +12537,7 @@ class AXConversationService {
|
|
|
12820
12537
|
try {
|
|
12821
12538
|
await this.conversationApi.muteConversation(conversationId, duration);
|
|
12822
12539
|
const mutedUntil = duration ? new Date(Date.now() + duration) : undefined;
|
|
12823
|
-
this.
|
|
12540
|
+
this.state.updateSettings(conversationId, { mutedUntil, notifications: false });
|
|
12824
12541
|
}
|
|
12825
12542
|
catch (error) {
|
|
12826
12543
|
this.errorHandler.handle(error, 'muteConversation', { conversationId, duration });
|
|
@@ -12834,7 +12551,7 @@ class AXConversationService {
|
|
|
12834
12551
|
async unmuteConversation(conversationId) {
|
|
12835
12552
|
try {
|
|
12836
12553
|
await this.conversationApi.unmuteConversation(conversationId);
|
|
12837
|
-
this.
|
|
12554
|
+
this.state.updateSettings(conversationId, { mutedUntil: undefined, notifications: true });
|
|
12838
12555
|
}
|
|
12839
12556
|
catch (error) {
|
|
12840
12557
|
this.errorHandler.handle(error, 'unmuteConversation', { conversationId });
|
|
@@ -12849,7 +12566,7 @@ class AXConversationService {
|
|
|
12849
12566
|
async addParticipants(conversationId, userIds) {
|
|
12850
12567
|
try {
|
|
12851
12568
|
const updatedConversation = await this.conversationApi.addParticipants(conversationId, userIds);
|
|
12852
|
-
this.
|
|
12569
|
+
this.state.setConversation(updatedConversation);
|
|
12853
12570
|
}
|
|
12854
12571
|
catch (error) {
|
|
12855
12572
|
this.errorHandler.handle(error, 'addParticipants', { conversationId, userIds });
|
|
@@ -12864,7 +12581,7 @@ class AXConversationService {
|
|
|
12864
12581
|
async removeParticipant(conversationId, userId) {
|
|
12865
12582
|
try {
|
|
12866
12583
|
const updatedConversation = await this.conversationApi.removeParticipant(conversationId, userId);
|
|
12867
|
-
this.
|
|
12584
|
+
this.state.setConversation(updatedConversation);
|
|
12868
12585
|
}
|
|
12869
12586
|
catch (error) {
|
|
12870
12587
|
this.errorHandler.handle(error, 'removeParticipant', { conversationId, userId });
|
|
@@ -12878,7 +12595,7 @@ class AXConversationService {
|
|
|
12878
12595
|
async leaveConversation(conversationId) {
|
|
12879
12596
|
try {
|
|
12880
12597
|
await this.conversationApi.leaveConversation(conversationId);
|
|
12881
|
-
this.
|
|
12598
|
+
this.state.deleteConversation(conversationId);
|
|
12882
12599
|
if (this._activeConversationId() === conversationId) {
|
|
12883
12600
|
this._activeConversationId.set(null);
|
|
12884
12601
|
}
|
|
@@ -12896,7 +12613,7 @@ class AXConversationService {
|
|
|
12896
12613
|
async saveDraft(conversationId, draft) {
|
|
12897
12614
|
try {
|
|
12898
12615
|
await this.conversationApi.saveDraft(conversationId, draft);
|
|
12899
|
-
this.
|
|
12616
|
+
this.state.updateConversation(conversationId, { draft });
|
|
12900
12617
|
}
|
|
12901
12618
|
catch (error) {
|
|
12902
12619
|
this.errorHandler.handle(error, 'saveDraft', { conversationId });
|
|
@@ -12910,7 +12627,7 @@ class AXConversationService {
|
|
|
12910
12627
|
async clearDraft(conversationId) {
|
|
12911
12628
|
try {
|
|
12912
12629
|
await this.conversationApi.clearDraft(conversationId);
|
|
12913
|
-
this.
|
|
12630
|
+
this.state.updateConversation(conversationId, { draft: undefined });
|
|
12914
12631
|
}
|
|
12915
12632
|
catch (error) {
|
|
12916
12633
|
this.errorHandler.handle(error, 'clearDraft', { conversationId });
|
|
@@ -12928,7 +12645,7 @@ class AXConversationService {
|
|
|
12928
12645
|
async pinMessage(conversationId, messageId) {
|
|
12929
12646
|
try {
|
|
12930
12647
|
await this.messageApi.pinMessage(conversationId, messageId);
|
|
12931
|
-
this.
|
|
12648
|
+
this.state.updateMessage(messageId, { pinned: true });
|
|
12932
12649
|
}
|
|
12933
12650
|
catch (error) {
|
|
12934
12651
|
this.errorHandler.handle(error, 'pinMessage', { conversationId, messageId });
|
|
@@ -12943,7 +12660,7 @@ class AXConversationService {
|
|
|
12943
12660
|
async unpinMessage(conversationId, messageId) {
|
|
12944
12661
|
try {
|
|
12945
12662
|
await this.messageApi.unpinMessage(conversationId, messageId);
|
|
12946
|
-
this.
|
|
12663
|
+
this.state.updateMessage(messageId, { pinned: false });
|
|
12947
12664
|
}
|
|
12948
12665
|
catch (error) {
|
|
12949
12666
|
this.errorHandler.handle(error, 'unpinMessage', { conversationId, messageId });
|
|
@@ -18600,7 +18317,7 @@ function createProviders(options, includeServices) {
|
|
|
18600
18317
|
if (realtimeApi) {
|
|
18601
18318
|
providers.push({ provide: AXRealtimeApi, useClass: realtimeApi });
|
|
18602
18319
|
}
|
|
18603
|
-
providers.push(
|
|
18320
|
+
providers.push(AXConversationService, AXComposerService, AXInfoBarService, AXMessageListService, AXSidebarService);
|
|
18604
18321
|
}
|
|
18605
18322
|
if (config) {
|
|
18606
18323
|
providers.push({ provide: CONVERSATION_CONFIG, useValue: mergeWithDefaults(config) });
|
|
@@ -19022,5 +18739,5 @@ function getErrorMessage(code, params) {
|
|
|
19022
18739
|
* Generated bundle index. Do not edit.
|
|
19023
18740
|
*/
|
|
19024
18741
|
|
|
19025
|
-
export { AXAudioInfoBarBannerComponent, AXAudioPickerComponent, AXAudioRendererComponent, AXBaseRegistry, AXComposerActionRegistry, AXComposerComponent, AXComposerPopupComponent, AXComposerService, AXComposerTabRegistry, AXConversation2Module, AXConversationAiApiKey, AXConversationAiResponderService, AXConversationApi, AXConversationContainerComponent, AXConversationContainerDirective, AXConversationDateUtilsService, AXConversationIndexedDbConversationApi, AXConversationIndexedDbMessageAiApi, AXConversationIndexedDbMessageApi, AXConversationIndexedDbRealtimeApi, AXConversationIndexedDbStorage, AXConversationIndexedDbStores, AXConversationIndexedDbUserApi, AXConversationInfoPanelComponent, AXConversationItemActionRegistry, AXConversationMessageRendererStateComponent, AXConversationMessageUtilsService, AXConversationService, AXConversationSharedStorage,
|
|
18742
|
+
export { AXAudioInfoBarBannerComponent, AXAudioPickerComponent, AXAudioRendererComponent, AXBaseRegistry, AXComposerActionRegistry, AXComposerComponent, AXComposerPopupComponent, AXComposerService, AXComposerTabRegistry, AXConversation2Module, AXConversationAiApiKey, AXConversationAiResponderService, AXConversationApi, AXConversationContainerComponent, AXConversationContainerDirective, AXConversationDateUtilsService, AXConversationIndexedDbConversationApi, AXConversationIndexedDbMessageAiApi, AXConversationIndexedDbMessageApi, AXConversationIndexedDbRealtimeApi, AXConversationIndexedDbStorage, AXConversationIndexedDbStores, AXConversationIndexedDbUserApi, AXConversationInfoPanelComponent, AXConversationItemActionRegistry, AXConversationMessageRendererStateComponent, AXConversationMessageUtilsService, AXConversationService, AXConversationSharedStorage, AXConversationTabRegistry, AXEmojiTabComponent, AXErrorHandlerService, AXFallbackRendererComponent, AXFilePickerComponent, AXFileRendererComponent, AXFileUploadService, AXForwardMessageDialogComponent, AXImagePickerComponent, AXImageRendererComponent, AXInfiniteScrollDirective, AXInfoBarActionRegistry, AXInfoBarComponent, AXInfoBarSearchComponent, AXInfoBarService, AXLocationPickerComponent, AXLocationRendererComponent, AXMessageActionRegistry, AXMessageApi, AXMessageListComponent, AXMessageListNoActiveDefaultComponent, AXMessageListService, AXMessageRendererRegistry, AXNewConversationDialogComponent, AXPickerFooterComponent, AXPickerHeaderComponent, AXRealtimeApi, AXRegistryService, AXSidebarComponent, AXSidebarService, AXStickerRendererComponent, AXStickerTabComponent, AXSystemRendererComponent, AXTextRendererComponent, AXUserApi, AXVideoInfoBarBannerComponent, AXVideoPickerComponent, AXVideoRendererComponent, AXVoiceInfoBarBannerComponent, AXVoiceRecorderComponent, AXVoiceRendererComponent, AX_CONVERSATION_AUDIO_RENDERER, AX_CONVERSATION_COMPOSER_AUDIO_ACTION, AX_CONVERSATION_COMPOSER_EMOJI_ACTION, AX_CONVERSATION_COMPOSER_EMOJI_TAB, AX_CONVERSATION_COMPOSER_FILE_ACTION, AX_CONVERSATION_COMPOSER_IMAGE_ACTION, AX_CONVERSATION_COMPOSER_LOCATION_ACTION, AX_CONVERSATION_COMPOSER_STICKER_TAB, AX_CONVERSATION_COMPOSER_VIDEO_ACTION, AX_CONVERSATION_COMPOSER_VOICE_RECORDING_ACTION, AX_CONVERSATION_FALLBACK_RENDERER, AX_CONVERSATION_FILE_RENDERER, AX_CONVERSATION_IMAGE_RENDERER, AX_CONVERSATION_INFO_BAR_ARCHIVE_ACTION, AX_CONVERSATION_INFO_BAR_BLOCK_ACTION, AX_CONVERSATION_INFO_BAR_DELETE_ACTION, AX_CONVERSATION_INFO_BAR_DIVIDER, AX_CONVERSATION_INFO_BAR_INFO_ACTION, AX_CONVERSATION_INFO_BAR_MUTE_ACTION, AX_CONVERSATION_INFO_BAR_SEARCH_ACTION, AX_CONVERSATION_ITEM_BLOCK_ACTION, AX_CONVERSATION_ITEM_DELETE_ACTION, AX_CONVERSATION_ITEM_DIVIDER, AX_CONVERSATION_ITEM_MARK_READ_ACTION, AX_CONVERSATION_ITEM_MUTE_ACTION, AX_CONVERSATION_ITEM_PIN_ACTION, AX_CONVERSATION_LOCATION_RENDERER, AX_CONVERSATION_MESSAGE_COPY_ACTION, AX_CONVERSATION_MESSAGE_DELETE_ACTION, AX_CONVERSATION_MESSAGE_EDIT_ACTION, AX_CONVERSATION_MESSAGE_FORWARD_ACTION, AX_CONVERSATION_MESSAGE_REPLY_ACTION, AX_CONVERSATION_STICKER_RENDERER, AX_CONVERSATION_SYSTEM_RENDERER, AX_CONVERSATION_TAB_ALL, AX_CONVERSATION_TAB_ARCHIVED, AX_CONVERSATION_TAB_BOT, AX_CONVERSATION_TAB_CHANNELS, AX_CONVERSATION_TAB_GROUPS, AX_CONVERSATION_TAB_PRIVATE, AX_CONVERSATION_TAB_UNREAD, AX_CONVERSATION_TEXT_RENDERER, AX_CONVERSATION_VIDEO_RENDERER, AX_CONVERSATION_VOICE_RENDERER, AX_DEFAULT_CONVERSATION_CONFIG, AX_STICKER_API_KEY, CONNECTION_ERRORS, CONVERSATION_CONFIG, CONVERSATION_ERRORS, DEFAULT_COMPOSER_ACTIONS, DEFAULT_COMPOSER_TABS, DEFAULT_CONVERSATION_ITEM_ACTIONS, DEFAULT_CONVERSATION_TABS, DEFAULT_INFO_BAR_ACTIONS, DEFAULT_MESSAGE_ACTIONS, DEFAULT_MESSAGE_RENDERERS, ERROR_HANDLER_CONFIG, ERROR_MESSAGES, FILE_ERRORS, LOCATION_ERRORS, MESSAGE_ERRORS, PERMISSION_ERRORS, REGISTRY_CONFIG, URL_ERRORS, USER_ERRORS, axConversationIndexedDbStorage, conversationSharedStorage, formatErrorMessage, getDefaultConversationItemActions, getErrorMessage, mergeWithDefaults, provideConversation, sanitizeInput, validateConversationId, validateEmail, validateFile, validateLatitude, validateLongitude, validateMessagePayload, validateMessageText, validateMessageType, validateUrl, validateUserId, validateUserIds };
|
|
19026
18743
|
//# sourceMappingURL=acorex-components-conversation2.mjs.map
|