@acorex/components 21.0.1-next.85 → 21.0.1-next.87
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/fesm2022/acorex-components-conversation2.mjs +876 -1159
- package/fesm2022/acorex-components-conversation2.mjs.map +1 -1
- package/fesm2022/acorex-components-password-box.mjs +6 -3
- package/fesm2022/acorex-components-password-box.mjs.map +1 -1
- package/package.json +3 -3
- package/types/acorex-components-conversation2.d.ts +17 -236
- package/types/acorex-components-password-box.d.ts +2 -1
|
@@ -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,871 +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: "21.1.3", ngImport: i0, type: AXErrorHandlerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
683
|
-
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: AXErrorHandlerService, providedIn: 'root' }); }
|
|
684
|
-
}
|
|
685
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.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
|
-
/** All messages as array */
|
|
728
|
-
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]) });
|
|
729
|
-
}
|
|
730
|
-
// =====================
|
|
731
|
-
// Initialization
|
|
732
|
-
// =====================
|
|
733
|
-
/**
|
|
734
|
-
* Initialize the store
|
|
735
|
-
*/
|
|
736
|
-
async initialize() {
|
|
737
|
-
// Store is initialized with empty state
|
|
738
|
-
// Data will be loaded from API
|
|
739
|
-
}
|
|
740
|
-
// =====================
|
|
741
|
-
// Conversation Operations
|
|
742
|
-
// =====================
|
|
743
|
-
/**
|
|
744
|
-
* Set all conversations (replaces existing)
|
|
745
|
-
* @param conversations - Array of conversations
|
|
746
|
-
*/
|
|
747
|
-
setConversations(conversations) {
|
|
748
|
-
const convMap = new Map();
|
|
749
|
-
conversations.forEach((conv) => convMap.set(conv.id, conv));
|
|
750
|
-
this._conversations.set(convMap);
|
|
751
|
-
}
|
|
752
|
-
/**
|
|
753
|
-
* Add multiple conversations (append to existing)
|
|
754
|
-
* Used for pagination - appends new conversations without replacing existing ones
|
|
755
|
-
* @param conversations - Array of conversations to add
|
|
756
|
-
*/
|
|
757
|
-
addConversations(conversations) {
|
|
758
|
-
this._conversations.update((existingConversations) => {
|
|
759
|
-
const newConversations = new Map(existingConversations);
|
|
760
|
-
conversations.forEach((conv) => newConversations.set(conv.id, conv));
|
|
761
|
-
// Cleanup old conversations if cache is too large
|
|
762
|
-
const maxCached = this.config.maxCachedConversations;
|
|
763
|
-
if (newConversations.size > maxCached) {
|
|
764
|
-
// Sort by last activity and keep only the most recent
|
|
765
|
-
const sorted = Array.from(newConversations.values()).sort((a, b) => (b.lastMessageAt?.getTime() ?? 0) - (a.lastMessageAt?.getTime() ?? 0));
|
|
766
|
-
// Keep only the most recent conversations
|
|
767
|
-
const toKeep = sorted.slice(0, maxCached);
|
|
768
|
-
const cleanedMap = new Map();
|
|
769
|
-
toKeep.forEach((conv) => cleanedMap.set(conv.id, conv));
|
|
770
|
-
return cleanedMap;
|
|
771
|
-
}
|
|
772
|
-
return newConversations;
|
|
773
|
-
});
|
|
774
|
-
}
|
|
775
|
-
/**
|
|
776
|
-
* Add or update a single conversation
|
|
777
|
-
* @param conversation - Conversation to add/update
|
|
778
|
-
*/
|
|
779
|
-
setConversation(conversation) {
|
|
780
|
-
this._conversations.update((conversations) => {
|
|
781
|
-
const newConversations = new Map(conversations);
|
|
782
|
-
newConversations.set(conversation.id, conversation);
|
|
783
|
-
return newConversations;
|
|
784
|
-
});
|
|
785
|
-
}
|
|
786
|
-
/**
|
|
787
|
-
* Get a conversation by ID
|
|
788
|
-
* @param conversationId - Conversation ID
|
|
789
|
-
* @returns Conversation or undefined
|
|
790
|
-
*/
|
|
791
|
-
getConversation(conversationId) {
|
|
792
|
-
return this._conversations().get(conversationId);
|
|
793
|
-
}
|
|
794
|
-
/**
|
|
795
|
-
* Update a conversation with partial data
|
|
796
|
-
* @param conversationId - Conversation ID
|
|
797
|
-
* @param updates - Partial conversation updates
|
|
798
|
-
*/
|
|
799
|
-
updateConversation(conversationId, updates) {
|
|
800
|
-
const conversation = this._conversations().get(conversationId);
|
|
801
|
-
if (!conversation)
|
|
802
|
-
return;
|
|
803
|
-
const updatedConversation = { ...conversation, ...updates };
|
|
804
|
-
this.setConversation(updatedConversation);
|
|
805
|
-
}
|
|
806
|
-
/**
|
|
807
|
-
* Delete a conversation
|
|
808
|
-
* @param conversationId - Conversation ID
|
|
809
|
-
*/
|
|
810
|
-
deleteConversation(conversationId) {
|
|
811
|
-
// Remove conversation
|
|
812
|
-
this._conversations.update((conversations) => {
|
|
813
|
-
const newConversations = new Map(conversations);
|
|
814
|
-
newConversations.delete(conversationId);
|
|
815
|
-
return newConversations;
|
|
816
|
-
});
|
|
817
|
-
// Remove all messages for this conversation
|
|
818
|
-
const messageIds = this._conversationMessages().get(conversationId) || [];
|
|
819
|
-
this._messages.update((messages) => {
|
|
820
|
-
const newMessages = new Map(messages);
|
|
821
|
-
messageIds.forEach((id) => newMessages.delete(id));
|
|
822
|
-
return newMessages;
|
|
823
|
-
});
|
|
824
|
-
this._conversationMessages.update((map) => {
|
|
825
|
-
const newMap = new Map(map);
|
|
826
|
-
newMap.delete(conversationId);
|
|
827
|
-
return newMap;
|
|
828
|
-
});
|
|
829
|
-
}
|
|
830
|
-
// =====================
|
|
831
|
-
// Conversation Updates
|
|
832
|
-
// =====================
|
|
833
|
-
/**
|
|
834
|
-
* Update conversation's last message
|
|
835
|
-
* @param message - The message to set as last message
|
|
836
|
-
*/
|
|
837
|
-
updateLastMessage(message) {
|
|
838
|
-
this.updateConversation(message.conversationId, {
|
|
839
|
-
lastMessage: message,
|
|
840
|
-
lastMessageAt: message.timestamp,
|
|
841
|
-
updatedAt: message.timestamp,
|
|
842
|
-
});
|
|
843
|
-
}
|
|
844
|
-
/**
|
|
845
|
-
* Increment unread count
|
|
846
|
-
* @param conversationId - Conversation ID
|
|
847
|
-
*/
|
|
848
|
-
incrementUnreadCount(conversationId) {
|
|
849
|
-
const conversation = this._conversations().get(conversationId);
|
|
850
|
-
if (!conversation)
|
|
851
|
-
return;
|
|
852
|
-
this.updateConversation(conversationId, { unreadCount: conversation.unreadCount + 1 });
|
|
853
|
-
}
|
|
854
|
-
/**
|
|
855
|
-
* Reset unread count to zero
|
|
856
|
-
* @param conversationId - Conversation ID
|
|
857
|
-
*/
|
|
858
|
-
resetUnreadCount(conversationId) {
|
|
859
|
-
this.updateConversation(conversationId, { unreadCount: 0 });
|
|
860
|
-
}
|
|
861
|
-
/**
|
|
862
|
-
* Update conversation settings
|
|
863
|
-
* @param conversationId - Conversation ID
|
|
864
|
-
* @param settings - Settings to merge
|
|
865
|
-
*/
|
|
866
|
-
updateSettings(conversationId, settings) {
|
|
867
|
-
const conversation = this._conversations().get(conversationId);
|
|
868
|
-
if (!conversation)
|
|
869
|
-
return;
|
|
870
|
-
this.updateConversation(conversationId, {
|
|
871
|
-
settings: { ...conversation.settings, ...settings },
|
|
872
|
-
updatedAt: new Date(),
|
|
873
|
-
});
|
|
874
|
-
}
|
|
875
|
-
/**
|
|
876
|
-
* Update conversation title
|
|
877
|
-
* @param conversationId - Conversation ID
|
|
878
|
-
* @param title - New title
|
|
879
|
-
*/
|
|
880
|
-
updateTitle(conversationId, title) {
|
|
881
|
-
this.updateConversation(conversationId, { title, updatedAt: new Date() });
|
|
882
|
-
}
|
|
883
|
-
/**
|
|
884
|
-
* Update conversation metadata
|
|
885
|
-
* @param conversationId - Conversation ID
|
|
886
|
-
* @param metadata - Metadata to merge
|
|
887
|
-
*/
|
|
888
|
-
updateMetadata(conversationId, metadata) {
|
|
889
|
-
const conversation = this._conversations().get(conversationId);
|
|
890
|
-
if (!conversation)
|
|
891
|
-
return;
|
|
892
|
-
this.updateConversation(conversationId, {
|
|
893
|
-
metadata: { ...conversation.metadata, ...metadata },
|
|
894
|
-
updatedAt: new Date(),
|
|
895
|
-
});
|
|
896
|
-
}
|
|
897
|
-
/**
|
|
898
|
-
* Update typing indicator
|
|
899
|
-
* @param conversationId - Conversation ID
|
|
900
|
-
* @param userId - User ID
|
|
901
|
-
* @param isTyping - Whether user is typing
|
|
902
|
-
*/
|
|
903
|
-
updateTypingIndicator(conversationId, userId, isTyping) {
|
|
904
|
-
const conversation = this._conversations().get(conversationId);
|
|
905
|
-
if (!conversation)
|
|
906
|
-
return;
|
|
907
|
-
let typingUsers = [...conversation.status.typingUsers];
|
|
908
|
-
if (isTyping) {
|
|
909
|
-
if (!typingUsers.includes(userId)) {
|
|
910
|
-
typingUsers.push(userId);
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
else {
|
|
914
|
-
typingUsers = typingUsers.filter((id) => id !== userId);
|
|
915
|
-
}
|
|
916
|
-
this.updateConversation(conversationId, {
|
|
917
|
-
status: {
|
|
918
|
-
...conversation.status,
|
|
919
|
-
isTyping: typingUsers.length > 0,
|
|
920
|
-
typingUsers,
|
|
921
|
-
},
|
|
922
|
-
});
|
|
923
|
-
}
|
|
924
|
-
/**
|
|
925
|
-
* Update participant presence across all conversations
|
|
926
|
-
* @param userId - User ID
|
|
927
|
-
* @param status - Presence status
|
|
928
|
-
* @param lastSeen - Last seen date
|
|
929
|
-
*/
|
|
930
|
-
updateParticipantPresence(userId, status, lastSeen) {
|
|
931
|
-
this._conversations.update((conversations) => {
|
|
932
|
-
const newConversations = new Map(conversations);
|
|
933
|
-
for (const [id, conv] of conversations) {
|
|
934
|
-
const participant = conv.participants.find((p) => p.id === userId);
|
|
935
|
-
if (participant) {
|
|
936
|
-
const updatedConversation = {
|
|
937
|
-
...conv,
|
|
938
|
-
participants: conv.participants.map((p) => (p.id === userId ? { ...p, status, lastSeen } : p)),
|
|
939
|
-
status: conv.type === 'private' ? { ...conv.status, presence: status, lastSeen } : conv.status,
|
|
940
|
-
};
|
|
941
|
-
newConversations.set(id, updatedConversation);
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
return newConversations;
|
|
945
|
-
});
|
|
946
|
-
}
|
|
947
|
-
// =====================
|
|
948
|
-
// Message Operations
|
|
949
|
-
// =====================
|
|
950
|
-
/**
|
|
951
|
-
* Add a message to the store
|
|
952
|
-
* @param message - Message to add
|
|
953
|
-
*/
|
|
954
|
-
addMessage(message) {
|
|
955
|
-
// Add to messages map
|
|
956
|
-
this._messages.update((messages) => {
|
|
957
|
-
const newMessages = new Map(messages);
|
|
958
|
-
newMessages.set(message.id, message);
|
|
959
|
-
return newMessages;
|
|
960
|
-
});
|
|
961
|
-
// Update conversation messages list (sorted) — always produce new arrays
|
|
962
|
-
this._conversationMessages.update((map) => {
|
|
963
|
-
const newMap = new Map(map);
|
|
964
|
-
const existing = newMap.get(message.conversationId) || [];
|
|
965
|
-
if (existing.includes(message.id)) {
|
|
966
|
-
return newMap;
|
|
967
|
-
}
|
|
968
|
-
const updated = [...existing, message.id].sort((a, b) => {
|
|
969
|
-
const msgA = this._messages().get(a);
|
|
970
|
-
const msgB = this._messages().get(b);
|
|
971
|
-
if (!msgA || !msgB)
|
|
972
|
-
return 0;
|
|
973
|
-
return msgA.timestamp.getTime() - msgB.timestamp.getTime();
|
|
974
|
-
});
|
|
975
|
-
newMap.set(message.conversationId, updated);
|
|
976
|
-
return newMap;
|
|
977
|
-
});
|
|
978
|
-
}
|
|
979
|
-
/**
|
|
980
|
-
* Add multiple messages
|
|
981
|
-
* @param messages - Messages to add
|
|
982
|
-
*/
|
|
983
|
-
addMessages(messages) {
|
|
984
|
-
if (messages.length === 0)
|
|
985
|
-
return;
|
|
986
|
-
// Add all messages to map
|
|
987
|
-
this._messages.update((msgs) => {
|
|
988
|
-
const newMessages = new Map(msgs);
|
|
989
|
-
messages.forEach((msg) => newMessages.set(msg.id, msg));
|
|
990
|
-
return newMessages;
|
|
991
|
-
});
|
|
992
|
-
// Update conversation messages lists
|
|
993
|
-
const conversationGroups = new Map();
|
|
994
|
-
messages.forEach((msg) => {
|
|
995
|
-
const existing = conversationGroups.get(msg.conversationId) || [];
|
|
996
|
-
existing.push(msg.id);
|
|
997
|
-
conversationGroups.set(msg.conversationId, existing);
|
|
998
|
-
});
|
|
999
|
-
this._conversationMessages.update((map) => {
|
|
1000
|
-
const newMap = new Map(map);
|
|
1001
|
-
for (const [conversationId, newMsgIds] of conversationGroups) {
|
|
1002
|
-
const existing = newMap.get(conversationId) || [];
|
|
1003
|
-
const idSet = new Set(existing);
|
|
1004
|
-
const merged = [...existing];
|
|
1005
|
-
for (const id of newMsgIds) {
|
|
1006
|
-
if (!idSet.has(id)) {
|
|
1007
|
-
merged.push(id);
|
|
1008
|
-
idSet.add(id);
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
const sorted = merged.sort((a, b) => {
|
|
1012
|
-
const msgA = this._messages().get(a);
|
|
1013
|
-
const msgB = this._messages().get(b);
|
|
1014
|
-
if (!msgA || !msgB)
|
|
1015
|
-
return 0;
|
|
1016
|
-
return msgA.timestamp.getTime() - msgB.timestamp.getTime();
|
|
1017
|
-
});
|
|
1018
|
-
newMap.set(conversationId, sorted);
|
|
1019
|
-
}
|
|
1020
|
-
return newMap;
|
|
1021
|
-
});
|
|
1022
|
-
// Cleanup old messages to prevent memory leaks
|
|
1023
|
-
this.cleanupOldMessages();
|
|
1024
|
-
this.cleanupConversationMessages();
|
|
1025
|
-
}
|
|
1026
|
-
/**
|
|
1027
|
-
* Get a message by ID
|
|
1028
|
-
* @param messageId - Message ID
|
|
1029
|
-
* @returns Message or undefined
|
|
1030
|
-
*/
|
|
1031
|
-
getMessage(messageId) {
|
|
1032
|
-
return this._messages().get(messageId);
|
|
1033
|
-
}
|
|
1034
|
-
/**
|
|
1035
|
-
* Get messages for a conversation
|
|
1036
|
-
* @param conversationId - Conversation ID
|
|
1037
|
-
* @returns Array of messages sorted by timestamp
|
|
1038
|
-
*/
|
|
1039
|
-
getConversationMessages(conversationId) {
|
|
1040
|
-
const messageIds = this._conversationMessages().get(conversationId) || [];
|
|
1041
|
-
return messageIds.map((id) => this._messages().get(id)).filter((msg) => msg !== undefined);
|
|
1042
|
-
}
|
|
1043
|
-
/**
|
|
1044
|
-
* Get messages signal for a conversation
|
|
1045
|
-
* @param conversationId - Conversation ID
|
|
1046
|
-
* @returns Computed signal of messages
|
|
1047
|
-
*/
|
|
1048
|
-
getConversationMessagesSignal(conversationId) {
|
|
1049
|
-
return computed(() => this.getConversationMessages(conversationId));
|
|
1050
|
-
}
|
|
1051
|
-
/**
|
|
1052
|
-
* Update a message
|
|
1053
|
-
* @param messageId - Message ID
|
|
1054
|
-
* @param updates - Partial message updates
|
|
1055
|
-
*/
|
|
1056
|
-
updateMessage(messageId, updates) {
|
|
1057
|
-
const message = this._messages().get(messageId);
|
|
1058
|
-
if (!message)
|
|
1059
|
-
return;
|
|
1060
|
-
const updatedMessage = { ...message, ...updates };
|
|
1061
|
-
this.addMessage(updatedMessage);
|
|
1062
|
-
}
|
|
1063
|
-
/**
|
|
1064
|
-
* Delete a message
|
|
1065
|
-
* @param messageId - Message ID
|
|
1066
|
-
*/
|
|
1067
|
-
deleteMessage(messageId) {
|
|
1068
|
-
const message = this._messages().get(messageId);
|
|
1069
|
-
if (!message)
|
|
1070
|
-
return;
|
|
1071
|
-
// Remove from messages map
|
|
1072
|
-
this._messages.update((messages) => {
|
|
1073
|
-
const newMessages = new Map(messages);
|
|
1074
|
-
newMessages.delete(messageId);
|
|
1075
|
-
return newMessages;
|
|
1076
|
-
});
|
|
1077
|
-
// Remove from conversation messages list — produce a new array via filter
|
|
1078
|
-
this._conversationMessages.update((map) => {
|
|
1079
|
-
const newMap = new Map(map);
|
|
1080
|
-
const existing = newMap.get(message.conversationId);
|
|
1081
|
-
if (existing) {
|
|
1082
|
-
newMap.set(message.conversationId, existing.filter((id) => id !== messageId));
|
|
1083
|
-
}
|
|
1084
|
-
return newMap;
|
|
1085
|
-
});
|
|
1086
|
-
}
|
|
1087
|
-
/**
|
|
1088
|
-
* Clear messages for a conversation
|
|
1089
|
-
* @param conversationId - Conversation ID
|
|
1090
|
-
*/
|
|
1091
|
-
clearConversationMessages(conversationId) {
|
|
1092
|
-
const messageIds = this._conversationMessages().get(conversationId) || [];
|
|
1093
|
-
// Remove messages
|
|
1094
|
-
this._messages.update((messages) => {
|
|
1095
|
-
const newMessages = new Map(messages);
|
|
1096
|
-
messageIds.forEach((id) => newMessages.delete(id));
|
|
1097
|
-
return newMessages;
|
|
1098
|
-
});
|
|
1099
|
-
// Clear conversation messages list
|
|
1100
|
-
this._conversationMessages.update((map) => {
|
|
1101
|
-
const newMap = new Map(map);
|
|
1102
|
-
newMap.delete(conversationId);
|
|
1103
|
-
return newMap;
|
|
1104
|
-
});
|
|
1105
|
-
}
|
|
1106
|
-
/**
|
|
1107
|
-
* Clear all data
|
|
1108
|
-
*/
|
|
1109
|
-
clearAll() {
|
|
1110
|
-
this._conversations.set(new Map());
|
|
1111
|
-
this._messages.set(new Map());
|
|
1112
|
-
this._conversationMessages.set(new Map());
|
|
1113
|
-
}
|
|
1114
|
-
// =====================
|
|
1115
|
-
// Memory Management
|
|
1116
|
-
// =====================
|
|
1117
|
-
/**
|
|
1118
|
-
* Cleanup old messages to prevent unbounded memory growth
|
|
1119
|
-
* Keeps only the most recent messages up to MAX_TOTAL_MESSAGES
|
|
1120
|
-
*/
|
|
1121
|
-
cleanupOldMessages() {
|
|
1122
|
-
const totalMessages = this._messages().size;
|
|
1123
|
-
if (totalMessages > this.config.maxTotalMessages) {
|
|
1124
|
-
// Get all messages sorted by timestamp (newest first)
|
|
1125
|
-
const allMessages = Array.from(this._messages().values()).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
|
1126
|
-
// Keep only the newest messages
|
|
1127
|
-
const toKeep = allMessages.slice(0, this.config.maxTotalMessages);
|
|
1128
|
-
const toKeepIds = new Set(toKeep.map((m) => m.id));
|
|
1129
|
-
// Remove old messages
|
|
1130
|
-
this._messages.update(() => {
|
|
1131
|
-
const newMessages = new Map();
|
|
1132
|
-
toKeep.forEach((msg) => newMessages.set(msg.id, msg));
|
|
1133
|
-
return newMessages;
|
|
1134
|
-
});
|
|
1135
|
-
// Update conversation messages lists to remove deleted message IDs
|
|
1136
|
-
this._conversationMessages.update((map) => {
|
|
1137
|
-
const newMap = new Map(map);
|
|
1138
|
-
for (const [convId, messageIds] of newMap) {
|
|
1139
|
-
const filteredIds = messageIds.filter((id) => toKeepIds.has(id));
|
|
1140
|
-
newMap.set(convId, filteredIds);
|
|
1141
|
-
}
|
|
1142
|
-
return newMap;
|
|
1143
|
-
});
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
/**
|
|
1147
|
-
* Cleanup messages per conversation to prevent memory leaks
|
|
1148
|
-
* Keeps only recent messages per conversation
|
|
1149
|
-
*/
|
|
1150
|
-
cleanupConversationMessages() {
|
|
1151
|
-
const maxMessages = this.config.maxMessagesPerConversation;
|
|
1152
|
-
const idsToRemove = [];
|
|
1153
|
-
this._conversationMessages.update((map) => {
|
|
1154
|
-
const newMap = new Map(map);
|
|
1155
|
-
for (const [convId, messageIds] of newMap) {
|
|
1156
|
-
if (messageIds.length > maxMessages) {
|
|
1157
|
-
idsToRemove.push(...messageIds.slice(0, messageIds.length - maxMessages));
|
|
1158
|
-
newMap.set(convId, messageIds.slice(-maxMessages));
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
return newMap;
|
|
1162
|
-
});
|
|
1163
|
-
if (idsToRemove.length > 0) {
|
|
1164
|
-
this._messages.update((messages) => {
|
|
1165
|
-
const newMessages = new Map(messages);
|
|
1166
|
-
idsToRemove.forEach((id) => newMessages.delete(id));
|
|
1167
|
-
return newMessages;
|
|
1168
|
-
});
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
1171
|
-
// =====================
|
|
1172
|
-
// Statistics
|
|
1173
|
-
// =====================
|
|
1174
|
-
/**
|
|
1175
|
-
* Get store statistics
|
|
1176
|
-
*/
|
|
1177
|
-
getStats() {
|
|
1178
|
-
return {
|
|
1179
|
-
conversationCount: this._conversations().size,
|
|
1180
|
-
messageCount: this._messages().size,
|
|
1181
|
-
conversationsWithMessages: this._conversationMessages().size,
|
|
1182
|
-
};
|
|
1183
|
-
}
|
|
1184
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: AXConversationStoreService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
1185
|
-
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: AXConversationStoreService }); }
|
|
1186
|
-
}
|
|
1187
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: AXConversationStoreService, decorators: [{
|
|
1188
|
-
type: Injectable
|
|
1189
|
-
}] });
|
|
1190
|
-
|
|
1191
|
-
/**
|
|
1192
|
-
* Validation Utilities
|
|
1193
|
-
* Centralized validation functions for messages, files, and user input
|
|
1194
|
-
*/
|
|
1195
|
-
/**
|
|
1196
|
-
* Validate message text content
|
|
1197
|
-
* @param text - Text to validate
|
|
1198
|
-
* @param config - Configuration for validation rules
|
|
1199
|
-
* @returns Validation result
|
|
1200
|
-
*/
|
|
1201
|
-
function validateMessageText(text, config) {
|
|
1202
|
-
// Check for empty text
|
|
1203
|
-
if (!text || text.trim().length === 0) {
|
|
1204
|
-
return {
|
|
1205
|
-
valid: false,
|
|
1206
|
-
error: 'Message text cannot be empty',
|
|
1207
|
-
errorCode: 'EMPTY_MESSAGE',
|
|
1208
|
-
};
|
|
1209
|
-
}
|
|
1210
|
-
// Check minimum length
|
|
1211
|
-
const minLength = config.minMessageLength ?? 1;
|
|
1212
|
-
if (text.trim().length < minLength) {
|
|
1213
|
-
return {
|
|
1214
|
-
valid: false,
|
|
1215
|
-
error: `Message must be at least ${minLength} character(s)`,
|
|
1216
|
-
errorCode: 'MESSAGE_TOO_SHORT',
|
|
1217
|
-
};
|
|
1218
|
-
}
|
|
1219
|
-
// Check maximum length
|
|
1220
|
-
const maxLength = config.maxMessageLength ?? 10000;
|
|
1221
|
-
if (text.length > maxLength) {
|
|
1222
|
-
return {
|
|
1223
|
-
valid: false,
|
|
1224
|
-
error: `Message exceeds ${maxLength} character limit`,
|
|
1225
|
-
errorCode: 'MESSAGE_TOO_LONG',
|
|
1226
|
-
};
|
|
1227
|
-
}
|
|
1228
|
-
return { valid: true };
|
|
1229
|
-
}
|
|
1230
|
-
/**
|
|
1231
|
-
* Validate file upload
|
|
1232
|
-
* @param file - File to validate
|
|
1233
|
-
* @param config - Configuration for validation rules
|
|
1234
|
-
* @returns File validation result
|
|
1235
|
-
*/
|
|
1236
|
-
function validateFile(file, config) {
|
|
1237
|
-
// Check file size
|
|
1238
|
-
const maxSize = config.maxFileSize ?? 10 * 1024 * 1024; // 10MB default
|
|
1239
|
-
if (file.size > maxSize) {
|
|
1240
|
-
return {
|
|
1241
|
-
valid: false,
|
|
1242
|
-
error: `File size exceeds ${formatFileSize(maxSize)} limit`,
|
|
1243
|
-
errorCode: 'FILE_TOO_LARGE',
|
|
1244
|
-
size: file.size,
|
|
1245
|
-
type: file.type,
|
|
1246
|
-
};
|
|
1247
|
-
}
|
|
1248
|
-
// Check file type if restrictions exist
|
|
1249
|
-
const allowedTypes = config.allowedFileTypes;
|
|
1250
|
-
if (allowedTypes && allowedTypes.length > 0) {
|
|
1251
|
-
const isAllowed = allowedTypes.some((pattern) => matchMimeType(file.type, pattern));
|
|
1252
|
-
if (!isAllowed) {
|
|
1253
|
-
return {
|
|
1254
|
-
valid: false,
|
|
1255
|
-
error: `File type "${file.type}" is not allowed`,
|
|
1256
|
-
errorCode: 'FILE_TYPE_NOT_ALLOWED',
|
|
1257
|
-
size: file.size,
|
|
1258
|
-
type: file.type,
|
|
1259
|
-
};
|
|
1260
|
-
}
|
|
1261
|
-
}
|
|
1262
|
-
return {
|
|
1263
|
-
valid: true,
|
|
1264
|
-
size: file.size,
|
|
1265
|
-
type: file.type,
|
|
1266
|
-
};
|
|
1267
|
-
}
|
|
1268
|
-
/**
|
|
1269
|
-
* Validate conversation ID
|
|
1270
|
-
* @param conversationId - Conversation ID to validate
|
|
1271
|
-
* @returns Validation result
|
|
1272
|
-
*/
|
|
1273
|
-
function validateConversationId(conversationId) {
|
|
1274
|
-
if (!conversationId || typeof conversationId !== 'string' || conversationId.trim().length === 0) {
|
|
1275
|
-
return {
|
|
1276
|
-
valid: false,
|
|
1277
|
-
error: 'Conversation ID is required',
|
|
1278
|
-
errorCode: 'MISSING_CONVERSATION_ID',
|
|
1279
|
-
};
|
|
1280
|
-
}
|
|
1281
|
-
// Check for reasonable length
|
|
1282
|
-
if (conversationId.length > 255) {
|
|
1283
|
-
return {
|
|
1284
|
-
valid: false,
|
|
1285
|
-
error: 'Conversation ID is too long',
|
|
1286
|
-
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',
|
|
1287
517
|
};
|
|
1288
518
|
}
|
|
1289
519
|
return { valid: true };
|
|
@@ -1354,242 +584,784 @@ function validateMessagePayload(payload, type) {
|
|
|
1354
584
|
}
|
|
1355
585
|
break;
|
|
1356
586
|
}
|
|
1357
|
-
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;
|
|
1358
746
|
}
|
|
1359
747
|
/**
|
|
1360
|
-
*
|
|
1361
|
-
* @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
|
|
1362
782
|
* @returns Validation result
|
|
1363
783
|
*/
|
|
1364
|
-
function
|
|
1365
|
-
if (
|
|
784
|
+
function validateLatitude(latitude) {
|
|
785
|
+
if (latitude === undefined || latitude === null || typeof latitude !== 'number' || isNaN(latitude)) {
|
|
1366
786
|
return {
|
|
1367
787
|
valid: false,
|
|
1368
|
-
error: '
|
|
1369
|
-
errorCode: '
|
|
788
|
+
error: 'Latitude is required',
|
|
789
|
+
errorCode: 'MISSING_LATITUDE',
|
|
1370
790
|
};
|
|
1371
791
|
}
|
|
1372
|
-
|
|
1373
|
-
if (userId.length > 255) {
|
|
792
|
+
if (latitude < -90 || latitude > 90) {
|
|
1374
793
|
return {
|
|
1375
794
|
valid: false,
|
|
1376
|
-
error: '
|
|
1377
|
-
errorCode: '
|
|
795
|
+
error: 'Latitude must be between -90 and 90',
|
|
796
|
+
errorCode: 'INVALID_LATITUDE',
|
|
1378
797
|
};
|
|
1379
798
|
}
|
|
1380
799
|
return { valid: true };
|
|
1381
800
|
}
|
|
1382
801
|
/**
|
|
1383
|
-
* Validate
|
|
1384
|
-
* @param
|
|
1385
|
-
* @param minCount - Minimum number of users required
|
|
1386
|
-
* @param maxCount - Maximum number of users allowed
|
|
802
|
+
* Validate longitude coordinate
|
|
803
|
+
* @param longitude - Longitude to validate
|
|
1387
804
|
* @returns Validation result
|
|
1388
805
|
*/
|
|
1389
|
-
function
|
|
1390
|
-
if (
|
|
806
|
+
function validateLongitude(longitude) {
|
|
807
|
+
if (longitude === undefined || longitude === null || typeof longitude !== 'number' || isNaN(longitude)) {
|
|
1391
808
|
return {
|
|
1392
809
|
valid: false,
|
|
1393
|
-
error: '
|
|
1394
|
-
errorCode: '
|
|
810
|
+
error: 'Longitude is required',
|
|
811
|
+
errorCode: 'MISSING_LONGITUDE',
|
|
1395
812
|
};
|
|
1396
813
|
}
|
|
1397
|
-
if (
|
|
814
|
+
if (longitude < -180 || longitude > 180) {
|
|
1398
815
|
return {
|
|
1399
816
|
valid: false,
|
|
1400
|
-
error:
|
|
1401
|
-
errorCode: '
|
|
817
|
+
error: 'Longitude must be between -180 and 180',
|
|
818
|
+
errorCode: 'INVALID_LONGITUDE',
|
|
1402
819
|
};
|
|
1403
820
|
}
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
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]) });
|
|
1410
838
|
}
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
839
|
+
setConversations(conversations) {
|
|
840
|
+
const convMap = new Map();
|
|
841
|
+
conversations.forEach((conv) => convMap.set(conv.id, conv));
|
|
842
|
+
this._conversations.set(convMap);
|
|
843
|
+
}
|
|
844
|
+
addConversations(conversations) {
|
|
845
|
+
this._conversations.update((existingConversations) => {
|
|
846
|
+
const newConversations = new Map(existingConversations);
|
|
847
|
+
conversations.forEach((conv) => newConversations.set(conv.id, conv));
|
|
848
|
+
const maxCached = this.config.maxCachedConversations;
|
|
849
|
+
if (newConversations.size > maxCached) {
|
|
850
|
+
const sorted = Array.from(newConversations.values()).sort((a, b) => (b.lastMessageAt?.getTime() ?? 0) - (a.lastMessageAt?.getTime() ?? 0));
|
|
851
|
+
const toKeep = sorted.slice(0, maxCached);
|
|
852
|
+
const cleanedMap = new Map();
|
|
853
|
+
toKeep.forEach((conv) => cleanedMap.set(conv.id, conv));
|
|
854
|
+
return cleanedMap;
|
|
855
|
+
}
|
|
856
|
+
return newConversations;
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
setConversation(conversation) {
|
|
860
|
+
this._conversations.update((conversations) => {
|
|
861
|
+
const newConversations = new Map(conversations);
|
|
862
|
+
newConversations.set(conversation.id, conversation);
|
|
863
|
+
return newConversations;
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
getConversation(conversationId) {
|
|
867
|
+
return this._conversations().get(conversationId);
|
|
868
|
+
}
|
|
869
|
+
updateConversation(conversationId, updates) {
|
|
870
|
+
const conversation = this._conversations().get(conversationId);
|
|
871
|
+
if (!conversation)
|
|
872
|
+
return;
|
|
873
|
+
this.setConversation({ ...conversation, ...updates });
|
|
874
|
+
}
|
|
875
|
+
deleteConversation(conversationId) {
|
|
876
|
+
this._conversations.update((conversations) => {
|
|
877
|
+
const newConversations = new Map(conversations);
|
|
878
|
+
newConversations.delete(conversationId);
|
|
879
|
+
return newConversations;
|
|
880
|
+
});
|
|
881
|
+
const messageIds = this._conversationMessages().get(conversationId) || [];
|
|
882
|
+
this._messages.update((messages) => {
|
|
883
|
+
const newMessages = new Map(messages);
|
|
884
|
+
messageIds.forEach((id) => newMessages.delete(id));
|
|
885
|
+
return newMessages;
|
|
886
|
+
});
|
|
887
|
+
this._conversationMessages.update((map) => {
|
|
888
|
+
const newMap = new Map(map);
|
|
889
|
+
newMap.delete(conversationId);
|
|
890
|
+
return newMap;
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
updateLastMessage(message) {
|
|
894
|
+
this.updateConversation(message.conversationId, {
|
|
895
|
+
lastMessage: message,
|
|
896
|
+
lastMessageAt: message.timestamp,
|
|
897
|
+
updatedAt: message.timestamp,
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
incrementUnreadCount(conversationId) {
|
|
901
|
+
const conversation = this._conversations().get(conversationId);
|
|
902
|
+
if (!conversation)
|
|
903
|
+
return;
|
|
904
|
+
this.updateConversation(conversationId, { unreadCount: conversation.unreadCount + 1 });
|
|
905
|
+
}
|
|
906
|
+
resetUnreadCount(conversationId) {
|
|
907
|
+
this.updateConversation(conversationId, { unreadCount: 0 });
|
|
908
|
+
}
|
|
909
|
+
updateSettings(conversationId, settings) {
|
|
910
|
+
const conversation = this._conversations().get(conversationId);
|
|
911
|
+
if (!conversation)
|
|
912
|
+
return;
|
|
913
|
+
this.updateConversation(conversationId, {
|
|
914
|
+
settings: { ...conversation.settings, ...settings },
|
|
915
|
+
updatedAt: new Date(),
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
updateTitle(conversationId, title) {
|
|
919
|
+
this.updateConversation(conversationId, { title, updatedAt: new Date() });
|
|
920
|
+
}
|
|
921
|
+
updateMetadata(conversationId, metadata) {
|
|
922
|
+
const conversation = this._conversations().get(conversationId);
|
|
923
|
+
if (!conversation)
|
|
924
|
+
return;
|
|
925
|
+
this.updateConversation(conversationId, {
|
|
926
|
+
metadata: { ...conversation.metadata, ...metadata },
|
|
927
|
+
updatedAt: new Date(),
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
updateTypingIndicator(conversationId, userId, isTyping) {
|
|
931
|
+
const conversation = this._conversations().get(conversationId);
|
|
932
|
+
if (!conversation)
|
|
933
|
+
return;
|
|
934
|
+
let typingUsers = [...conversation.status.typingUsers];
|
|
935
|
+
if (isTyping) {
|
|
936
|
+
if (!typingUsers.includes(userId)) {
|
|
937
|
+
typingUsers.push(userId);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
else {
|
|
941
|
+
typingUsers = typingUsers.filter((id) => id !== userId);
|
|
942
|
+
}
|
|
943
|
+
this.updateConversation(conversationId, {
|
|
944
|
+
status: {
|
|
945
|
+
...conversation.status,
|
|
946
|
+
isTyping: typingUsers.length > 0,
|
|
947
|
+
typingUsers,
|
|
948
|
+
},
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
updateParticipantPresence(userId, status, lastSeen) {
|
|
952
|
+
this._conversations.update((conversations) => {
|
|
953
|
+
const newConversations = new Map(conversations);
|
|
954
|
+
for (const [id, conv] of conversations) {
|
|
955
|
+
const participant = conv.participants.find((p) => p.id === userId);
|
|
956
|
+
if (participant) {
|
|
957
|
+
const updatedConversation = {
|
|
958
|
+
...conv,
|
|
959
|
+
participants: conv.participants.map((p) => (p.id === userId ? { ...p, status, lastSeen } : p)),
|
|
960
|
+
status: conv.type === 'private' ? { ...conv.status, presence: status, lastSeen } : conv.status,
|
|
961
|
+
};
|
|
962
|
+
newConversations.set(id, updatedConversation);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
return newConversations;
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
addMessage(message) {
|
|
969
|
+
this._messages.update((messages) => {
|
|
970
|
+
const newMessages = new Map(messages);
|
|
971
|
+
newMessages.set(message.id, message);
|
|
972
|
+
return newMessages;
|
|
973
|
+
});
|
|
974
|
+
this._conversationMessages.update((map) => {
|
|
975
|
+
const newMap = new Map(map);
|
|
976
|
+
const existing = newMap.get(message.conversationId) || [];
|
|
977
|
+
if (existing.includes(message.id)) {
|
|
978
|
+
return newMap;
|
|
979
|
+
}
|
|
980
|
+
const updated = [...existing, message.id].sort((a, b) => {
|
|
981
|
+
const msgA = this._messages().get(a);
|
|
982
|
+
const msgB = this._messages().get(b);
|
|
983
|
+
if (!msgA || !msgB)
|
|
984
|
+
return 0;
|
|
985
|
+
return msgA.timestamp.getTime() - msgB.timestamp.getTime();
|
|
986
|
+
});
|
|
987
|
+
newMap.set(message.conversationId, updated);
|
|
988
|
+
return newMap;
|
|
989
|
+
});
|
|
1419
990
|
}
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
};
|
|
991
|
+
addMessages(messages) {
|
|
992
|
+
if (messages.length === 0)
|
|
993
|
+
return;
|
|
994
|
+
this._messages.update((msgs) => {
|
|
995
|
+
const newMessages = new Map(msgs);
|
|
996
|
+
messages.forEach((msg) => newMessages.set(msg.id, msg));
|
|
997
|
+
return newMessages;
|
|
998
|
+
});
|
|
999
|
+
const conversationGroups = new Map();
|
|
1000
|
+
messages.forEach((msg) => {
|
|
1001
|
+
const existing = conversationGroups.get(msg.conversationId) || [];
|
|
1002
|
+
existing.push(msg.id);
|
|
1003
|
+
conversationGroups.set(msg.conversationId, existing);
|
|
1004
|
+
});
|
|
1005
|
+
this._conversationMessages.update((map) => {
|
|
1006
|
+
const newMap = new Map(map);
|
|
1007
|
+
for (const [conversationId, newMsgIds] of conversationGroups) {
|
|
1008
|
+
const existing = newMap.get(conversationId) || [];
|
|
1009
|
+
const idSet = new Set(existing);
|
|
1010
|
+
const merged = [...existing];
|
|
1011
|
+
for (const id of newMsgIds) {
|
|
1012
|
+
if (!idSet.has(id)) {
|
|
1013
|
+
merged.push(id);
|
|
1014
|
+
idSet.add(id);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
const sorted = merged.sort((a, b) => {
|
|
1018
|
+
const msgA = this._messages().get(a);
|
|
1019
|
+
const msgB = this._messages().get(b);
|
|
1020
|
+
if (!msgA || !msgB)
|
|
1021
|
+
return 0;
|
|
1022
|
+
return msgA.timestamp.getTime() - msgB.timestamp.getTime();
|
|
1023
|
+
});
|
|
1024
|
+
newMap.set(conversationId, sorted);
|
|
1025
|
+
}
|
|
1026
|
+
return newMap;
|
|
1027
|
+
});
|
|
1028
|
+
this.cleanupOldMessages();
|
|
1029
|
+
this.cleanupConversationMessages();
|
|
1434
1030
|
}
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
// Check length constraints
|
|
1438
|
-
if (trimmedEmail.length > 254) {
|
|
1439
|
-
return {
|
|
1440
|
-
valid: false,
|
|
1441
|
-
error: 'Email is too long',
|
|
1442
|
-
errorCode: 'INVALID_EMAIL',
|
|
1443
|
-
};
|
|
1031
|
+
getMessage(messageId) {
|
|
1032
|
+
return this._messages().get(messageId);
|
|
1444
1033
|
}
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
return {
|
|
1449
|
-
valid: false,
|
|
1450
|
-
error: 'Invalid email format',
|
|
1451
|
-
errorCode: 'INVALID_EMAIL',
|
|
1452
|
-
};
|
|
1034
|
+
getConversationMessages(conversationId) {
|
|
1035
|
+
const messageIds = this._conversationMessages().get(conversationId) || [];
|
|
1036
|
+
return messageIds.map((id) => this._messages().get(id)).filter((msg) => msg !== undefined);
|
|
1453
1037
|
}
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
* @returns Validation result
|
|
1460
|
-
*/
|
|
1461
|
-
function validateUrl(url) {
|
|
1462
|
-
if (!url || url.trim().length === 0) {
|
|
1463
|
-
return {
|
|
1464
|
-
valid: false,
|
|
1465
|
-
error: 'URL is required',
|
|
1466
|
-
errorCode: 'MISSING_URL',
|
|
1467
|
-
};
|
|
1038
|
+
updateMessage(messageId, updates) {
|
|
1039
|
+
const message = this._messages().get(messageId);
|
|
1040
|
+
if (!message)
|
|
1041
|
+
return;
|
|
1042
|
+
this.addMessage({ ...message, ...updates });
|
|
1468
1043
|
}
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1044
|
+
deleteMessage(messageId) {
|
|
1045
|
+
const message = this._messages().get(messageId);
|
|
1046
|
+
if (!message)
|
|
1047
|
+
return;
|
|
1048
|
+
this._messages.update((messages) => {
|
|
1049
|
+
const newMessages = new Map(messages);
|
|
1050
|
+
newMessages.delete(messageId);
|
|
1051
|
+
return newMessages;
|
|
1052
|
+
});
|
|
1053
|
+
this._conversationMessages.update((map) => {
|
|
1054
|
+
const newMap = new Map(map);
|
|
1055
|
+
const existing = newMap.get(message.conversationId);
|
|
1056
|
+
if (existing) {
|
|
1057
|
+
newMap.set(message.conversationId, existing.filter((id) => id !== messageId));
|
|
1058
|
+
}
|
|
1059
|
+
return newMap;
|
|
1060
|
+
});
|
|
1477
1061
|
}
|
|
1478
|
-
|
|
1479
|
-
const
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1062
|
+
cleanupOldMessages() {
|
|
1063
|
+
const totalMessages = this._messages().size;
|
|
1064
|
+
if (totalMessages > this.config.maxTotalMessages) {
|
|
1065
|
+
const allMessages = Array.from(this._messages().values()).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
|
1066
|
+
const toKeep = allMessages.slice(0, this.config.maxTotalMessages);
|
|
1067
|
+
const toKeepIds = new Set(toKeep.map((m) => m.id));
|
|
1068
|
+
this._messages.update(() => {
|
|
1069
|
+
const newMessages = new Map();
|
|
1070
|
+
toKeep.forEach((msg) => newMessages.set(msg.id, msg));
|
|
1071
|
+
return newMessages;
|
|
1072
|
+
});
|
|
1073
|
+
this._conversationMessages.update((map) => {
|
|
1074
|
+
const newMap = new Map(map);
|
|
1075
|
+
for (const [convId, messageIds] of newMap) {
|
|
1076
|
+
const filteredIds = messageIds.filter((id) => toKeepIds.has(id));
|
|
1077
|
+
newMap.set(convId, filteredIds);
|
|
1078
|
+
}
|
|
1079
|
+
return newMap;
|
|
1080
|
+
});
|
|
1487
1081
|
}
|
|
1488
|
-
return { valid: true };
|
|
1489
|
-
}
|
|
1490
|
-
catch {
|
|
1491
|
-
return {
|
|
1492
|
-
valid: false,
|
|
1493
|
-
error: 'Invalid URL format',
|
|
1494
|
-
errorCode: 'INVALID_URL',
|
|
1495
|
-
};
|
|
1496
|
-
}
|
|
1497
|
-
}
|
|
1498
|
-
// =====================
|
|
1499
|
-
// Helper Functions
|
|
1500
|
-
// =====================
|
|
1501
|
-
/**
|
|
1502
|
-
* Match MIME type against a pattern (supports wildcards)
|
|
1503
|
-
* @param mimeType - MIME type to check
|
|
1504
|
-
* @param pattern - Pattern to match (e.g., "image/*", "video/mp4")
|
|
1505
|
-
* @returns Whether the MIME type matches the pattern
|
|
1506
|
-
*/
|
|
1507
|
-
function matchMimeType(mimeType, pattern) {
|
|
1508
|
-
if (pattern === '*/*' || pattern === '*') {
|
|
1509
|
-
return true;
|
|
1510
1082
|
}
|
|
1511
|
-
|
|
1512
|
-
const
|
|
1513
|
-
|
|
1083
|
+
cleanupConversationMessages() {
|
|
1084
|
+
const maxMessages = this.config.maxMessagesPerConversation;
|
|
1085
|
+
const idsToRemove = [];
|
|
1086
|
+
this._conversationMessages.update((map) => {
|
|
1087
|
+
const newMap = new Map(map);
|
|
1088
|
+
for (const [convId, messageIds] of newMap) {
|
|
1089
|
+
if (messageIds.length > maxMessages) {
|
|
1090
|
+
idsToRemove.push(...messageIds.slice(0, messageIds.length - maxMessages));
|
|
1091
|
+
newMap.set(convId, messageIds.slice(-maxMessages));
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
return newMap;
|
|
1095
|
+
});
|
|
1096
|
+
if (idsToRemove.length > 0) {
|
|
1097
|
+
this._messages.update((messages) => {
|
|
1098
|
+
const newMessages = new Map(messages);
|
|
1099
|
+
idsToRemove.forEach((id) => newMessages.delete(id));
|
|
1100
|
+
return newMessages;
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1514
1103
|
}
|
|
1515
|
-
return mimeType === pattern;
|
|
1516
|
-
}
|
|
1517
|
-
/**
|
|
1518
|
-
* Format file size in human-readable format
|
|
1519
|
-
* @param bytes - File size in bytes
|
|
1520
|
-
* @returns Formatted file size string
|
|
1521
|
-
*/
|
|
1522
|
-
function formatFileSize(bytes) {
|
|
1523
|
-
if (bytes === 0)
|
|
1524
|
-
return '0 Bytes';
|
|
1525
|
-
const k = 1024;
|
|
1526
|
-
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
1527
|
-
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
|
1528
|
-
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
|
1529
1104
|
}
|
|
1105
|
+
|
|
1530
1106
|
/**
|
|
1531
|
-
*
|
|
1532
|
-
*
|
|
1533
|
-
* @param input - User input to sanitize
|
|
1534
|
-
* @returns Sanitized input
|
|
1107
|
+
* Error Handler Service
|
|
1108
|
+
* Centralized error handling and logging
|
|
1535
1109
|
*/
|
|
1536
|
-
function sanitizeInput(input) {
|
|
1537
|
-
if (!input)
|
|
1538
|
-
return '';
|
|
1539
|
-
return input
|
|
1540
|
-
.replace(/&/g, '&')
|
|
1541
|
-
.replace(/</g, '<')
|
|
1542
|
-
.replace(/>/g, '>')
|
|
1543
|
-
.replace(/"/g, '"')
|
|
1544
|
-
.replace(/'/g, ''')
|
|
1545
|
-
.replace(/\//g, '/')
|
|
1546
|
-
.replace(/`/g, '`')
|
|
1547
|
-
.replace(/=/g, '=');
|
|
1548
|
-
}
|
|
1549
1110
|
/**
|
|
1550
|
-
*
|
|
1551
|
-
* @param latitude - Latitude to validate
|
|
1552
|
-
* @returns Validation result
|
|
1111
|
+
* Error Handler Service
|
|
1553
1112
|
*/
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1113
|
+
class AXErrorHandlerService {
|
|
1114
|
+
constructor() {
|
|
1115
|
+
this.injectedConfig = inject(ERROR_HANDLER_CONFIG);
|
|
1116
|
+
this._errors$ = new Subject();
|
|
1117
|
+
this._config = {
|
|
1118
|
+
logToConsole: true,
|
|
1119
|
+
showUserMessages: true,
|
|
1120
|
+
autoRetry: false,
|
|
1121
|
+
maxRetries: 3,
|
|
1560
1122
|
};
|
|
1123
|
+
/** Error stream */
|
|
1124
|
+
this.errors$ = this._errors$.asObservable();
|
|
1125
|
+
this.configure(this.injectedConfig);
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Configure error handler
|
|
1129
|
+
*/
|
|
1130
|
+
configure(config) {
|
|
1131
|
+
Object.assign(this._config, config);
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Handle an error
|
|
1135
|
+
*/
|
|
1136
|
+
handle(error, operation, context) {
|
|
1137
|
+
const conversationError = this.normalizeError(error, operation, context);
|
|
1138
|
+
this.publish(conversationError);
|
|
1139
|
+
return conversationError;
|
|
1561
1140
|
}
|
|
1562
|
-
|
|
1141
|
+
/**
|
|
1142
|
+
* Handle API error (same pipeline as {@link handle}, for typed API failures)
|
|
1143
|
+
*/
|
|
1144
|
+
handleApiError(apiError, operation, context) {
|
|
1145
|
+
const conversationError = this.conversationErrorFromApi(apiError, operation, context);
|
|
1146
|
+
this.publish(conversationError);
|
|
1147
|
+
return conversationError;
|
|
1148
|
+
}
|
|
1149
|
+
publish(conversationError) {
|
|
1150
|
+
this._errors$.next(conversationError);
|
|
1151
|
+
if (this._config.logToConsole) {
|
|
1152
|
+
this.logError(conversationError);
|
|
1153
|
+
}
|
|
1154
|
+
if (this._config.customHandler) {
|
|
1155
|
+
this._config.customHandler(conversationError);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Normalize any error to conversation error format (does not publish — use {@link handle})
|
|
1160
|
+
*/
|
|
1161
|
+
normalizeError(error, operation, context) {
|
|
1162
|
+
if (this.isApiError(error)) {
|
|
1163
|
+
return this.conversationErrorFromApi(error, operation, context);
|
|
1164
|
+
}
|
|
1165
|
+
const errorObj = error;
|
|
1166
|
+
const message = (typeof errorObj['message'] === 'string' && errorObj['message']) ||
|
|
1167
|
+
(error instanceof Error ? error.message : String(error)) ||
|
|
1168
|
+
'An unknown error occurred';
|
|
1169
|
+
const code = (typeof errorObj['code'] === 'string' && errorObj['code']) || 'UNKNOWN_ERROR';
|
|
1170
|
+
const statusCodeRaw = errorObj['statusCode'] ?? errorObj['status'];
|
|
1171
|
+
const statusCode = typeof statusCodeRaw === 'number' ? statusCodeRaw : undefined;
|
|
1563
1172
|
return {
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1173
|
+
code,
|
|
1174
|
+
message,
|
|
1175
|
+
severity: 'error',
|
|
1176
|
+
operation,
|
|
1177
|
+
originalError: error,
|
|
1178
|
+
statusCode,
|
|
1179
|
+
context,
|
|
1180
|
+
timestamp: new Date(),
|
|
1181
|
+
handled: false,
|
|
1182
|
+
recoverySuggestions: this.getDefaultRecoverySuggestions(code),
|
|
1567
1183
|
};
|
|
1568
1184
|
}
|
|
1569
|
-
|
|
1570
|
-
}
|
|
1571
|
-
/**
|
|
1572
|
-
* Validate longitude coordinate
|
|
1573
|
-
* @param longitude - Longitude to validate
|
|
1574
|
-
* @returns Validation result
|
|
1575
|
-
*/
|
|
1576
|
-
function validateLongitude(longitude) {
|
|
1577
|
-
if (longitude === undefined || longitude === null || typeof longitude !== 'number' || isNaN(longitude)) {
|
|
1185
|
+
conversationErrorFromApi(apiError, operation, context) {
|
|
1578
1186
|
return {
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1187
|
+
code: apiError.code,
|
|
1188
|
+
message: apiError.message,
|
|
1189
|
+
severity: this.determineSeverity(apiError.statusCode),
|
|
1190
|
+
operation,
|
|
1191
|
+
originalError: apiError,
|
|
1192
|
+
statusCode: apiError.statusCode,
|
|
1193
|
+
context,
|
|
1194
|
+
timestamp: apiError.timestamp ?? new Date(),
|
|
1195
|
+
handled: false,
|
|
1196
|
+
recoverySuggestions: this.getRecoverySuggestions(apiError),
|
|
1582
1197
|
};
|
|
1583
1198
|
}
|
|
1584
|
-
|
|
1585
|
-
return
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1199
|
+
isApiError(error) {
|
|
1200
|
+
return (typeof error === 'object' &&
|
|
1201
|
+
error !== null &&
|
|
1202
|
+
'code' in error &&
|
|
1203
|
+
'message' in error &&
|
|
1204
|
+
typeof error.code === 'string' &&
|
|
1205
|
+
typeof error.message === 'string');
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Determine severity based on status code
|
|
1209
|
+
*/
|
|
1210
|
+
determineSeverity(statusCode) {
|
|
1211
|
+
if (!statusCode)
|
|
1212
|
+
return 'error';
|
|
1213
|
+
if (statusCode >= 500)
|
|
1214
|
+
return 'critical';
|
|
1215
|
+
if (statusCode >= 400)
|
|
1216
|
+
return 'error';
|
|
1217
|
+
if (statusCode >= 300)
|
|
1218
|
+
return 'warning';
|
|
1219
|
+
return 'info';
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* Get recovery suggestions based on error
|
|
1223
|
+
*/
|
|
1224
|
+
getRecoverySuggestions(error) {
|
|
1225
|
+
const suggestions = [];
|
|
1226
|
+
if (error.statusCode === 401 || error.code === 'UNAUTHORIZED') {
|
|
1227
|
+
suggestions.push('Please log in again');
|
|
1228
|
+
suggestions.push('Check if your session has expired');
|
|
1229
|
+
}
|
|
1230
|
+
else if (error.statusCode === 403 || error.code === 'FORBIDDEN') {
|
|
1231
|
+
suggestions.push('You do not have permission for this action');
|
|
1232
|
+
suggestions.push('Ask your administrator for access');
|
|
1233
|
+
}
|
|
1234
|
+
else if (error.statusCode === 404 || error.code === 'NOT_FOUND') {
|
|
1235
|
+
suggestions.push('The requested resource was not found');
|
|
1236
|
+
suggestions.push('It may have been deleted or moved');
|
|
1237
|
+
}
|
|
1238
|
+
else if (error.statusCode === 429 || error.code === 'RATE_LIMIT_EXCEEDED') {
|
|
1239
|
+
suggestions.push('Too many requests. Please wait and try again');
|
|
1240
|
+
}
|
|
1241
|
+
else if (error.statusCode && error.statusCode >= 500) {
|
|
1242
|
+
suggestions.push('Server error occurred');
|
|
1243
|
+
suggestions.push('Please try again later');
|
|
1244
|
+
suggestions.push('If the problem persists, reach out to support');
|
|
1245
|
+
}
|
|
1246
|
+
else if (error.code === 'NETWORK_ERROR') {
|
|
1247
|
+
suggestions.push('Check your internet connection');
|
|
1248
|
+
suggestions.push('Try refreshing the page');
|
|
1249
|
+
}
|
|
1250
|
+
return suggestions;
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* Get default recovery suggestions
|
|
1254
|
+
*/
|
|
1255
|
+
getDefaultRecoverySuggestions(code) {
|
|
1256
|
+
const suggestions = [];
|
|
1257
|
+
if (code.includes('NETWORK') || code.includes('CONNECTION')) {
|
|
1258
|
+
suggestions.push('Check your internet connection');
|
|
1259
|
+
suggestions.push('Try refreshing the page');
|
|
1260
|
+
}
|
|
1261
|
+
else if (code.includes('TIMEOUT')) {
|
|
1262
|
+
suggestions.push('The operation took too long');
|
|
1263
|
+
suggestions.push('Please try again');
|
|
1264
|
+
}
|
|
1265
|
+
else {
|
|
1266
|
+
suggestions.push('Please try again');
|
|
1267
|
+
suggestions.push('If the problem persists, reach out to support');
|
|
1268
|
+
}
|
|
1269
|
+
return suggestions;
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* Log error to console
|
|
1273
|
+
*/
|
|
1274
|
+
logError(error) {
|
|
1275
|
+
const isError = error.severity === 'critical' || error.severity === 'error';
|
|
1276
|
+
const header = `[Conversation ${error.severity.toUpperCase()}] ${error.operation}:`;
|
|
1277
|
+
if (isError) {
|
|
1278
|
+
console.error(header, error.message, error.context || '');
|
|
1279
|
+
}
|
|
1280
|
+
else {
|
|
1281
|
+
console.warn(header, error.message, error.context || '');
|
|
1282
|
+
}
|
|
1283
|
+
if (error.originalError && error.severity !== 'info') {
|
|
1284
|
+
console.error('Original error:', error.originalError);
|
|
1285
|
+
}
|
|
1286
|
+
if (error.recoverySuggestions && error.recoverySuggestions.length > 0) {
|
|
1287
|
+
console.info('Recovery suggestions:', error.recoverySuggestions);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* Get user-friendly error message
|
|
1292
|
+
*/
|
|
1293
|
+
getUserFriendlyMessage(error) {
|
|
1294
|
+
// Map technical errors to user-friendly messages
|
|
1295
|
+
const messageMap = {
|
|
1296
|
+
NETWORK_ERROR: 'Unable to connect. Please check your internet connection.',
|
|
1297
|
+
UNAUTHORIZED: 'You are not authorized. Please log in again.',
|
|
1298
|
+
FORBIDDEN: 'You do not have permission to perform this action.',
|
|
1299
|
+
NOT_FOUND: 'The requested item could not be found.',
|
|
1300
|
+
RATE_LIMIT_EXCEEDED: 'Too many requests. Please slow down.',
|
|
1301
|
+
VALIDATION_ERROR: 'The provided data is invalid.',
|
|
1302
|
+
SERVER_ERROR: 'A server error occurred. Please try again later.',
|
|
1303
|
+
TIMEOUT: 'The operation timed out. Please try again.',
|
|
1589
1304
|
};
|
|
1305
|
+
return messageMap[error.code] || error.message || 'An unexpected error occurred.';
|
|
1590
1306
|
}
|
|
1591
|
-
|
|
1307
|
+
/**
|
|
1308
|
+
* Check if error is retryable
|
|
1309
|
+
*/
|
|
1310
|
+
isRetryable(error) {
|
|
1311
|
+
const retryableCodes = ['NETWORK_ERROR', 'TIMEOUT', 'RATE_LIMIT_EXCEEDED', 'SERVER_ERROR'];
|
|
1312
|
+
const retryableStatusCodes = [408, 429, 500, 502, 503, 504];
|
|
1313
|
+
return (retryableCodes.includes(error.code) ||
|
|
1314
|
+
(error.statusCode !== undefined && retryableStatusCodes.includes(error.statusCode)));
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Execute an operation with automatic retry logic
|
|
1318
|
+
* @param operation - The async operation to execute
|
|
1319
|
+
* @param operationName - Name of the operation for error tracking
|
|
1320
|
+
* @param context - Additional context for error handling
|
|
1321
|
+
* @returns Promise resolving to the operation result
|
|
1322
|
+
* @throws {AXConversationError} If all retries fail
|
|
1323
|
+
*/
|
|
1324
|
+
async executeWithRetry(operation, operationName, context) {
|
|
1325
|
+
if (!this._config.autoRetry) {
|
|
1326
|
+
// If auto-retry is disabled, just execute once
|
|
1327
|
+
return operation();
|
|
1328
|
+
}
|
|
1329
|
+
const maxRetries = this._config.maxRetries ?? 3;
|
|
1330
|
+
let lastError;
|
|
1331
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1332
|
+
try {
|
|
1333
|
+
return await operation();
|
|
1334
|
+
}
|
|
1335
|
+
catch (error) {
|
|
1336
|
+
lastError = error;
|
|
1337
|
+
const conversationError = this.handle(error, operationName, { ...context, attempt });
|
|
1338
|
+
// Don't retry if error is not retryable or if this was the last attempt
|
|
1339
|
+
if (!this.isRetryable(conversationError) || attempt >= maxRetries) {
|
|
1340
|
+
throw conversationError;
|
|
1341
|
+
}
|
|
1342
|
+
// Exponential backoff: 1s, 2s, 4s, 8s...
|
|
1343
|
+
const delayMs = Math.pow(2, attempt) * 1000;
|
|
1344
|
+
await this.delay(delayMs);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
throw this.handle(lastError, operationName, context);
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Delay helper for retry backoff
|
|
1351
|
+
* @param ms - Milliseconds to delay
|
|
1352
|
+
*/
|
|
1353
|
+
delay(ms) {
|
|
1354
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1355
|
+
}
|
|
1356
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: AXErrorHandlerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
1357
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: AXErrorHandlerService, providedIn: 'root' }); }
|
|
1592
1358
|
}
|
|
1359
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImport: i0, type: AXErrorHandlerService, decorators: [{
|
|
1360
|
+
type: Injectable,
|
|
1361
|
+
args: [{
|
|
1362
|
+
providedIn: 'root',
|
|
1363
|
+
}]
|
|
1364
|
+
}], ctorParameters: () => [] });
|
|
1593
1365
|
|
|
1594
1366
|
/**
|
|
1595
1367
|
* Composer Action Registry
|
|
@@ -4968,24 +4740,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImpor
|
|
|
4968
4740
|
*/
|
|
4969
4741
|
|
|
4970
4742
|
/**
|
|
4971
|
-
* File
|
|
4972
|
-
* Centralized service for handling file uploads and processing
|
|
4743
|
+
* File helpers for composer pickers: previews, validation, and metadata.
|
|
4973
4744
|
*/
|
|
4974
4745
|
class AXFileUploadService {
|
|
4975
4746
|
constructor() {
|
|
4976
4747
|
this.config = inject(CONVERSATION_CONFIG);
|
|
4977
4748
|
this.platformId = inject(PLATFORM_ID);
|
|
4978
|
-
this.uploadProgress$ = new Subject();
|
|
4979
|
-
}
|
|
4980
|
-
/**
|
|
4981
|
-
* Get upload progress observable
|
|
4982
|
-
*/
|
|
4983
|
-
getUploadProgress() {
|
|
4984
|
-
return this.uploadProgress$.asObservable();
|
|
4985
4749
|
}
|
|
4986
|
-
/**
|
|
4987
|
-
* Read file as data URL for preview
|
|
4988
|
-
*/
|
|
4989
4750
|
async readFileAsDataURL(file) {
|
|
4990
4751
|
return new Promise((resolve, reject) => {
|
|
4991
4752
|
const reader = new FileReader();
|
|
@@ -4994,9 +4755,6 @@ class AXFileUploadService {
|
|
|
4994
4755
|
reader.readAsDataURL(file);
|
|
4995
4756
|
});
|
|
4996
4757
|
}
|
|
4997
|
-
/**
|
|
4998
|
-
* Get file type category
|
|
4999
|
-
*/
|
|
5000
4758
|
getFileType(file) {
|
|
5001
4759
|
if (file.type.startsWith('image/'))
|
|
5002
4760
|
return 'image';
|
|
@@ -5006,9 +4764,6 @@ class AXFileUploadService {
|
|
|
5006
4764
|
return 'audio';
|
|
5007
4765
|
return 'file';
|
|
5008
4766
|
}
|
|
5009
|
-
/**
|
|
5010
|
-
* Generate file preview
|
|
5011
|
-
*/
|
|
5012
4767
|
async generatePreview(file) {
|
|
5013
4768
|
const type = this.getFileType(file);
|
|
5014
4769
|
let preview;
|
|
@@ -5022,15 +4777,9 @@ class AXFileUploadService {
|
|
|
5022
4777
|
}
|
|
5023
4778
|
return { file, preview, type };
|
|
5024
4779
|
}
|
|
5025
|
-
/**
|
|
5026
|
-
* Generate previews for multiple files
|
|
5027
|
-
*/
|
|
5028
4780
|
async generatePreviews(files) {
|
|
5029
4781
|
return Promise.all(files.map((file) => this.generatePreview(file)));
|
|
5030
4782
|
}
|
|
5031
|
-
/**
|
|
5032
|
-
* Get video duration
|
|
5033
|
-
*/
|
|
5034
4783
|
async getVideoDuration(file) {
|
|
5035
4784
|
if (!isPlatformBrowser(this.platformId)) {
|
|
5036
4785
|
return 0;
|
|
@@ -5049,9 +4798,6 @@ class AXFileUploadService {
|
|
|
5049
4798
|
video.src = URL.createObjectURL(file);
|
|
5050
4799
|
});
|
|
5051
4800
|
}
|
|
5052
|
-
/**
|
|
5053
|
-
* Get audio duration
|
|
5054
|
-
*/
|
|
5055
4801
|
async getAudioDuration(file) {
|
|
5056
4802
|
if (!isPlatformBrowser(this.platformId)) {
|
|
5057
4803
|
return 0;
|
|
@@ -5069,9 +4815,6 @@ class AXFileUploadService {
|
|
|
5069
4815
|
audio.src = URL.createObjectURL(file);
|
|
5070
4816
|
});
|
|
5071
4817
|
}
|
|
5072
|
-
/**
|
|
5073
|
-
* Format file size
|
|
5074
|
-
*/
|
|
5075
4818
|
formatFileSize(bytes) {
|
|
5076
4819
|
if (bytes < 1024)
|
|
5077
4820
|
return `${bytes} B`;
|
|
@@ -5081,17 +4824,11 @@ class AXFileUploadService {
|
|
|
5081
4824
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
5082
4825
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
5083
4826
|
}
|
|
5084
|
-
/**
|
|
5085
|
-
* Format duration (seconds to mm:ss)
|
|
5086
|
-
*/
|
|
5087
4827
|
formatDuration(seconds) {
|
|
5088
4828
|
const mins = Math.floor(seconds / 60);
|
|
5089
4829
|
const secs = Math.floor(seconds % 60);
|
|
5090
4830
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
5091
4831
|
}
|
|
5092
|
-
/**
|
|
5093
|
-
* Validate file type
|
|
5094
|
-
*/
|
|
5095
4832
|
validateFileType(file, allowedTypes) {
|
|
5096
4833
|
return allowedTypes.some((type) => {
|
|
5097
4834
|
if (type.endsWith('/*')) {
|
|
@@ -5101,34 +4838,19 @@ class AXFileUploadService {
|
|
|
5101
4838
|
return file.type === type;
|
|
5102
4839
|
});
|
|
5103
4840
|
}
|
|
5104
|
-
/**
|
|
5105
|
-
* Validate file size
|
|
5106
|
-
*/
|
|
5107
4841
|
validateFileSize(file, maxSizeInMB) {
|
|
5108
4842
|
const maxSizeInBytes = maxSizeInMB * 1024 * 1024;
|
|
5109
4843
|
return file.size <= maxSizeInBytes;
|
|
5110
4844
|
}
|
|
5111
|
-
/**
|
|
5112
|
-
* Filter files by type
|
|
5113
|
-
*/
|
|
5114
4845
|
filterFilesByType(files, allowedTypes) {
|
|
5115
4846
|
return files.filter((file) => this.validateFileType(file, allowedTypes));
|
|
5116
4847
|
}
|
|
5117
|
-
/**
|
|
5118
|
-
* Validate file type against conversation config
|
|
5119
|
-
*/
|
|
5120
4848
|
isFileTypeAllowed(file) {
|
|
5121
4849
|
return this.validateFileType(file, this.config.allowedFileTypes);
|
|
5122
4850
|
}
|
|
5123
|
-
/**
|
|
5124
|
-
* Validate file size against conversation config
|
|
5125
|
-
*/
|
|
5126
4851
|
isFileSizeAllowed(file) {
|
|
5127
4852
|
return file.size <= this.config.maxFileSize;
|
|
5128
4853
|
}
|
|
5129
|
-
/**
|
|
5130
|
-
* Validate a set of files using conversation config
|
|
5131
|
-
*/
|
|
5132
4854
|
validateFilesWithConfig(files) {
|
|
5133
4855
|
const accepted = [];
|
|
5134
4856
|
const rejected = [];
|
|
@@ -11893,10 +11615,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.3", ngImpor
|
|
|
11893
11615
|
type: Injectable
|
|
11894
11616
|
}], ctorParameters: () => [] });
|
|
11895
11617
|
|
|
11896
|
-
/**
|
|
11897
|
-
* Registry Service
|
|
11898
|
-
* Central service for managing all registries and extensions
|
|
11899
|
-
*/
|
|
11618
|
+
/** Unified access to conversation2 registries (renderers, actions, tabs). */
|
|
11900
11619
|
/**
|
|
11901
11620
|
* Central Registry Service
|
|
11902
11621
|
* Provides unified access to all registries
|
|
@@ -11965,12 +11684,12 @@ class AXConversationService {
|
|
|
11965
11684
|
}
|
|
11966
11685
|
constructor() {
|
|
11967
11686
|
this.config = inject(CONVERSATION_CONFIG);
|
|
11687
|
+
this.state = new ConversationState(this.config);
|
|
11968
11688
|
// New separated APIs
|
|
11969
11689
|
this.userApi = inject(AXUserApi);
|
|
11970
11690
|
this.conversationApi = inject(AXConversationApi);
|
|
11971
11691
|
this.messageApi = inject(AXMessageApi);
|
|
11972
11692
|
this.realtimeApi = inject(AXRealtimeApi, { optional: true });
|
|
11973
|
-
this.store = inject(AXConversationStoreService);
|
|
11974
11693
|
this.errorHandler = inject(AXErrorHandlerService);
|
|
11975
11694
|
this.dialogService = inject(AXDialogService);
|
|
11976
11695
|
this.popupService = inject(AXPopupService);
|
|
@@ -11992,18 +11711,18 @@ class AXConversationService {
|
|
|
11992
11711
|
this._typingIndicator$ = new Subject();
|
|
11993
11712
|
this._presenceUpdate$ = new Subject();
|
|
11994
11713
|
/** All conversations */
|
|
11995
|
-
this.conversations = this.
|
|
11714
|
+
this.conversations = this.state.conversations;
|
|
11996
11715
|
/** Active conversation ID */
|
|
11997
11716
|
this.activeConversationId = this._activeConversationId.asReadonly();
|
|
11998
11717
|
/** Active conversation */
|
|
11999
11718
|
this.activeConversation = computed(() => {
|
|
12000
11719
|
const id = this._activeConversationId();
|
|
12001
|
-
return id ? this.
|
|
11720
|
+
return id ? this.state.getConversation(id) : null;
|
|
12002
11721
|
}, ...(ngDevMode ? [{ debugName: "activeConversation" }] : []));
|
|
12003
11722
|
/** Messages for active conversation */
|
|
12004
11723
|
this.activeMessages = computed(() => {
|
|
12005
11724
|
const convId = this._activeConversationId();
|
|
12006
|
-
return convId ? this.
|
|
11725
|
+
return convId ? this.state.getConversationMessages(convId) : [];
|
|
12007
11726
|
}, ...(ngDevMode ? [{ debugName: "activeMessages" }] : []));
|
|
12008
11727
|
/** Loading state */
|
|
12009
11728
|
this.loading = this._loading.asReadonly();
|
|
@@ -12039,8 +11758,6 @@ class AXConversationService {
|
|
|
12039
11758
|
*/
|
|
12040
11759
|
async initializeService() {
|
|
12041
11760
|
try {
|
|
12042
|
-
// Initialize store
|
|
12043
|
-
await this.store.initialize();
|
|
12044
11761
|
// Connect to real-time API (optional)
|
|
12045
11762
|
if (this.realtimeApi) {
|
|
12046
11763
|
await this.realtimeApi.connect();
|
|
@@ -12137,7 +11854,7 @@ class AXConversationService {
|
|
|
12137
11854
|
pageSize: this.config.conversationPageSize,
|
|
12138
11855
|
}, undefined);
|
|
12139
11856
|
// Store conversations
|
|
12140
|
-
this.
|
|
11857
|
+
this.state.setConversations(result.items);
|
|
12141
11858
|
}
|
|
12142
11859
|
catch (error) {
|
|
12143
11860
|
const handledError = this.errorHandler.handle(error, 'loadConversations');
|
|
@@ -12159,7 +11876,7 @@ class AXConversationService {
|
|
|
12159
11876
|
}, undefined);
|
|
12160
11877
|
// Append new conversations to existing ones
|
|
12161
11878
|
if (result.items.length > 0) {
|
|
12162
|
-
this.
|
|
11879
|
+
this.state.addConversations(result.items);
|
|
12163
11880
|
}
|
|
12164
11881
|
return result.hasMore;
|
|
12165
11882
|
}
|
|
@@ -12214,7 +11931,7 @@ class AXConversationService {
|
|
|
12214
11931
|
page,
|
|
12215
11932
|
pageSize: this.config.messagePageSize,
|
|
12216
11933
|
});
|
|
12217
|
-
this.
|
|
11934
|
+
this.state.addMessages(result.items);
|
|
12218
11935
|
return result.items;
|
|
12219
11936
|
}
|
|
12220
11937
|
catch (error) {
|
|
@@ -12252,12 +11969,12 @@ class AXConversationService {
|
|
|
12252
11969
|
metadata: command.metadata,
|
|
12253
11970
|
};
|
|
12254
11971
|
// Add to store immediately (optimistic update)
|
|
12255
|
-
this.
|
|
11972
|
+
this.state.addMessage(tempMessage);
|
|
12256
11973
|
// Send to server
|
|
12257
11974
|
const sentMessage = await this.messageApi.sendMessage(command);
|
|
12258
11975
|
// Replace temporary message with real one
|
|
12259
|
-
this.
|
|
12260
|
-
this.
|
|
11976
|
+
this.state.deleteMessage(tempMessageId);
|
|
11977
|
+
this.state.addMessage(sentMessage);
|
|
12261
11978
|
// Update conversation's last message
|
|
12262
11979
|
this.updateConversationLastMessage(sentMessage);
|
|
12263
11980
|
// Emit forward count update if this is a forwarded message
|
|
@@ -12270,7 +11987,7 @@ class AXConversationService {
|
|
|
12270
11987
|
}
|
|
12271
11988
|
catch (error) {
|
|
12272
11989
|
// Mark temp message as failed
|
|
12273
|
-
this.
|
|
11990
|
+
this.state.updateMessage(tempMessageId, { status: 'failed' });
|
|
12274
11991
|
this.errorHandler.handle(error, 'sendMessage', {
|
|
12275
11992
|
conversationId: command.conversationId,
|
|
12276
11993
|
type: command.type,
|
|
@@ -12282,12 +11999,12 @@ class AXConversationService {
|
|
|
12282
11999
|
* Retry a failed message
|
|
12283
12000
|
*/
|
|
12284
12001
|
async retryFailedMessage(messageId) {
|
|
12285
|
-
const message = this.
|
|
12002
|
+
const message = this.state.getMessage(messageId);
|
|
12286
12003
|
if (!message || message.status !== 'failed') {
|
|
12287
12004
|
return;
|
|
12288
12005
|
}
|
|
12289
12006
|
// Update status to sending
|
|
12290
|
-
this.
|
|
12007
|
+
this.state.updateMessage(messageId, { status: 'sending' });
|
|
12291
12008
|
try {
|
|
12292
12009
|
// Recreate command from message
|
|
12293
12010
|
const command = {
|
|
@@ -12302,14 +12019,14 @@ class AXConversationService {
|
|
|
12302
12019
|
// Send to server
|
|
12303
12020
|
const sentMessage = await this.messageApi.sendMessage(command);
|
|
12304
12021
|
// Replace with real message
|
|
12305
|
-
this.
|
|
12306
|
-
this.
|
|
12022
|
+
this.state.deleteMessage(messageId);
|
|
12023
|
+
this.state.addMessage(sentMessage);
|
|
12307
12024
|
// Update conversation's last message
|
|
12308
12025
|
this.updateConversationLastMessage(sentMessage);
|
|
12309
12026
|
}
|
|
12310
12027
|
catch (error) {
|
|
12311
12028
|
// Mark as failed again
|
|
12312
|
-
this.
|
|
12029
|
+
this.state.updateMessage(messageId, { status: 'failed' });
|
|
12313
12030
|
this.errorHandler.handle(error, 'retryFailedMessage', { messageId });
|
|
12314
12031
|
throw error;
|
|
12315
12032
|
}
|
|
@@ -12320,10 +12037,10 @@ class AXConversationService {
|
|
|
12320
12037
|
*/
|
|
12321
12038
|
async editMessage(messageId, payload) {
|
|
12322
12039
|
// Store original message for rollback
|
|
12323
|
-
const originalMessage = this.
|
|
12040
|
+
const originalMessage = this.state.getMessage(messageId);
|
|
12324
12041
|
try {
|
|
12325
12042
|
// Optimistic update
|
|
12326
|
-
this.
|
|
12043
|
+
this.state.updateMessage(messageId, {
|
|
12327
12044
|
payload,
|
|
12328
12045
|
editedAt: new Date(),
|
|
12329
12046
|
});
|
|
@@ -12333,7 +12050,7 @@ class AXConversationService {
|
|
|
12333
12050
|
catch (error) {
|
|
12334
12051
|
// Rollback on failure
|
|
12335
12052
|
if (originalMessage) {
|
|
12336
|
-
this.
|
|
12053
|
+
this.state.updateMessage(messageId, {
|
|
12337
12054
|
payload: originalMessage.payload,
|
|
12338
12055
|
editedAt: originalMessage.editedAt,
|
|
12339
12056
|
});
|
|
@@ -12356,17 +12073,17 @@ class AXConversationService {
|
|
|
12356
12073
|
return;
|
|
12357
12074
|
}
|
|
12358
12075
|
// Store message for rollback
|
|
12359
|
-
const message = this.
|
|
12076
|
+
const message = this.state.getMessage(messageId);
|
|
12360
12077
|
try {
|
|
12361
12078
|
// Optimistic delete
|
|
12362
|
-
this.
|
|
12079
|
+
this.state.deleteMessage(messageId);
|
|
12363
12080
|
// Sync with server
|
|
12364
12081
|
await this.messageApi.deleteMessage(messageId, forEveryone);
|
|
12365
12082
|
}
|
|
12366
12083
|
catch (error) {
|
|
12367
12084
|
// Rollback on failure
|
|
12368
12085
|
if (message) {
|
|
12369
|
-
this.
|
|
12086
|
+
this.state.addMessage(message);
|
|
12370
12087
|
}
|
|
12371
12088
|
this.errorHandler.handle(error, 'deleteMessage', { messageId, forEveryone });
|
|
12372
12089
|
throw error;
|
|
@@ -12405,25 +12122,25 @@ class AXConversationService {
|
|
|
12405
12122
|
* Updates message status locally and syncs with server
|
|
12406
12123
|
*/
|
|
12407
12124
|
async markMessageAsRead(messageId) {
|
|
12408
|
-
const message = this.
|
|
12125
|
+
const message = this.state.getMessage(messageId);
|
|
12409
12126
|
if (!message || message.status === 'read')
|
|
12410
12127
|
return;
|
|
12411
12128
|
try {
|
|
12412
12129
|
// Optimistic update
|
|
12413
|
-
this.
|
|
12130
|
+
this.state.updateMessage(messageId, { status: 'read' });
|
|
12414
12131
|
// Sync with server
|
|
12415
12132
|
await this.messageApi.markAsRead(message.conversationId, [messageId]);
|
|
12416
12133
|
// Update conversation unread count
|
|
12417
|
-
const conversation = this.
|
|
12134
|
+
const conversation = this.state.getConversation(message.conversationId);
|
|
12418
12135
|
if (conversation && conversation.unreadCount > 0) {
|
|
12419
|
-
this.
|
|
12136
|
+
this.state.updateConversation(message.conversationId, {
|
|
12420
12137
|
unreadCount: Math.max(0, conversation.unreadCount - 1),
|
|
12421
12138
|
});
|
|
12422
12139
|
}
|
|
12423
12140
|
}
|
|
12424
12141
|
catch (error) {
|
|
12425
12142
|
// Rollback on failure
|
|
12426
|
-
this.
|
|
12143
|
+
this.state.updateMessage(messageId, { status: message.status });
|
|
12427
12144
|
this.errorHandler.handle(error, 'markMessageAsRead', { messageId });
|
|
12428
12145
|
// Don't throw - marking as read failure shouldn't break the app
|
|
12429
12146
|
}
|
|
@@ -12433,20 +12150,20 @@ class AXConversationService {
|
|
|
12433
12150
|
* Marks messages locally and syncs with server
|
|
12434
12151
|
*/
|
|
12435
12152
|
async markAsRead(conversationId) {
|
|
12436
|
-
const messages = this.
|
|
12153
|
+
const messages = this.state.getConversationMessages(conversationId);
|
|
12437
12154
|
const currentId = this._currentUser()?.id ?? 'current-user';
|
|
12438
12155
|
const unreadMessageIds = messages.filter((m) => m.status !== 'read' && m.senderId !== currentId).map((m) => m.id);
|
|
12439
12156
|
if (unreadMessageIds.length === 0)
|
|
12440
12157
|
return;
|
|
12441
12158
|
try {
|
|
12442
12159
|
// Optimistic update
|
|
12443
|
-
this.
|
|
12160
|
+
this.state.resetUnreadCount(conversationId);
|
|
12444
12161
|
// Sync with server
|
|
12445
12162
|
await this.messageApi.markAsRead(conversationId, unreadMessageIds);
|
|
12446
12163
|
}
|
|
12447
12164
|
catch (error) {
|
|
12448
12165
|
// Rollback on failure
|
|
12449
|
-
this.
|
|
12166
|
+
this.state.updateConversation(conversationId, {
|
|
12450
12167
|
unreadCount: unreadMessageIds.length,
|
|
12451
12168
|
});
|
|
12452
12169
|
this.errorHandler.handle(error, 'markAsRead', { conversationId });
|
|
@@ -12483,21 +12200,21 @@ class AXConversationService {
|
|
|
12483
12200
|
* Handle new message received
|
|
12484
12201
|
*/
|
|
12485
12202
|
handleNewMessage(message) {
|
|
12486
|
-
this.
|
|
12487
|
-
this.
|
|
12203
|
+
this.state.addMessage(message);
|
|
12204
|
+
this.state.updateLastMessage(message);
|
|
12488
12205
|
// Increment unread count for messages from other users
|
|
12489
12206
|
// The intersection observer will mark them as read when they become visible
|
|
12490
12207
|
const currentId = this._currentUser()?.id ?? 'current-user';
|
|
12491
12208
|
const isFromOtherUser = message.senderId !== currentId;
|
|
12492
12209
|
if (isFromOtherUser) {
|
|
12493
|
-
this.
|
|
12210
|
+
this.state.incrementUnreadCount(message.conversationId);
|
|
12494
12211
|
}
|
|
12495
12212
|
}
|
|
12496
12213
|
/**
|
|
12497
12214
|
* Handle message update
|
|
12498
12215
|
*/
|
|
12499
12216
|
handleMessageUpdate(message) {
|
|
12500
|
-
this.
|
|
12217
|
+
this.state.updateMessage(message.id, message);
|
|
12501
12218
|
}
|
|
12502
12219
|
/**
|
|
12503
12220
|
* Handle message count update
|
|
@@ -12517,7 +12234,7 @@ class AXConversationService {
|
|
|
12517
12234
|
* Handle message deletion
|
|
12518
12235
|
*/
|
|
12519
12236
|
handleMessageDeletion(messageId) {
|
|
12520
|
-
this.
|
|
12237
|
+
this.state.deleteMessage(messageId);
|
|
12521
12238
|
}
|
|
12522
12239
|
/**
|
|
12523
12240
|
* Handle typing indicator
|
|
@@ -12528,30 +12245,30 @@ class AXConversationService {
|
|
|
12528
12245
|
if (currentUser && indicator.userId === currentUser.id) {
|
|
12529
12246
|
return;
|
|
12530
12247
|
}
|
|
12531
|
-
this.
|
|
12248
|
+
this.state.updateTypingIndicator(indicator.conversationId, indicator.userId, true);
|
|
12532
12249
|
// Clear typing indicator after timeout
|
|
12533
12250
|
setTimeout(() => {
|
|
12534
|
-
this.
|
|
12251
|
+
this.state.updateTypingIndicator(indicator.conversationId, indicator.userId, false);
|
|
12535
12252
|
}, this.config.typingIndicatorTimeout ?? 3000);
|
|
12536
12253
|
}
|
|
12537
12254
|
/**
|
|
12538
12255
|
* Handle presence update
|
|
12539
12256
|
*/
|
|
12540
12257
|
handlePresenceUpdate(update) {
|
|
12541
|
-
this.
|
|
12258
|
+
this.state.updateParticipantPresence(update.userId, update.status, update.lastSeen);
|
|
12542
12259
|
}
|
|
12543
12260
|
/**
|
|
12544
12261
|
* Handle conversation update
|
|
12545
12262
|
* Updates conversation metadata including unread count, last message, etc.
|
|
12546
12263
|
*/
|
|
12547
12264
|
handleConversationUpdate(conversation) {
|
|
12548
|
-
this.
|
|
12265
|
+
this.state.setConversation(conversation);
|
|
12549
12266
|
}
|
|
12550
12267
|
/**
|
|
12551
12268
|
* Update conversation's last message
|
|
12552
12269
|
*/
|
|
12553
12270
|
updateConversationLastMessage(message) {
|
|
12554
|
-
this.
|
|
12271
|
+
this.state.updateLastMessage(message);
|
|
12555
12272
|
}
|
|
12556
12273
|
// =====================
|
|
12557
12274
|
// Conversation Update APIs
|
|
@@ -12563,7 +12280,7 @@ class AXConversationService {
|
|
|
12563
12280
|
*/
|
|
12564
12281
|
async updateConversation(conversationId, updates) {
|
|
12565
12282
|
try {
|
|
12566
|
-
this.
|
|
12283
|
+
this.state.updateConversation(conversationId, updates);
|
|
12567
12284
|
}
|
|
12568
12285
|
catch (error) {
|
|
12569
12286
|
this.errorHandler.handle(error, 'updateConversation', { conversationId });
|
|
@@ -12577,7 +12294,7 @@ class AXConversationService {
|
|
|
12577
12294
|
*/
|
|
12578
12295
|
async updateConversationSettings(conversationId, settings) {
|
|
12579
12296
|
try {
|
|
12580
|
-
this.
|
|
12297
|
+
this.state.updateSettings(conversationId, settings);
|
|
12581
12298
|
}
|
|
12582
12299
|
catch (error) {
|
|
12583
12300
|
this.errorHandler.handle(error, 'updateConversationSettings', { conversationId });
|
|
@@ -12591,7 +12308,7 @@ class AXConversationService {
|
|
|
12591
12308
|
*/
|
|
12592
12309
|
async updateConversationTitle(conversationId, title) {
|
|
12593
12310
|
try {
|
|
12594
|
-
this.
|
|
12311
|
+
this.state.updateTitle(conversationId, title);
|
|
12595
12312
|
}
|
|
12596
12313
|
catch (error) {
|
|
12597
12314
|
this.errorHandler.handle(error, 'updateConversationTitle', { conversationId, title });
|
|
@@ -12605,7 +12322,7 @@ class AXConversationService {
|
|
|
12605
12322
|
*/
|
|
12606
12323
|
async updateConversationMetadata(conversationId, metadata) {
|
|
12607
12324
|
try {
|
|
12608
|
-
this.
|
|
12325
|
+
this.state.updateMetadata(conversationId, metadata);
|
|
12609
12326
|
}
|
|
12610
12327
|
catch (error) {
|
|
12611
12328
|
this.errorHandler.handle(error, 'updateConversationMetadata', { conversationId });
|
|
@@ -12618,7 +12335,7 @@ class AXConversationService {
|
|
|
12618
12335
|
* @returns Conversation or null
|
|
12619
12336
|
*/
|
|
12620
12337
|
getConversation(conversationId) {
|
|
12621
|
-
return this.
|
|
12338
|
+
return this.state.getConversation(conversationId) || null;
|
|
12622
12339
|
}
|
|
12623
12340
|
/**
|
|
12624
12341
|
* Create a new conversation
|
|
@@ -12638,7 +12355,7 @@ class AXConversationService {
|
|
|
12638
12355
|
icon: AXConversationService.normalizeOptionalString(metadata?.['icon']),
|
|
12639
12356
|
metadata,
|
|
12640
12357
|
});
|
|
12641
|
-
this.
|
|
12358
|
+
this.state.setConversation(conversation);
|
|
12642
12359
|
return conversation;
|
|
12643
12360
|
}
|
|
12644
12361
|
catch (error) {
|
|
@@ -12667,7 +12384,7 @@ class AXConversationService {
|
|
|
12667
12384
|
async markConversationAsRead(conversationId) {
|
|
12668
12385
|
try {
|
|
12669
12386
|
await this.conversationApi.markConversationAsRead(conversationId);
|
|
12670
|
-
this.
|
|
12387
|
+
this.state.resetUnreadCount(conversationId);
|
|
12671
12388
|
}
|
|
12672
12389
|
catch (error) {
|
|
12673
12390
|
this.errorHandler.handle(error, 'markConversationAsRead', { conversationId });
|
|
@@ -12681,7 +12398,7 @@ class AXConversationService {
|
|
|
12681
12398
|
*/
|
|
12682
12399
|
async deleteConversation(conversationId) {
|
|
12683
12400
|
// Get conversation title for confirmation message
|
|
12684
|
-
const conversation = this.
|
|
12401
|
+
const conversation = this.state.getConversation(conversationId);
|
|
12685
12402
|
const conversationTitle = conversation?.title || this.translation.translateSync('@acorex:chat.fallbacks.this-conversation');
|
|
12686
12403
|
// Show confirmation dialog
|
|
12687
12404
|
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);
|
|
@@ -12695,7 +12412,7 @@ class AXConversationService {
|
|
|
12695
12412
|
if (!result)
|
|
12696
12413
|
return false;
|
|
12697
12414
|
// Delete from store
|
|
12698
|
-
this.
|
|
12415
|
+
this.state.deleteConversation(conversationId);
|
|
12699
12416
|
// If this was the active conversation, clear selection
|
|
12700
12417
|
if (this._activeConversationId() === conversationId) {
|
|
12701
12418
|
this._activeConversationId.set(null);
|
|
@@ -12760,7 +12477,7 @@ class AXConversationService {
|
|
|
12760
12477
|
async archiveConversation(conversationId) {
|
|
12761
12478
|
try {
|
|
12762
12479
|
await this.conversationApi.archiveConversation(conversationId);
|
|
12763
|
-
this.
|
|
12480
|
+
this.state.updateConversation(conversationId, { archived: true });
|
|
12764
12481
|
}
|
|
12765
12482
|
catch (error) {
|
|
12766
12483
|
this.errorHandler.handle(error, 'archiveConversation', { conversationId });
|
|
@@ -12774,7 +12491,7 @@ class AXConversationService {
|
|
|
12774
12491
|
async unarchiveConversation(conversationId) {
|
|
12775
12492
|
try {
|
|
12776
12493
|
await this.conversationApi.unarchiveConversation(conversationId);
|
|
12777
|
-
this.
|
|
12494
|
+
this.state.updateConversation(conversationId, { archived: false });
|
|
12778
12495
|
}
|
|
12779
12496
|
catch (error) {
|
|
12780
12497
|
this.errorHandler.handle(error, 'unarchiveConversation', { conversationId });
|
|
@@ -12788,7 +12505,7 @@ class AXConversationService {
|
|
|
12788
12505
|
async pinConversation(conversationId) {
|
|
12789
12506
|
try {
|
|
12790
12507
|
await this.conversationApi.pinConversation(conversationId);
|
|
12791
|
-
this.
|
|
12508
|
+
this.state.updateConversation(conversationId, { pinned: true });
|
|
12792
12509
|
}
|
|
12793
12510
|
catch (error) {
|
|
12794
12511
|
this.errorHandler.handle(error, 'pinConversation', { conversationId });
|
|
@@ -12802,7 +12519,7 @@ class AXConversationService {
|
|
|
12802
12519
|
async unpinConversation(conversationId) {
|
|
12803
12520
|
try {
|
|
12804
12521
|
await this.conversationApi.unpinConversation(conversationId);
|
|
12805
|
-
this.
|
|
12522
|
+
this.state.updateConversation(conversationId, { pinned: false });
|
|
12806
12523
|
}
|
|
12807
12524
|
catch (error) {
|
|
12808
12525
|
this.errorHandler.handle(error, 'unpinConversation', { conversationId });
|
|
@@ -12818,7 +12535,7 @@ class AXConversationService {
|
|
|
12818
12535
|
try {
|
|
12819
12536
|
await this.conversationApi.muteConversation(conversationId, duration);
|
|
12820
12537
|
const mutedUntil = duration ? new Date(Date.now() + duration) : undefined;
|
|
12821
|
-
this.
|
|
12538
|
+
this.state.updateSettings(conversationId, { mutedUntil, notifications: false });
|
|
12822
12539
|
}
|
|
12823
12540
|
catch (error) {
|
|
12824
12541
|
this.errorHandler.handle(error, 'muteConversation', { conversationId, duration });
|
|
@@ -12832,7 +12549,7 @@ class AXConversationService {
|
|
|
12832
12549
|
async unmuteConversation(conversationId) {
|
|
12833
12550
|
try {
|
|
12834
12551
|
await this.conversationApi.unmuteConversation(conversationId);
|
|
12835
|
-
this.
|
|
12552
|
+
this.state.updateSettings(conversationId, { mutedUntil: undefined, notifications: true });
|
|
12836
12553
|
}
|
|
12837
12554
|
catch (error) {
|
|
12838
12555
|
this.errorHandler.handle(error, 'unmuteConversation', { conversationId });
|
|
@@ -12847,7 +12564,7 @@ class AXConversationService {
|
|
|
12847
12564
|
async addParticipants(conversationId, userIds) {
|
|
12848
12565
|
try {
|
|
12849
12566
|
const updatedConversation = await this.conversationApi.addParticipants(conversationId, userIds);
|
|
12850
|
-
this.
|
|
12567
|
+
this.state.setConversation(updatedConversation);
|
|
12851
12568
|
}
|
|
12852
12569
|
catch (error) {
|
|
12853
12570
|
this.errorHandler.handle(error, 'addParticipants', { conversationId, userIds });
|
|
@@ -12862,7 +12579,7 @@ class AXConversationService {
|
|
|
12862
12579
|
async removeParticipant(conversationId, userId) {
|
|
12863
12580
|
try {
|
|
12864
12581
|
const updatedConversation = await this.conversationApi.removeParticipant(conversationId, userId);
|
|
12865
|
-
this.
|
|
12582
|
+
this.state.setConversation(updatedConversation);
|
|
12866
12583
|
}
|
|
12867
12584
|
catch (error) {
|
|
12868
12585
|
this.errorHandler.handle(error, 'removeParticipant', { conversationId, userId });
|
|
@@ -12876,7 +12593,7 @@ class AXConversationService {
|
|
|
12876
12593
|
async leaveConversation(conversationId) {
|
|
12877
12594
|
try {
|
|
12878
12595
|
await this.conversationApi.leaveConversation(conversationId);
|
|
12879
|
-
this.
|
|
12596
|
+
this.state.deleteConversation(conversationId);
|
|
12880
12597
|
if (this._activeConversationId() === conversationId) {
|
|
12881
12598
|
this._activeConversationId.set(null);
|
|
12882
12599
|
}
|
|
@@ -12894,7 +12611,7 @@ class AXConversationService {
|
|
|
12894
12611
|
async saveDraft(conversationId, draft) {
|
|
12895
12612
|
try {
|
|
12896
12613
|
await this.conversationApi.saveDraft(conversationId, draft);
|
|
12897
|
-
this.
|
|
12614
|
+
this.state.updateConversation(conversationId, { draft });
|
|
12898
12615
|
}
|
|
12899
12616
|
catch (error) {
|
|
12900
12617
|
this.errorHandler.handle(error, 'saveDraft', { conversationId });
|
|
@@ -12908,7 +12625,7 @@ class AXConversationService {
|
|
|
12908
12625
|
async clearDraft(conversationId) {
|
|
12909
12626
|
try {
|
|
12910
12627
|
await this.conversationApi.clearDraft(conversationId);
|
|
12911
|
-
this.
|
|
12628
|
+
this.state.updateConversation(conversationId, { draft: undefined });
|
|
12912
12629
|
}
|
|
12913
12630
|
catch (error) {
|
|
12914
12631
|
this.errorHandler.handle(error, 'clearDraft', { conversationId });
|
|
@@ -12926,7 +12643,7 @@ class AXConversationService {
|
|
|
12926
12643
|
async pinMessage(conversationId, messageId) {
|
|
12927
12644
|
try {
|
|
12928
12645
|
await this.messageApi.pinMessage(conversationId, messageId);
|
|
12929
|
-
this.
|
|
12646
|
+
this.state.updateMessage(messageId, { pinned: true });
|
|
12930
12647
|
}
|
|
12931
12648
|
catch (error) {
|
|
12932
12649
|
this.errorHandler.handle(error, 'pinMessage', { conversationId, messageId });
|
|
@@ -12941,7 +12658,7 @@ class AXConversationService {
|
|
|
12941
12658
|
async unpinMessage(conversationId, messageId) {
|
|
12942
12659
|
try {
|
|
12943
12660
|
await this.messageApi.unpinMessage(conversationId, messageId);
|
|
12944
|
-
this.
|
|
12661
|
+
this.state.updateMessage(messageId, { pinned: false });
|
|
12945
12662
|
}
|
|
12946
12663
|
catch (error) {
|
|
12947
12664
|
this.errorHandler.handle(error, 'unpinMessage', { conversationId, messageId });
|
|
@@ -18608,7 +18325,7 @@ function createProviders(options, includeServices) {
|
|
|
18608
18325
|
if (realtimeApi) {
|
|
18609
18326
|
providers.push({ provide: AXRealtimeApi, useClass: realtimeApi });
|
|
18610
18327
|
}
|
|
18611
|
-
providers.push(
|
|
18328
|
+
providers.push(AXConversationService, AXComposerService, AXInfoBarService, AXMessageListService, AXSidebarService);
|
|
18612
18329
|
}
|
|
18613
18330
|
if (config) {
|
|
18614
18331
|
providers.push({ provide: CONVERSATION_CONFIG, useValue: mergeWithDefaults(config) });
|
|
@@ -19030,5 +18747,5 @@ function getErrorMessage(code, params) {
|
|
|
19030
18747
|
* Generated bundle index. Do not edit.
|
|
19031
18748
|
*/
|
|
19032
18749
|
|
|
19033
|
-
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,
|
|
18750
|
+
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 };
|
|
19034
18751
|
//# sourceMappingURL=acorex-components-conversation2.mjs.map
|