valtech-components 4.0.228 → 4.0.230

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.
Files changed (28) hide show
  1. package/esm2022/lib/components/molecules/chat-composer/chat-composer.component.mjs +431 -0
  2. package/esm2022/lib/components/molecules/chat-input/chat-input.component.mjs +1 -9
  3. package/esm2022/lib/components/molecules/conversation-list-item/conversation-list-item.component.mjs +108 -0
  4. package/esm2022/lib/components/molecules/message-bubble/message-bubble.component.mjs +274 -191
  5. package/esm2022/lib/components/molecules/typing-indicator/typing-indicator.component.mjs +11 -12
  6. package/esm2022/lib/components/organisms/chat-window/chat-window.component.mjs +213 -203
  7. package/esm2022/lib/components/organisms/thread-panel/thread-panel.component.mjs +13 -11
  8. package/esm2022/lib/services/chat/conversation.service.mjs +37 -14
  9. package/esm2022/lib/services/chat/types.mjs +1 -1
  10. package/esm2022/lib/services/i18n/default-content.mjs +5 -1
  11. package/esm2022/lib/shared/utils/datetime.mjs +54 -1
  12. package/esm2022/lib/version.mjs +2 -2
  13. package/esm2022/public-api.mjs +4 -2
  14. package/fesm2022/valtech-components.mjs +1120 -429
  15. package/fesm2022/valtech-components.mjs.map +1 -1
  16. package/lib/components/molecules/chat-composer/chat-composer.component.d.ts +75 -0
  17. package/lib/components/molecules/conversation-list-item/conversation-list-item.component.d.ts +30 -0
  18. package/lib/components/molecules/message-bubble/message-bubble.component.d.ts +34 -32
  19. package/lib/components/molecules/typing-indicator/typing-indicator.component.d.ts +5 -4
  20. package/lib/components/organisms/chat-window/chat-window.component.d.ts +52 -56
  21. package/lib/components/organisms/thread-panel/thread-panel.component.d.ts +4 -1
  22. package/lib/services/chat/conversation.service.d.ts +12 -5
  23. package/lib/services/chat/types.d.ts +86 -52
  24. package/lib/shared/utils/datetime.d.ts +19 -0
  25. package/lib/version.d.ts +1 -1
  26. package/package.json +1 -1
  27. package/public-api.d.ts +3 -1
  28. package/src/lib/services/firebase/firebase-messaging-sw.js +70 -125
@@ -25,7 +25,7 @@ import { getApps, getApp, initializeApp as initializeApp$1 } from 'firebase/app'
25
25
  import * as i2$1 from '@ionic/angular/standalone';
26
26
  import { AlertController, IonAvatar, IonCard, IonIcon, IonButton, IonSpinner, IonText, IonModal, IonHeader, IonToolbar, IonContent, IonButtons, IonTitle, IonProgressBar, IonSkeletonText, IonFab, IonFabButton, IonFabList, IonLabel, IonCardContent, IonCardHeader, IonCardTitle, IonCardSubtitle, IonCheckbox, IonRadio, IonRadioGroup, IonTextarea, IonDatetime, IonSelect, IonSelectOption, IonPopover, IonList, IonItem, IonInput, IonChip, IonRange, IonSearchbar, IonSegment, IonSegmentButton, IonToggle, IonRippleEffect, IonAccordion, IonAccordionGroup, IonTabBar, IonTabButton, IonBadge, IonBreadcrumb, IonBreadcrumbs, IonInfiniteScroll, IonInfiniteScrollContent, IonNote, IonDatetimeButton, ToastController as ToastController$1, IonCol, IonRow, IonRefresher, IonRefresherContent, IonMenuButton, IonFooter, IonListHeader, IonGrid, MenuController, IonMenu, IonMenuToggle, IonSplitPane } from '@ionic/angular/standalone';
27
27
  import { addIcons } from 'ionicons';
28
- import { addOutline, addCircleOutline, alertOutline, alertCircleOutline, arrowBackOutline, arrowForwardOutline, arrowDownOutline, settings, settingsOutline, checkmarkCircleOutline, ellipsisHorizontalOutline, notifications, notificationsOutline, openOutline, closeOutline, chatbubblesOutline, shareOutline, heart, heartOutline, home, homeOutline, eyeOffOutline, eyeOutline, scanOutline, chevronDownOutline, chevronForwardOutline, checkmarkOutline, clipboardOutline, copyOutline, filterOutline, locationOutline, calendarOutline, businessOutline, logoTwitter, logoInstagram, logoLinkedin, logoYoutube, logoTiktok, logoFacebook, logoGoogle, createOutline, trashOutline, playOutline, peopleOutline, phonePortraitOutline, refreshOutline, documentTextOutline, lockClosedOutline, cloudUploadOutline, textOutline, informationCircleOutline, logoNpm, removeOutline, optionsOutline, personOutline, shieldCheckmarkOutline, keyOutline, desktopOutline, logOutOutline, cloudDownloadOutline, cloudOutline, gitBranchOutline, layersOutline, serverOutline, warningOutline, bulbOutline, lockOpenOutline, diamondOutline, newspaperOutline, add, close, share, create, trash, star, camera, mic, send, downloadOutline, chevronDown, language, globeOutline, checkmark, list, grid, apps, menu, search, person, helpCircle, informationCircle, documentText, mail, calendar, folder, chevronForward, ellipsisHorizontal, chevronBack, playBack, playForward, ellipse, starOutline, starHalf, heartHalf, checkmarkCircle, timeOutline, flag, trendingUp, trendingDown, remove, analytics, people, cash, cart, eye, chatbubbleOutline, thumbsUpOutline, thumbsUp, happyOutline, happy, sadOutline, sad, chevronUp, pin, pencil, callOutline, logoWhatsapp, paperPlaneOutline, mailOutline, chevronDownCircleOutline, closeCircle, alertCircle, logoApple, logoMicrosoft, linkOutline, unlinkOutline, cloudOfflineOutline, documentOutline, attachOutline, cameraOutline, closeCircleOutline, imageOutline, printOutline, chevronBackOutline, sendOutline, chatbubbleEllipsesOutline, swapVerticalOutline, chevronUpOutline, searchOutline, cartOutline, chatbubble, compass, compassOutline, gridOutline, listOutline, folderOutline, documents, documentsOutline, statsChart, statsChartOutline, laptopOutline, swapHorizontalOutline, codeSlashOutline, notificationsOffOutline, alarmOutline, chatboxEllipsesOutline, checkmarkDoneOutline, cogOutline, shareSocialOutline, helpCircleOutline, menuOutline, stopCircleOutline, videocamOffOutline, checkmarkDone, returnDownBackOutline } from 'ionicons/icons';
28
+ import { addOutline, addCircleOutline, alertOutline, alertCircleOutline, arrowBackOutline, arrowForwardOutline, arrowDownOutline, settings, settingsOutline, checkmarkCircleOutline, ellipsisHorizontalOutline, notifications, notificationsOutline, openOutline, closeOutline, chatbubblesOutline, shareOutline, heart, heartOutline, home, homeOutline, eyeOffOutline, eyeOutline, scanOutline, chevronDownOutline, chevronForwardOutline, checkmarkOutline, clipboardOutline, copyOutline, filterOutline, locationOutline, calendarOutline, businessOutline, logoTwitter, logoInstagram, logoLinkedin, logoYoutube, logoTiktok, logoFacebook, logoGoogle, createOutline, trashOutline, playOutline, peopleOutline, phonePortraitOutline, refreshOutline, documentTextOutline, lockClosedOutline, cloudUploadOutline, textOutline, informationCircleOutline, logoNpm, removeOutline, optionsOutline, personOutline, shieldCheckmarkOutline, keyOutline, desktopOutline, logOutOutline, cloudDownloadOutline, cloudOutline, gitBranchOutline, layersOutline, serverOutline, warningOutline, bulbOutline, lockOpenOutline, diamondOutline, newspaperOutline, add, close, share, create, trash, star, camera, mic, send, downloadOutline, chevronDown, language, globeOutline, checkmark, list, grid, apps, menu, search, person, helpCircle, informationCircle, documentText, mail, calendar, folder, chevronForward, ellipsisHorizontal, chevronBack, playBack, playForward, ellipse, starOutline, starHalf, heartHalf, checkmarkCircle, timeOutline, flag, trendingUp, trendingDown, remove, analytics, people, cash, cart, eye, chatbubbleOutline, thumbsUpOutline, thumbsUp, happyOutline, happy, sadOutline, sad, chevronUp, pin, pencil, callOutline, logoWhatsapp, paperPlaneOutline, mailOutline, chevronDownCircleOutline, closeCircle, alertCircle, logoApple, logoMicrosoft, linkOutline, unlinkOutline, cloudOfflineOutline, documentOutline, attachOutline, cameraOutline, closeCircleOutline, imageOutline, printOutline, chevronBackOutline, sendOutline, chatbubbleEllipsesOutline, swapVerticalOutline, chevronUpOutline, searchOutline, cartOutline, chatbubble, compass, compassOutline, gridOutline, listOutline, folderOutline, documents, documentsOutline, statsChart, statsChartOutline, laptopOutline, swapHorizontalOutline, codeSlashOutline, notificationsOffOutline, alarmOutline, chatboxEllipsesOutline, checkmarkDoneOutline, cogOutline, shareSocialOutline, helpCircleOutline, menuOutline, stopCircleOutline, videocamOffOutline, checkmarkDone, arrowUndoOutline, micOutline, arrowUpOutline } from 'ionicons/icons';
29
29
  import { Browser } from '@capacitor/browser';
30
30
  import * as i1$6 from '@angular/platform-browser';
31
31
  import { DomSanitizer, Meta, Title } from '@angular/platform-browser';
@@ -56,7 +56,7 @@ import { BrowserMultiFormatReader } from '@zxing/browser';
56
56
  * Current version of valtech-components.
57
57
  * This is automatically updated during the publish process.
58
58
  */
59
- const VERSION = '4.0.228';
59
+ const VERSION = '4.0.230';
60
60
 
61
61
  // Control de estado de refresco (singleton a nivel de módulo)
62
62
  let isRefreshing = false;
@@ -1538,6 +1538,59 @@ function getTimeOfDayKey(date = new Date()) {
1538
1538
  return 'goodAfternoon';
1539
1539
  return 'goodEvening';
1540
1540
  }
1541
+ /** Convierte un valor de fecha (Date | ISO string | undefined) a Date válido o null. */
1542
+ function toDate(value) {
1543
+ if (!value)
1544
+ return null;
1545
+ const d = value instanceof Date ? value : new Date(value);
1546
+ return Number.isNaN(d.getTime()) ? null : d;
1547
+ }
1548
+ /** Diferencia en días-calendario entre `from` y `to` (to - from), ignorando la hora. */
1549
+ function calendarDayDiff(from, to) {
1550
+ const a = new Date(from.getFullYear(), from.getMonth(), from.getDate());
1551
+ const b = new Date(to.getFullYear(), to.getMonth(), to.getDate());
1552
+ return Math.round((b.getTime() - a.getTime()) / 86_400_000);
1553
+ }
1554
+ /** Hora corta local, ej. "14:05". */
1555
+ function formatClockTime(value, locale = 'es-CL') {
1556
+ const d = toDate(value);
1557
+ if (!d)
1558
+ return '';
1559
+ return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
1560
+ }
1561
+ /**
1562
+ * Etiqueta corta para listas de conversaciones (estilo Telegram/WhatsApp):
1563
+ * hoy → hora ("14:05"), ayer → label `yesterday`, esta semana → día de semana,
1564
+ * más viejo → fecha corta. `labels.today/yesterday` se inyectan ya traducidos.
1565
+ */
1566
+ function formatRelativeTime(value, locale = 'es-CL', labels = {}) {
1567
+ const d = toDate(value);
1568
+ if (!d)
1569
+ return '';
1570
+ const diff = calendarDayDiff(d, new Date());
1571
+ if (diff <= 0)
1572
+ return formatClockTime(d, locale);
1573
+ if (diff === 1)
1574
+ return labels.yesterday ?? 'Ayer';
1575
+ if (diff < 7)
1576
+ return d.toLocaleDateString(locale, { weekday: 'short' });
1577
+ return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: '2-digit' });
1578
+ }
1579
+ /**
1580
+ * Etiqueta para separadores de fecha dentro de un chat: "Hoy" / "Ayer" /
1581
+ * fecha larga ("12 de junio de 2026"). `labels.today/yesterday` ya traducidos.
1582
+ */
1583
+ function formatDateSeparator(value, locale = 'es-CL', labels = {}) {
1584
+ const d = toDate(value);
1585
+ if (!d)
1586
+ return '';
1587
+ const diff = calendarDayDiff(d, new Date());
1588
+ if (diff <= 0)
1589
+ return labels.today ?? 'Hoy';
1590
+ if (diff === 1)
1591
+ return labels.yesterday ?? 'Ayer';
1592
+ return d.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' });
1593
+ }
1541
1594
 
1542
1595
  /**
1543
1596
  * Servicio de internacionalización basado en Angular Signals.
@@ -7030,6 +7083,8 @@ const VALTECH_DEFAULT_CONTENT = {
7030
7083
  loading: 'Cargando...',
7031
7084
  search: 'Buscar',
7032
7085
  learnMore: 'Saber más',
7086
+ today: 'Hoy',
7087
+ yesterday: 'Ayer',
7033
7088
  // Estados
7034
7089
  success: 'Éxito',
7035
7090
  error: 'Error',
@@ -7187,6 +7242,8 @@ const VALTECH_DEFAULT_CONTENT = {
7187
7242
  loading: 'Loading...',
7188
7243
  search: 'Search',
7189
7244
  learnMore: 'Learn more',
7245
+ today: 'Today',
7246
+ yesterday: 'Yesterday',
7190
7247
  // States
7191
7248
  success: 'Success',
7192
7249
  error: 'Error',
@@ -72040,21 +72097,29 @@ class ConversationService {
72040
72097
  }
72041
72098
  /**
72042
72099
  * Crea una nueva conversacion.
72100
+ * El backend responde con un envelope { operationId, conversation } — lo
72101
+ * desenvolvemos para devolver la Conversation directa.
72043
72102
  */
72044
72103
  createConversation(req) {
72045
- return this.http.post(`${this.baseUrl}/conversations`, req);
72104
+ return this.http
72105
+ .post(`${this.baseUrl}/conversations`, req)
72106
+ .pipe(map(r => r.conversation));
72046
72107
  }
72047
72108
  /**
72048
- * Obtiene o crea una conversacion asociada a una entidad (post, request, etc.).
72109
+ * Obtiene o crea una conversacion asociada a una entidad (post, request, org, etc.).
72049
72110
  * Usa el patron upsert del backend: si ya existe una conversacion para esa entidad,
72050
72111
  * la devuelve; si no, la crea.
72112
+ *
72113
+ * El backend espera { entityRef: { type, id }, type } y responde con un envelope
72114
+ * { operationId, conversation, created }.
72051
72115
  */
72052
- getOrCreateForEntity(entityType, entityId, type = 'entity') {
72053
- return this.http.post(`${this.baseUrl}/conversations/entity-conversations`, {
72054
- entityType,
72055
- entityId,
72116
+ getOrCreateForEntity(entityType, entityId, type = 'thread') {
72117
+ return this.http
72118
+ .post(`${this.baseUrl}/entity-conversations`, {
72119
+ entityRef: { type: entityType, id: entityId },
72056
72120
  type,
72057
- });
72121
+ })
72122
+ .pipe(map(r => r.conversation));
72058
72123
  }
72059
72124
  /**
72060
72125
  * Lista las conversaciones del usuario autenticado.
@@ -72069,13 +72134,17 @@ class ConversationService {
72069
72134
  * Obtiene una conversacion por ID.
72070
72135
  */
72071
72136
  getConversation(convId) {
72072
- return this.http.get(`${this.baseUrl}/conversations/${convId}`);
72137
+ return this.http
72138
+ .get(`${this.baseUrl}/conversations/${convId}`)
72139
+ .pipe(map(r => r.conversation));
72073
72140
  }
72074
72141
  /**
72075
- * Actualiza el titulo o estado de una conversacion.
72142
+ * Actualiza el nombre o settings de una conversacion.
72076
72143
  */
72077
72144
  updateConversation(convId, req) {
72078
- return this.http.patch(`${this.baseUrl}/conversations/${convId}`, req);
72145
+ return this.http
72146
+ .patch(`${this.baseUrl}/conversations/${convId}`, req)
72147
+ .pipe(map(r => r.conversation));
72079
72148
  }
72080
72149
  /**
72081
72150
  * Abre una conversacion cerrada.
@@ -72102,10 +72171,21 @@ class ConversationService {
72102
72171
  return this.http.delete(`${this.baseUrl}/conversations/${convId}/participants/${userId}`);
72103
72172
  }
72104
72173
  /**
72105
- * Envia un mensaje a una conversacion.
72174
+ * Envia un mensaje a una conversacion, opcionalmente con adjuntos.
72175
+ * El backend espera `attachments: { url, type, name, size }[]` (key `type` = MIME),
72176
+ * mientras que el front usa `mimeType` — se mapea aqui.
72106
72177
  */
72107
- sendMessage(convId, body) {
72108
- return this.http.post(`${this.baseUrl}/conversations/${convId}/messages`, { body });
72178
+ sendMessage(convId, body, attachments) {
72179
+ const payload = { body };
72180
+ if (attachments?.length) {
72181
+ payload['attachments'] = attachments.map(a => ({
72182
+ url: a.url,
72183
+ type: a.mimeType,
72184
+ name: a.name,
72185
+ size: a.size ?? 0,
72186
+ }));
72187
+ }
72188
+ return this.http.post(`${this.baseUrl}/conversations/${convId}/messages`, payload);
72109
72189
  }
72110
72190
  /**
72111
72191
  * Elimina (marca como eliminado) un mensaje.
@@ -72274,241 +72354,323 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
72274
72354
  */
72275
72355
 
72276
72356
  addIcons({
72357
+ alertCircle,
72277
72358
  checkmark,
72278
72359
  checkmarkDone,
72360
+ createOutline,
72361
+ documentOutline,
72362
+ happyOutline,
72363
+ arrowUndoOutline,
72279
72364
  timeOutline,
72280
- alertCircle,
72281
- pencil,
72282
- trash,
72283
- returnDownBackOutline,
72365
+ trashOutline,
72284
72366
  });
72285
- const CHAT_I18N = {
72367
+ const MESSAGE_BUBBLE_I18N = {
72286
72368
  es: {
72287
- messageDeleted: '[mensaje eliminado]',
72288
- edited: '(editado)',
72289
- react: 'Reaccionar',
72290
72369
  reply: 'Responder',
72291
72370
  edit: 'Editar',
72292
72371
  delete: 'Eliminar',
72372
+ react: 'Reaccionar',
72373
+ deleted: 'Mensaje eliminado',
72374
+ edited: 'editado',
72293
72375
  },
72294
72376
  en: {
72295
- messageDeleted: '[message deleted]',
72296
- edited: '(edited)',
72297
- react: 'React',
72298
72377
  reply: 'Reply',
72299
72378
  edit: 'Edit',
72300
72379
  delete: 'Delete',
72380
+ react: 'React',
72381
+ deleted: 'Message deleted',
72382
+ edited: 'edited',
72301
72383
  },
72302
72384
  };
72303
72385
  /**
72304
- * val-message-bubble
72386
+ * val-message-bubble — burbuja de mensaje presentacional (signal inputs).
72305
72387
  *
72306
- * Burbuja de mensaje para el chat. Alineada a la derecha si isMine, a la izquierda si no.
72307
- * Muestra nombre del emisor, cuerpo, timestamp, estado de envio, reacciones y acciones.
72308
- * Si el mensaje fue eliminado muestra un texto en gris sin acciones.
72388
+ * Sin lógica de negocio: el contenedor (`val-chat-window`) decide agrupación y
72389
+ * pasa flags (showAvatar/showName/tail). Las acciones (responder/editar/borrar/
72390
+ * reaccionar) NO están siempre visibles: se revelan en hover (desktop) o al tocar
72391
+ * la burbuja (móvil), y se emiten por un único `(action)` tipado.
72309
72392
  *
72310
- * @example
72311
- * <val-message-bubble
72312
- * [props]="msg"
72313
- * (reactionClick)="onReaction($event)"
72314
- * (replyClick)="onReply($event)"
72315
- * (deleteClick)="onDelete($event)"
72316
- * />
72393
+ * Read receipts estilo WhatsApp: sending → reloj, sent → ✓, delivered/read → ✓✓
72394
+ * (read en color), failed → alerta.
72317
72395
  */
72318
72396
  class MessageBubbleComponent {
72319
72397
  constructor() {
72320
72398
  this.i18n = inject(I18nService);
72321
- this.reactionClick = new EventEmitter();
72322
- this.replyClick = new EventEmitter();
72323
- this.deleteClick = new EventEmitter();
72324
- this.editClick = new EventEmitter();
72325
- if (!this.i18n.hasNamespace('Chat')) {
72326
- this.i18n.registerDefaults('Chat', CHAT_I18N);
72327
- }
72328
- }
72329
- ngOnInit() { }
72330
- deletedText() {
72331
- this.i18n.lang();
72332
- return this.i18n.t('messageDeleted', 'Chat');
72333
- }
72334
- editedLabel() {
72335
- this.i18n.lang();
72336
- return this.i18n.t('edited', 'Chat');
72337
- }
72338
- formatTime(date) {
72339
- if (!date)
72340
- return '';
72341
- const d = date instanceof Date ? date : new Date(date);
72342
- return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
72343
- }
72344
- statusIcon() {
72345
- switch (this.props.status) {
72346
- case 'sending':
72347
- return 'time-outline';
72348
- case 'failed':
72349
- return 'alert-circle';
72350
- case 'sent':
72351
- default:
72352
- return 'checkmark';
72353
- }
72354
- }
72355
- onReactionClick(reaction) {
72356
- this.reactionClick.emit({ token: reaction.token, msgId: this.props.msgId });
72399
+ this.msg = input.required();
72400
+ this.showAvatar = input(false);
72401
+ this.showName = input(false);
72402
+ this.tail = input(true);
72403
+ this.locale = input('es-CL');
72404
+ this.action = output();
72405
+ this.actionsOpen = signal(false);
72406
+ this.time = computed(() => formatClockTime(this.msg().createdAt, this.locale()));
72407
+ this.initials = computed(() => {
72408
+ const name = this.msg().senderName?.trim() ?? '';
72409
+ if (!name)
72410
+ return '?';
72411
+ const parts = name.split(/\s+/);
72412
+ return (parts[0][0] + (parts[1]?.[0] ?? '')).toUpperCase();
72413
+ });
72414
+ this.imageAttachments = computed(() => (this.msg().attachments ?? []).filter(a => a.mimeType?.startsWith('image/')));
72415
+ this.audioAttachments = computed(() => (this.msg().attachments ?? []).filter(a => a.mimeType?.startsWith('audio/')));
72416
+ this.fileAttachments = computed(() => (this.msg().attachments ?? []).filter(a => !a.mimeType?.startsWith('image/') && !a.mimeType?.startsWith('audio/')));
72417
+ this.statusIcon = computed(() => {
72418
+ switch (this.msg().status) {
72419
+ case 'sending':
72420
+ return 'time-outline';
72421
+ case 'failed':
72422
+ return 'alert-circle';
72423
+ case 'delivered':
72424
+ case 'read':
72425
+ return 'checkmark-done';
72426
+ case 'sent':
72427
+ default:
72428
+ return 'checkmark';
72429
+ }
72430
+ });
72431
+ this.i18n.registerDefaults('MessageBubble', MESSAGE_BUBBLE_I18N);
72357
72432
  }
72358
- onReplyClick() {
72359
- this.replyClick.emit({ msgId: this.props.msgId });
72433
+ toggleActions() {
72434
+ if (this.msg().isDeleted)
72435
+ return;
72436
+ this.actionsOpen.update(v => !v);
72360
72437
  }
72361
- onDeleteClick() {
72362
- this.deleteClick.emit({ msgId: this.props.msgId });
72438
+ emit(type, token) {
72439
+ this.actionsOpen.set(false);
72440
+ this.action.emit({ type, msgId: this.msg().msgId, token });
72363
72441
  }
72364
- onEditClick() {
72365
- this.editClick.emit({ msgId: this.props.msgId });
72442
+ t(key) {
72443
+ return this.i18n.t(key, 'MessageBubble');
72366
72444
  }
72367
72445
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: MessageBubbleComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
72368
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: MessageBubbleComponent, isStandalone: true, selector: "val-message-bubble", inputs: { props: "props" }, outputs: { reactionClick: "reactionClick", replyClick: "replyClick", deleteClick: "deleteClick", editClick: "editClick" }, ngImport: i0, template: `
72369
- <div class="message-bubble-wrapper" [class.is-mine]="props.isMine">
72370
- @if (!props.isMine && !props.isDeleted) {
72371
- <span class="sender-name">{{ props.senderName }}</span>
72446
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: MessageBubbleComponent, isStandalone: true, selector: "val-message-bubble", inputs: { msg: { classPropertyName: "msg", publicName: "msg", isSignal: true, isRequired: true, transformFunction: null }, showAvatar: { classPropertyName: "showAvatar", publicName: "showAvatar", isSignal: true, isRequired: false, transformFunction: null }, showName: { classPropertyName: "showName", publicName: "showName", isSignal: true, isRequired: false, transformFunction: null }, tail: { classPropertyName: "tail", publicName: "tail", isSignal: true, isRequired: false, transformFunction: null }, locale: { classPropertyName: "locale", publicName: "locale", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { action: "action" }, ngImport: i0, template: `
72447
+ <div class="row" [class.mine]="msg().isMine" [class.tail]="tail()" [class.with-avatar]="showAvatar()">
72448
+ @if (showAvatar() && !msg().isMine) {
72449
+ <div class="avatar" [class.hidden]="!tail()">
72450
+ @if (msg().senderAvatar) {
72451
+ <img [src]="msg().senderAvatar" [alt]="msg().senderName" />
72452
+ } @else {
72453
+ <span>{{ initials() }}</span>
72454
+ }
72455
+ </div>
72372
72456
  }
72373
72457
 
72374
- <div class="bubble">
72375
- @if (props.replyTo && !props.isDeleted) {
72376
- <div class="reply-preview">
72377
- <div class="reply-sender">{{ props.replyTo.senderName }}</div>
72378
- <div class="reply-body">{{ props.replyTo.body }}</div>
72379
- </div>
72380
- }
72381
-
72382
- @if (props.isDeleted) {
72383
- <span class="deleted-text">{{ deletedText() }}</span>
72384
- } @else {
72385
- <div class="bubble-body">{{ props.body }}</div>
72386
- }
72387
-
72388
- <div class="bubble-meta">
72389
- <span class="timestamp">{{ formatTime(props.createdAt) }}</span>
72390
- @if (props.isEdited && !props.isDeleted) {
72391
- <span class="edited-label">{{ editedLabel() }}</span>
72392
- }
72393
- @if (props.isMine && props.status) {
72394
- <ion-icon
72395
- [name]="statusIcon()"
72396
- [class]="'status-icon ' + (props.status ?? '')"
72397
- aria-hidden="true"
72398
- ></ion-icon>
72458
+ <div class="bubble-wrap">
72459
+ <div
72460
+ class="bubble"
72461
+ [class.deleted]="msg().isDeleted"
72462
+ (click)="toggleActions()"
72463
+ (keyup.enter)="toggleActions()"
72464
+ tabindex="0"
72465
+ >
72466
+ @if (showName() && !msg().isMine && !msg().isDeleted) {
72467
+ <span class="name">{{ msg().senderName }}</span>
72399
72468
  }
72400
- </div>
72401
- </div>
72402
72469
 
72403
- @if (!props.isDeleted && props.reactions && props.reactions.length > 0) {
72404
- <div class="reactions-bar">
72405
- @for (reaction of props.reactions; track reaction.token) {
72406
- <button class="reaction-btn" [class.active]="reaction.active" (click)="onReactionClick(reaction)">
72407
- <span>{{ reaction.token }}</span>
72408
- @if (reaction.count > 0) {
72409
- <span class="reaction-count">{{ reaction.count }}</span>
72410
- }
72411
- </button>
72470
+ @if (msg().replyTo && !msg().isDeleted) {
72471
+ <div class="reply-preview">
72472
+ <span class="reply-name">{{ msg().replyTo!.senderName }}</span>
72473
+ <span class="reply-body">{{ msg().replyTo!.body }}</span>
72474
+ </div>
72412
72475
  }
72413
- </div>
72414
- }
72415
72476
 
72416
- @if (!props.isDeleted) {
72417
- <div class="bubble-actions">
72418
- <ion-button fill="clear" size="small" (click)="onReplyClick()">
72419
- <ion-icon name="return-down-back-outline" slot="icon-only" aria-hidden="true"></ion-icon>
72420
- </ion-button>
72421
- @if (props.isMine) {
72422
- <ion-button fill="clear" size="small" (click)="onEditClick()">
72423
- <ion-icon name="pencil" slot="icon-only" aria-hidden="true"></ion-icon>
72424
- </ion-button>
72425
- <ion-button fill="clear" size="small" color="danger" (click)="onDeleteClick()">
72426
- <ion-icon name="trash" slot="icon-only" aria-hidden="true"></ion-icon>
72427
- </ion-button>
72477
+ @if (msg().isDeleted) {
72478
+ <span class="body deleted-text">{{ t('deleted') }}</span>
72479
+ } @else {
72480
+ @if (imageAttachments().length > 0) {
72481
+ <div class="attachments">
72482
+ @for (att of imageAttachments(); track att.url) {
72483
+ <img class="att-image" [src]="att.url" [alt]="att.name" />
72484
+ }
72485
+ </div>
72486
+ }
72487
+ @if (audioAttachments().length > 0) {
72488
+ <div class="audios">
72489
+ @for (att of audioAttachments(); track att.url) {
72490
+ <audio class="att-audio" controls preload="metadata" [src]="att.url"></audio>
72491
+ }
72492
+ </div>
72493
+ }
72494
+ @if (fileAttachments().length > 0) {
72495
+ <div class="files">
72496
+ @for (att of fileAttachments(); track att.url) {
72497
+ <a class="file-chip" [href]="att.url" target="_blank" rel="noopener">
72498
+ <ion-icon name="document-outline" aria-hidden="true" />
72499
+ <span>{{ att.name }}</span>
72500
+ </a>
72501
+ }
72502
+ </div>
72503
+ }
72504
+ @if (msg().body) {
72505
+ <span class="body">{{ msg().body }}</span>
72506
+ }
72428
72507
  }
72508
+
72509
+ <span class="meta">
72510
+ @if (msg().isEdited && !msg().isDeleted) {
72511
+ <span class="edited">{{ t('edited') }}</span>
72512
+ }
72513
+ <span class="time">{{ time() }}</span>
72514
+ @if (msg().isMine && !msg().isDeleted) {
72515
+ <ion-icon
72516
+ class="status"
72517
+ [class.read]="msg().status === 'read'"
72518
+ [class.failed]="msg().status === 'failed'"
72519
+ [name]="statusIcon()"
72520
+ aria-hidden="true"
72521
+ />
72522
+ }
72523
+ </span>
72429
72524
  </div>
72430
- }
72431
- </div>
72432
- `, isInline: true, styles: [".message-bubble-wrapper{display:flex;flex-direction:column;align-items:flex-start;margin-bottom:4px}.message-bubble-wrapper.is-mine{align-items:flex-end}.message-bubble-wrapper.is-mine .bubble{background:var(--ion-color-primary);color:#fff;border-radius:18px 18px 4px}.message-bubble-wrapper.is-mine .bubble-meta{justify-content:flex-end}.message-bubble-wrapper:not(.is-mine) .bubble{background:var(--ion-card-background, var(--ion-background-color, #fff));color:var(--ion-text-color, #000);border-radius:18px 18px 18px 4px;border:1px solid var(--ion-border-color, rgba(0, 0, 0, .08))}.sender-name{font-size:.75rem;font-weight:600;color:var(--ion-color-medium, #92949c);margin:0 0 2px 12px}.bubble{max-width:75%;min-width:60px;padding:10px 14px;word-break:break-word;position:relative;box-shadow:0 1px 2px #0000000f}.reply-preview{background:#0000000f;border-left:3px solid var(--ion-color-primary);border-radius:6px;padding:6px 8px;margin-bottom:6px;font-size:.75rem;opacity:.85}.reply-preview .reply-sender{font-weight:700;margin-bottom:2px}.reply-preview .reply-body{opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:200px}.bubble-body{font-size:.9375rem;line-height:1.4;white-space:pre-wrap}.deleted-text{font-size:.875rem;font-style:italic;opacity:.6}.bubble-meta{display:flex;align-items:center;gap:4px;margin-top:4px;font-size:.6875rem;opacity:.7}.edited-label{font-style:italic}.status-icon{font-size:.75rem}.status-icon.sending{color:var(--ion-color-medium)}.status-icon.sent{color:#fff}.status-icon.failed{color:var(--ion-color-danger)}.reactions-bar{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px}.reactions-bar .reaction-btn{display:inline-flex;align-items:center;gap:3px;background:var(--ion-card-background, var(--ion-background-color, #fff));border:1px solid var(--ion-border-color, rgba(0, 0, 0, .1));border-radius:12px;padding:2px 8px;font-size:.8125rem;cursor:pointer;transition:background .15s ease}.reactions-bar .reaction-btn.active{background:var(--ion-color-primary-tint, #4c8dff);color:#fff;border-color:var(--ion-color-primary)}.reactions-bar .reaction-btn .reaction-count{font-weight:600}.bubble-actions{display:flex;gap:4px;margin-top:2px}.bubble-actions ion-button{--padding-start: 6px;--padding-end: 6px;font-size:.75rem}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }, { kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"] }] }); }
72433
- }
72434
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: MessageBubbleComponent, decorators: [{
72435
- type: Component,
72436
- args: [{ selector: 'val-message-bubble', standalone: true, imports: [CommonModule, IonIcon, IonButton], template: `
72437
- <div class="message-bubble-wrapper" [class.is-mine]="props.isMine">
72438
- @if (!props.isMine && !props.isDeleted) {
72439
- <span class="sender-name">{{ props.senderName }}</span>
72440
- }
72441
72525
 
72442
- <div class="bubble">
72443
- @if (props.replyTo && !props.isDeleted) {
72444
- <div class="reply-preview">
72445
- <div class="reply-sender">{{ props.replyTo.senderName }}</div>
72446
- <div class="reply-body">{{ props.replyTo.body }}</div>
72526
+ @if (msg().reactions && msg().reactions!.length > 0) {
72527
+ <div class="reactions">
72528
+ @for (r of msg().reactions!; track r.token) {
72529
+ <button class="reaction" [class.active]="r.active" (click)="emit('react', r.token)">
72530
+ <span>{{ r.token }}</span>
72531
+ <span class="count">{{ r.count }}</span>
72532
+ </button>
72533
+ }
72447
72534
  </div>
72448
72535
  }
72449
72536
 
72450
- @if (props.isDeleted) {
72451
- <span class="deleted-text">{{ deletedText() }}</span>
72452
- } @else {
72453
- <div class="bubble-body">{{ props.body }}</div>
72537
+ @if (!msg().isDeleted) {
72538
+ <div class="actions" [class.open]="actionsOpen()">
72539
+ <button class="act" [attr.aria-label]="t('react')" (click)="emit('react', '👍')">
72540
+ <ion-icon name="happy-outline" aria-hidden="true" />
72541
+ </button>
72542
+ <button class="act" [attr.aria-label]="t('reply')" (click)="emit('reply')">
72543
+ <ion-icon name="arrow-undo-outline" aria-hidden="true" />
72544
+ </button>
72545
+ @if (msg().isMine) {
72546
+ <button class="act" [attr.aria-label]="t('edit')" (click)="emit('edit')">
72547
+ <ion-icon name="create-outline" aria-hidden="true" />
72548
+ </button>
72549
+ <button class="act danger" [attr.aria-label]="t('delete')" (click)="emit('delete')">
72550
+ <ion-icon name="trash-outline" aria-hidden="true" />
72551
+ </button>
72552
+ }
72553
+ </div>
72454
72554
  }
72455
-
72456
- <div class="bubble-meta">
72457
- <span class="timestamp">{{ formatTime(props.createdAt) }}</span>
72458
- @if (props.isEdited && !props.isDeleted) {
72459
- <span class="edited-label">{{ editedLabel() }}</span>
72460
- }
72461
- @if (props.isMine && props.status) {
72462
- <ion-icon
72463
- [name]="statusIcon()"
72464
- [class]="'status-icon ' + (props.status ?? '')"
72465
- aria-hidden="true"
72466
- ></ion-icon>
72467
- }
72468
- </div>
72469
72555
  </div>
72470
-
72471
- @if (!props.isDeleted && props.reactions && props.reactions.length > 0) {
72472
- <div class="reactions-bar">
72473
- @for (reaction of props.reactions; track reaction.token) {
72474
- <button class="reaction-btn" [class.active]="reaction.active" (click)="onReactionClick(reaction)">
72475
- <span>{{ reaction.token }}</span>
72476
- @if (reaction.count > 0) {
72477
- <span class="reaction-count">{{ reaction.count }}</span>
72478
- }
72479
- </button>
72556
+ </div>
72557
+ `, isInline: true, styles: [":host{display:block}.row{display:flex;align-items:flex-end;gap:8px;margin:1px 0}.row.mine{flex-direction:row-reverse}.row.tail{margin-bottom:8px}.avatar{width:28px;height:28px;border-radius:50%;overflow:hidden;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:var(--ion-color-primary);color:#fff;font-size:.6875rem;font-weight:700}.avatar.hidden{visibility:hidden}.avatar img{width:100%;height:100%;object-fit:cover}.bubble-wrap{position:relative;max-width:min(78%,520px);display:flex;flex-direction:column}.row.mine .bubble-wrap{align-items:flex-end}.bubble{position:relative;padding:7px 11px;border-radius:16px;background:var(--ion-card-background, var(--ion-background-color, #fff));border:1px solid var(--ion-border-color, rgba(0, 0, 0, .08));color:var(--ion-text-color, #000);font-size:.9375rem;line-height:1.35;cursor:pointer;outline:none;word-break:break-word}.row:not(.mine).tail .bubble{border-bottom-left-radius:5px}.mine .bubble{background:var(--ion-color-primary);border-color:var(--ion-color-primary);color:var(--ion-color-primary-contrast, #fff)}.mine.tail .bubble{border-bottom-right-radius:5px}.bubble.deleted{background:transparent;border-style:dashed;cursor:default}.name{display:block;font-size:.75rem;font-weight:700;color:var(--ion-color-primary);margin-bottom:2px}.reply-preview{display:flex;flex-direction:column;gap:1px;padding:4px 8px;margin-bottom:4px;border-left:3px solid currentColor;border-radius:6px;background:var(--ion-color-step-100, rgba(127, 127, 127, .12));font-size:.8125rem;opacity:.85}.reply-name{font-weight:700}.reply-body{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:240px}.body{white-space:pre-wrap}.deleted-text{font-style:italic;opacity:.6}.attachments{display:flex;flex-direction:column;gap:4px;margin-bottom:4px}.att-image{max-width:100%;border-radius:10px;display:block}.audios{display:flex;flex-direction:column;gap:4px;margin-bottom:4px}.att-audio{width:240px;max-width:100%;height:40px}.files{display:flex;flex-direction:column;gap:4px;margin-bottom:4px}.file-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;background:var(--ion-color-step-100, rgba(127, 127, 127, .12));color:inherit;text-decoration:none;font-size:.8125rem}.meta{display:inline-flex;align-items:center;gap:4px;float:right;margin:2px 0 -2px 8px;font-size:.6875rem;opacity:.7}.edited{font-style:italic}.status{font-size:.875rem}.status.read{color:var(--ion-color-secondary, #4fc3f7);opacity:1}.status.failed{color:var(--ion-color-danger, #eb445a);opacity:1}.reactions{display:flex;flex-wrap:wrap;gap:4px;margin-top:3px}.reaction{display:inline-flex;align-items:center;gap:3px;padding:1px 7px;border-radius:999px;border:1px solid var(--ion-border-color, rgba(0, 0, 0, .1));background:var(--ion-card-background, var(--ion-background-color, #fff));font-size:.75rem;cursor:pointer;color:var(--ion-text-color, #000)}.reaction.active{border-color:var(--ion-color-primary);background:var(--ion-color-primary-tint, var(--ion-color-primary));color:var(--ion-color-primary-contrast, #fff)}.reaction .count{opacity:.75}.actions{position:absolute;top:-14px;display:flex;gap:2px;padding:2px;border-radius:999px;background:var(--ion-card-background, var(--ion-background-color, #fff));border:1px solid var(--ion-border-color, rgba(0, 0, 0, .1));box-shadow:0 2px 8px #0000001f;opacity:0;pointer-events:none;transition:opacity .12s ease;z-index:2}.row.mine .actions{right:0}.row:not(.mine) .actions{left:0}.bubble-wrap:hover .actions,.actions.open{opacity:1;pointer-events:auto}.act{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:50%;background:transparent;color:var(--ion-color-medium, #92949c);cursor:pointer;font-size:1rem}.act:hover{background:var(--ion-color-step-100, rgba(127, 127, 127, .12));color:var(--ion-text-color, #000)}.act.danger:hover{color:var(--ion-color-danger, #eb445a)}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }] }); }
72558
+ }
72559
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: MessageBubbleComponent, decorators: [{
72560
+ type: Component,
72561
+ args: [{ selector: 'val-message-bubble', standalone: true, imports: [CommonModule, IonIcon], template: `
72562
+ <div class="row" [class.mine]="msg().isMine" [class.tail]="tail()" [class.with-avatar]="showAvatar()">
72563
+ @if (showAvatar() && !msg().isMine) {
72564
+ <div class="avatar" [class.hidden]="!tail()">
72565
+ @if (msg().senderAvatar) {
72566
+ <img [src]="msg().senderAvatar" [alt]="msg().senderName" />
72567
+ } @else {
72568
+ <span>{{ initials() }}</span>
72480
72569
  }
72481
72570
  </div>
72482
72571
  }
72483
72572
 
72484
- @if (!props.isDeleted) {
72485
- <div class="bubble-actions">
72486
- <ion-button fill="clear" size="small" (click)="onReplyClick()">
72487
- <ion-icon name="return-down-back-outline" slot="icon-only" aria-hidden="true"></ion-icon>
72488
- </ion-button>
72489
- @if (props.isMine) {
72490
- <ion-button fill="clear" size="small" (click)="onEditClick()">
72491
- <ion-icon name="pencil" slot="icon-only" aria-hidden="true"></ion-icon>
72492
- </ion-button>
72493
- <ion-button fill="clear" size="small" color="danger" (click)="onDeleteClick()">
72494
- <ion-icon name="trash" slot="icon-only" aria-hidden="true"></ion-icon>
72495
- </ion-button>
72573
+ <div class="bubble-wrap">
72574
+ <div
72575
+ class="bubble"
72576
+ [class.deleted]="msg().isDeleted"
72577
+ (click)="toggleActions()"
72578
+ (keyup.enter)="toggleActions()"
72579
+ tabindex="0"
72580
+ >
72581
+ @if (showName() && !msg().isMine && !msg().isDeleted) {
72582
+ <span class="name">{{ msg().senderName }}</span>
72583
+ }
72584
+
72585
+ @if (msg().replyTo && !msg().isDeleted) {
72586
+ <div class="reply-preview">
72587
+ <span class="reply-name">{{ msg().replyTo!.senderName }}</span>
72588
+ <span class="reply-body">{{ msg().replyTo!.body }}</span>
72589
+ </div>
72590
+ }
72591
+
72592
+ @if (msg().isDeleted) {
72593
+ <span class="body deleted-text">{{ t('deleted') }}</span>
72594
+ } @else {
72595
+ @if (imageAttachments().length > 0) {
72596
+ <div class="attachments">
72597
+ @for (att of imageAttachments(); track att.url) {
72598
+ <img class="att-image" [src]="att.url" [alt]="att.name" />
72599
+ }
72600
+ </div>
72601
+ }
72602
+ @if (audioAttachments().length > 0) {
72603
+ <div class="audios">
72604
+ @for (att of audioAttachments(); track att.url) {
72605
+ <audio class="att-audio" controls preload="metadata" [src]="att.url"></audio>
72606
+ }
72607
+ </div>
72608
+ }
72609
+ @if (fileAttachments().length > 0) {
72610
+ <div class="files">
72611
+ @for (att of fileAttachments(); track att.url) {
72612
+ <a class="file-chip" [href]="att.url" target="_blank" rel="noopener">
72613
+ <ion-icon name="document-outline" aria-hidden="true" />
72614
+ <span>{{ att.name }}</span>
72615
+ </a>
72616
+ }
72617
+ </div>
72618
+ }
72619
+ @if (msg().body) {
72620
+ <span class="body">{{ msg().body }}</span>
72621
+ }
72496
72622
  }
72623
+
72624
+ <span class="meta">
72625
+ @if (msg().isEdited && !msg().isDeleted) {
72626
+ <span class="edited">{{ t('edited') }}</span>
72627
+ }
72628
+ <span class="time">{{ time() }}</span>
72629
+ @if (msg().isMine && !msg().isDeleted) {
72630
+ <ion-icon
72631
+ class="status"
72632
+ [class.read]="msg().status === 'read'"
72633
+ [class.failed]="msg().status === 'failed'"
72634
+ [name]="statusIcon()"
72635
+ aria-hidden="true"
72636
+ />
72637
+ }
72638
+ </span>
72497
72639
  </div>
72498
- }
72640
+
72641
+ @if (msg().reactions && msg().reactions!.length > 0) {
72642
+ <div class="reactions">
72643
+ @for (r of msg().reactions!; track r.token) {
72644
+ <button class="reaction" [class.active]="r.active" (click)="emit('react', r.token)">
72645
+ <span>{{ r.token }}</span>
72646
+ <span class="count">{{ r.count }}</span>
72647
+ </button>
72648
+ }
72649
+ </div>
72650
+ }
72651
+
72652
+ @if (!msg().isDeleted) {
72653
+ <div class="actions" [class.open]="actionsOpen()">
72654
+ <button class="act" [attr.aria-label]="t('react')" (click)="emit('react', '👍')">
72655
+ <ion-icon name="happy-outline" aria-hidden="true" />
72656
+ </button>
72657
+ <button class="act" [attr.aria-label]="t('reply')" (click)="emit('reply')">
72658
+ <ion-icon name="arrow-undo-outline" aria-hidden="true" />
72659
+ </button>
72660
+ @if (msg().isMine) {
72661
+ <button class="act" [attr.aria-label]="t('edit')" (click)="emit('edit')">
72662
+ <ion-icon name="create-outline" aria-hidden="true" />
72663
+ </button>
72664
+ <button class="act danger" [attr.aria-label]="t('delete')" (click)="emit('delete')">
72665
+ <ion-icon name="trash-outline" aria-hidden="true" />
72666
+ </button>
72667
+ }
72668
+ </div>
72669
+ }
72670
+ </div>
72499
72671
  </div>
72500
- `, styles: [".message-bubble-wrapper{display:flex;flex-direction:column;align-items:flex-start;margin-bottom:4px}.message-bubble-wrapper.is-mine{align-items:flex-end}.message-bubble-wrapper.is-mine .bubble{background:var(--ion-color-primary);color:#fff;border-radius:18px 18px 4px}.message-bubble-wrapper.is-mine .bubble-meta{justify-content:flex-end}.message-bubble-wrapper:not(.is-mine) .bubble{background:var(--ion-card-background, var(--ion-background-color, #fff));color:var(--ion-text-color, #000);border-radius:18px 18px 18px 4px;border:1px solid var(--ion-border-color, rgba(0, 0, 0, .08))}.sender-name{font-size:.75rem;font-weight:600;color:var(--ion-color-medium, #92949c);margin:0 0 2px 12px}.bubble{max-width:75%;min-width:60px;padding:10px 14px;word-break:break-word;position:relative;box-shadow:0 1px 2px #0000000f}.reply-preview{background:#0000000f;border-left:3px solid var(--ion-color-primary);border-radius:6px;padding:6px 8px;margin-bottom:6px;font-size:.75rem;opacity:.85}.reply-preview .reply-sender{font-weight:700;margin-bottom:2px}.reply-preview .reply-body{opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:200px}.bubble-body{font-size:.9375rem;line-height:1.4;white-space:pre-wrap}.deleted-text{font-size:.875rem;font-style:italic;opacity:.6}.bubble-meta{display:flex;align-items:center;gap:4px;margin-top:4px;font-size:.6875rem;opacity:.7}.edited-label{font-style:italic}.status-icon{font-size:.75rem}.status-icon.sending{color:var(--ion-color-medium)}.status-icon.sent{color:#fff}.status-icon.failed{color:var(--ion-color-danger)}.reactions-bar{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px}.reactions-bar .reaction-btn{display:inline-flex;align-items:center;gap:3px;background:var(--ion-card-background, var(--ion-background-color, #fff));border:1px solid var(--ion-border-color, rgba(0, 0, 0, .1));border-radius:12px;padding:2px 8px;font-size:.8125rem;cursor:pointer;transition:background .15s ease}.reactions-bar .reaction-btn.active{background:var(--ion-color-primary-tint, #4c8dff);color:#fff;border-color:var(--ion-color-primary)}.reactions-bar .reaction-btn .reaction-count{font-weight:600}.bubble-actions{display:flex;gap:4px;margin-top:2px}.bubble-actions ion-button{--padding-start: 6px;--padding-end: 6px;font-size:.75rem}\n"] }]
72501
- }], ctorParameters: () => [], propDecorators: { props: [{
72502
- type: Input
72503
- }], reactionClick: [{
72504
- type: Output
72505
- }], replyClick: [{
72506
- type: Output
72507
- }], deleteClick: [{
72508
- type: Output
72509
- }], editClick: [{
72510
- type: Output
72511
- }] } });
72672
+ `, styles: [":host{display:block}.row{display:flex;align-items:flex-end;gap:8px;margin:1px 0}.row.mine{flex-direction:row-reverse}.row.tail{margin-bottom:8px}.avatar{width:28px;height:28px;border-radius:50%;overflow:hidden;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:var(--ion-color-primary);color:#fff;font-size:.6875rem;font-weight:700}.avatar.hidden{visibility:hidden}.avatar img{width:100%;height:100%;object-fit:cover}.bubble-wrap{position:relative;max-width:min(78%,520px);display:flex;flex-direction:column}.row.mine .bubble-wrap{align-items:flex-end}.bubble{position:relative;padding:7px 11px;border-radius:16px;background:var(--ion-card-background, var(--ion-background-color, #fff));border:1px solid var(--ion-border-color, rgba(0, 0, 0, .08));color:var(--ion-text-color, #000);font-size:.9375rem;line-height:1.35;cursor:pointer;outline:none;word-break:break-word}.row:not(.mine).tail .bubble{border-bottom-left-radius:5px}.mine .bubble{background:var(--ion-color-primary);border-color:var(--ion-color-primary);color:var(--ion-color-primary-contrast, #fff)}.mine.tail .bubble{border-bottom-right-radius:5px}.bubble.deleted{background:transparent;border-style:dashed;cursor:default}.name{display:block;font-size:.75rem;font-weight:700;color:var(--ion-color-primary);margin-bottom:2px}.reply-preview{display:flex;flex-direction:column;gap:1px;padding:4px 8px;margin-bottom:4px;border-left:3px solid currentColor;border-radius:6px;background:var(--ion-color-step-100, rgba(127, 127, 127, .12));font-size:.8125rem;opacity:.85}.reply-name{font-weight:700}.reply-body{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:240px}.body{white-space:pre-wrap}.deleted-text{font-style:italic;opacity:.6}.attachments{display:flex;flex-direction:column;gap:4px;margin-bottom:4px}.att-image{max-width:100%;border-radius:10px;display:block}.audios{display:flex;flex-direction:column;gap:4px;margin-bottom:4px}.att-audio{width:240px;max-width:100%;height:40px}.files{display:flex;flex-direction:column;gap:4px;margin-bottom:4px}.file-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;background:var(--ion-color-step-100, rgba(127, 127, 127, .12));color:inherit;text-decoration:none;font-size:.8125rem}.meta{display:inline-flex;align-items:center;gap:4px;float:right;margin:2px 0 -2px 8px;font-size:.6875rem;opacity:.7}.edited{font-style:italic}.status{font-size:.875rem}.status.read{color:var(--ion-color-secondary, #4fc3f7);opacity:1}.status.failed{color:var(--ion-color-danger, #eb445a);opacity:1}.reactions{display:flex;flex-wrap:wrap;gap:4px;margin-top:3px}.reaction{display:inline-flex;align-items:center;gap:3px;padding:1px 7px;border-radius:999px;border:1px solid var(--ion-border-color, rgba(0, 0, 0, .1));background:var(--ion-card-background, var(--ion-background-color, #fff));font-size:.75rem;cursor:pointer;color:var(--ion-text-color, #000)}.reaction.active{border-color:var(--ion-color-primary);background:var(--ion-color-primary-tint, var(--ion-color-primary));color:var(--ion-color-primary-contrast, #fff)}.reaction .count{opacity:.75}.actions{position:absolute;top:-14px;display:flex;gap:2px;padding:2px;border-radius:999px;background:var(--ion-card-background, var(--ion-background-color, #fff));border:1px solid var(--ion-border-color, rgba(0, 0, 0, .1));box-shadow:0 2px 8px #0000001f;opacity:0;pointer-events:none;transition:opacity .12s ease;z-index:2}.row.mine .actions{right:0}.row:not(.mine) .actions{left:0}.bubble-wrap:hover .actions,.actions.open{opacity:1;pointer-events:auto}.act{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:50%;background:transparent;color:var(--ion-color-medium, #92949c);cursor:pointer;font-size:1rem}.act:hover{background:var(--ion-color-step-100, rgba(127, 127, 127, .12));color:var(--ion-text-color, #000)}.act.danger:hover{color:var(--ion-color-danger, #eb445a)}\n"] }]
72673
+ }], ctorParameters: () => [] });
72512
72674
 
72513
72675
  addIcons({ sendOutline });
72514
72676
  const CHAT_INPUT_I18N = {
@@ -72601,10 +72763,6 @@ class ChatInputComponent {
72601
72763
  [autoGrow]="true"
72602
72764
  [rows]="1"
72603
72765
  [value]="body()"
72604
- enterkeyhint="send"
72605
- autocomplete="off"
72606
- autocorrect="off"
72607
- spellcheck="false"
72608
72766
  (ionInput)="onInput($event)"
72609
72767
  (keydown)="onKeydown($event)"
72610
72768
  ></ion-textarea>
@@ -72637,10 +72795,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
72637
72795
  [autoGrow]="true"
72638
72796
  [rows]="1"
72639
72797
  [value]="body()"
72640
- enterkeyhint="send"
72641
- autocomplete="off"
72642
- autocorrect="off"
72643
- spellcheck="false"
72644
72798
  (ionInput)="onInput($event)"
72645
72799
  (keydown)="onKeydown($event)"
72646
72800
  ></ion-textarea>
@@ -72668,6 +72822,533 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
72668
72822
  type: Output
72669
72823
  }] } });
72670
72824
 
72825
+ addIcons({ addOutline, checkmarkOutline, closeOutline, documentOutline, micOutline, arrowUpOutline, trashOutline });
72826
+ const CHAT_COMPOSER_I18N = {
72827
+ es: {
72828
+ placeholder: 'Escribe un mensaje',
72829
+ send: 'Enviar',
72830
+ attach: 'Adjuntar',
72831
+ voice: 'Mensaje de voz',
72832
+ done: 'Listo',
72833
+ replyingTo: 'Respondiendo a',
72834
+ cancel: 'Cancelar',
72835
+ remove: 'Quitar',
72836
+ },
72837
+ en: {
72838
+ placeholder: 'Write a message',
72839
+ send: 'Send',
72840
+ attach: 'Attach',
72841
+ voice: 'Voice message',
72842
+ done: 'Done',
72843
+ replyingTo: 'Replying to',
72844
+ cancel: 'Cancel',
72845
+ remove: 'Remove',
72846
+ },
72847
+ };
72848
+ /**
72849
+ * val-chat-composer — barra de redacción de chat moderna (signal inputs).
72850
+ *
72851
+ * Pill con: adjuntar (+), textarea auto-grow, y mic/enviar (mic cuando está vacío,
72852
+ * enviar cuando hay texto o adjuntos). Los adjuntos (botón + / pegar imagen) se
72853
+ * acumulan como PREVIEW (miniaturas) y se emiten por `(attach)` recién al ENVIAR
72854
+ * — no se van solos al chat. Audio grabado se emite directo al parar.
72855
+ * No incluye picker de emoji: el teclado del sistema ya lo trae.
72856
+ */
72857
+ class ChatComposerComponent {
72858
+ constructor() {
72859
+ this.i18n = inject(I18nService);
72860
+ this.placeholder = input('');
72861
+ this.disabled = input(false);
72862
+ this.maxLength = input(2000);
72863
+ this.replyingTo = input(null);
72864
+ this.showAttach = input(true);
72865
+ this.showMic = input(true);
72866
+ this.send = output();
72867
+ this.typing = output();
72868
+ this.voice = output();
72869
+ this.cancelReply = output();
72870
+ this.body = signal('');
72871
+ this.pending = signal([]);
72872
+ this.pendingId = 0;
72873
+ // Grabación de audio (MediaRecorder).
72874
+ this.recording = signal(false);
72875
+ this.recSeconds = signal(0);
72876
+ this.recChunks = [];
72877
+ this.recTimeLabel = computed(() => {
72878
+ const s = this.recSeconds();
72879
+ const mm = String(Math.floor(s / 60)).padStart(2, '0');
72880
+ const ss = String(s % 60).padStart(2, '0');
72881
+ return `${mm}:${ss}`;
72882
+ });
72883
+ this.lastTypingEmit = 0;
72884
+ this.canSend = computed(() => {
72885
+ const t = this.body().trim();
72886
+ if (this.disabled())
72887
+ return false;
72888
+ return (t.length > 0 && t.length <= this.maxLength()) || this.pending().length > 0;
72889
+ });
72890
+ this.placeholderText = computed(() => {
72891
+ this.i18n.lang();
72892
+ return this.placeholder() || this.i18n.t('placeholder', 'ChatComposer');
72893
+ });
72894
+ this.i18n.registerDefaults('ChatComposer', CHAT_COMPOSER_I18N);
72895
+ }
72896
+ onInput(event) {
72897
+ const target = event.target;
72898
+ this.body.set(target.value ?? '');
72899
+ this.emitTypingDebounced();
72900
+ }
72901
+ onKeydown(event) {
72902
+ if (event.key === 'Enter' && !event.shiftKey) {
72903
+ event.preventDefault();
72904
+ this.onSend();
72905
+ }
72906
+ }
72907
+ onSend() {
72908
+ if (!this.canSend())
72909
+ return;
72910
+ // Un solo evento: texto (caption) + todos los adjuntos staged. El consumer
72911
+ // los sube y manda como UN mensaje con body + attachments.
72912
+ const files = this.pending().map(p => p.file);
72913
+ const text = this.body().trim();
72914
+ this.send.emit({ text, files });
72915
+ for (const att of this.pending()) {
72916
+ if (att.url)
72917
+ URL.revokeObjectURL(att.url);
72918
+ }
72919
+ this.pending.set([]);
72920
+ this.body.set('');
72921
+ }
72922
+ onFile(event) {
72923
+ const input = event.target;
72924
+ const file = input.files?.[0];
72925
+ if (file)
72926
+ this.addPending(file);
72927
+ input.value = '';
72928
+ }
72929
+ /** Pegar imagen desde el portapapeles (captura de pantalla, copiar imagen). */
72930
+ onPaste(event) {
72931
+ const items = event.clipboardData?.items;
72932
+ if (!items)
72933
+ return;
72934
+ for (const item of Array.from(items)) {
72935
+ if (item.type.startsWith('image/')) {
72936
+ const file = item.getAsFile();
72937
+ if (file) {
72938
+ event.preventDefault();
72939
+ this.addPending(file);
72940
+ }
72941
+ return;
72942
+ }
72943
+ }
72944
+ }
72945
+ addPending(file) {
72946
+ const kind = file.type.startsWith('image/')
72947
+ ? 'image'
72948
+ : file.type.startsWith('audio/')
72949
+ ? 'audio'
72950
+ : 'file';
72951
+ const url = kind === 'file' ? undefined : URL.createObjectURL(file);
72952
+ this.pending.update(arr => [...arr, { id: this.pendingId++, file, url, kind }]);
72953
+ }
72954
+ removePending(id) {
72955
+ this.pending.update(arr => {
72956
+ const found = arr.find(a => a.id === id);
72957
+ if (found?.url)
72958
+ URL.revokeObjectURL(found.url);
72959
+ return arr.filter(a => a.id !== id);
72960
+ });
72961
+ }
72962
+ /**
72963
+ * Emite typing como máximo 1 vez cada 2s. performance.now es seguro en runtime.
72964
+ */
72965
+ emitTypingDebounced() {
72966
+ const now = performance.now();
72967
+ if (now - this.lastTypingEmit > 2000) {
72968
+ this.lastTypingEmit = now;
72969
+ this.typing.emit();
72970
+ }
72971
+ }
72972
+ // ─── Grabación de audio ───────────────────────────────────────────────────
72973
+ /** Selecciona un mimeType de audio soportado por el navegador. */
72974
+ pickAudioMime() {
72975
+ const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4'];
72976
+ const MR = window.MediaRecorder;
72977
+ if (!MR?.isTypeSupported)
72978
+ return '';
72979
+ return candidates.find(c => MR.isTypeSupported(c)) ?? '';
72980
+ }
72981
+ async startRecording() {
72982
+ if (this.recording())
72983
+ return;
72984
+ if (!navigator.mediaDevices?.getUserMedia || !('MediaRecorder' in window)) {
72985
+ this.voice.emit(); // sin soporte: deja que el consumer decida (fallback)
72986
+ return;
72987
+ }
72988
+ try {
72989
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
72990
+ this.recStream = stream;
72991
+ const mimeType = this.pickAudioMime();
72992
+ this.recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
72993
+ this.recChunks = [];
72994
+ this.recorder.ondataavailable = e => {
72995
+ if (e.data.size > 0)
72996
+ this.recChunks.push(e.data);
72997
+ };
72998
+ this.recorder.start();
72999
+ this.recording.set(true);
73000
+ this.recSeconds.set(0);
73001
+ this.recTimer = setInterval(() => this.recSeconds.update(s => s + 1), 1000);
73002
+ }
73003
+ catch {
73004
+ this.cleanupRecording();
73005
+ }
73006
+ }
73007
+ /** Para la grabación y la stagea como adjunto pendiente (con preview de audio). */
73008
+ finishRecording() {
73009
+ const rec = this.recorder;
73010
+ if (!rec) {
73011
+ this.cleanupRecording();
73012
+ return;
73013
+ }
73014
+ rec.onstop = () => {
73015
+ const type = rec.mimeType || 'audio/webm';
73016
+ const blob = new Blob(this.recChunks, { type });
73017
+ const ext = type.includes('mp4') ? 'm4a' : 'webm';
73018
+ const file = new File([blob], `audio-${this.recSeconds()}s.${ext}`, { type });
73019
+ if (blob.size > 0)
73020
+ this.addPending(file);
73021
+ this.cleanupRecording();
73022
+ };
73023
+ rec.stop();
73024
+ }
73025
+ cancelRecording() {
73026
+ const rec = this.recorder;
73027
+ if (rec)
73028
+ rec.onstop = () => this.cleanupRecording();
73029
+ try {
73030
+ rec?.stop();
73031
+ }
73032
+ catch {
73033
+ this.cleanupRecording();
73034
+ }
73035
+ if (!rec)
73036
+ this.cleanupRecording();
73037
+ }
73038
+ cleanupRecording() {
73039
+ if (this.recTimer)
73040
+ clearInterval(this.recTimer);
73041
+ this.recTimer = undefined;
73042
+ this.recStream?.getTracks().forEach(t => t.stop());
73043
+ this.recStream = undefined;
73044
+ this.recorder = undefined;
73045
+ this.recChunks = [];
73046
+ this.recording.set(false);
73047
+ this.recSeconds.set(0);
73048
+ }
73049
+ ngOnDestroy() {
73050
+ this.cleanupRecording();
73051
+ for (const att of this.pending()) {
73052
+ if (att.url)
73053
+ URL.revokeObjectURL(att.url);
73054
+ }
73055
+ }
73056
+ t(key) {
73057
+ return this.i18n.t(key, 'ChatComposer');
73058
+ }
73059
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ChatComposerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
73060
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ChatComposerComponent, isStandalone: true, selector: "val-chat-composer", inputs: { placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, maxLength: { classPropertyName: "maxLength", publicName: "maxLength", isSignal: true, isRequired: false, transformFunction: null }, replyingTo: { classPropertyName: "replyingTo", publicName: "replyingTo", isSignal: true, isRequired: false, transformFunction: null }, showAttach: { classPropertyName: "showAttach", publicName: "showAttach", isSignal: true, isRequired: false, transformFunction: null }, showMic: { classPropertyName: "showMic", publicName: "showMic", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { send: "send", typing: "typing", voice: "voice", cancelReply: "cancelReply" }, ngImport: i0, template: `
73061
+ <div class="composer">
73062
+ @if (replyingTo()) {
73063
+ <div class="reply-bar">
73064
+ <div class="reply-content">
73065
+ <span class="reply-name">{{ replyingTo()!.senderName }}</span>
73066
+ <span class="reply-body">{{ replyingTo()!.body }}</span>
73067
+ </div>
73068
+ <button class="icon-btn" [attr.aria-label]="t('cancel')" (click)="cancelReply.emit()">
73069
+ <ion-icon name="close-outline" aria-hidden="true" />
73070
+ </button>
73071
+ </div>
73072
+ }
73073
+
73074
+ @if (pending().length > 0) {
73075
+ <div class="pending-row">
73076
+ @for (att of pending(); track att.id) {
73077
+ @if (att.kind === 'audio') {
73078
+ <div class="audio-chip">
73079
+ <audio controls preload="metadata" [src]="att.url"></audio>
73080
+ <button class="thumb-remove inline" [attr.aria-label]="t('remove')" (click)="removePending(att.id)">
73081
+ <ion-icon name="close-outline" aria-hidden="true" />
73082
+ </button>
73083
+ </div>
73084
+ } @else {
73085
+ <div class="thumb" [class.file]="att.kind === 'file'">
73086
+ @if (att.kind === 'image') {
73087
+ <img [src]="att.url" [alt]="att.file.name" />
73088
+ } @else {
73089
+ <ion-icon name="document-outline" aria-hidden="true" />
73090
+ <span class="thumb-name">{{ att.file.name }}</span>
73091
+ }
73092
+ <button class="thumb-remove" [attr.aria-label]="t('remove')" (click)="removePending(att.id)">
73093
+ <ion-icon name="close-outline" aria-hidden="true" />
73094
+ </button>
73095
+ </div>
73096
+ }
73097
+ }
73098
+ </div>
73099
+ }
73100
+
73101
+ @if (recording()) {
73102
+ <div class="rec-bar">
73103
+ <button class="icon-btn danger" [attr.aria-label]="t('cancel')" (click)="cancelRecording()">
73104
+ <ion-icon name="trash-outline" aria-hidden="true" />
73105
+ </button>
73106
+ <span class="rec-dot"></span>
73107
+ <span class="rec-time">{{ recTimeLabel() }}</span>
73108
+ <span class="rec-spacer"></span>
73109
+ <button class="send-btn" [attr.aria-label]="t('done')" (click)="finishRecording()">
73110
+ <ion-icon name="checkmark-outline" aria-hidden="true" />
73111
+ </button>
73112
+ </div>
73113
+ } @else {
73114
+ <div class="pill">
73115
+ @if (showAttach()) {
73116
+ <label class="icon-btn" [attr.aria-label]="t('attach')">
73117
+ <ion-icon name="add-outline" aria-hidden="true" />
73118
+ <input type="file" hidden (change)="onFile($event)" accept="image/*,application/pdf" />
73119
+ </label>
73120
+ }
73121
+
73122
+ <ion-textarea
73123
+ class="field"
73124
+ [placeholder]="placeholderText()"
73125
+ [disabled]="disabled()"
73126
+ [maxlength]="maxLength()"
73127
+ [autoGrow]="true"
73128
+ [rows]="1"
73129
+ [value]="body()"
73130
+ [spellcheck]="false"
73131
+ autocapitalize="sentences"
73132
+ autocorrect="off"
73133
+ enterkeyhint="enter"
73134
+ (ionInput)="onInput($event)"
73135
+ (keydown)="onKeydown($event)"
73136
+ (paste)="onPaste($event)"
73137
+ ></ion-textarea>
73138
+
73139
+ @if (canSend()) {
73140
+ <button class="send-btn" [attr.aria-label]="t('send')" (click)="onSend()">
73141
+ <ion-icon name="arrow-up-outline" aria-hidden="true" />
73142
+ </button>
73143
+ } @else if (showMic()) {
73144
+ <button class="icon-btn mic" [attr.aria-label]="t('voice')" (click)="startRecording()">
73145
+ <ion-icon name="mic-outline" aria-hidden="true" />
73146
+ </button>
73147
+ }
73148
+ </div>
73149
+ }
73150
+ </div>
73151
+ `, isInline: true, styles: [":host{display:block}.composer{display:flex;flex-direction:column;gap:6px;padding:8px 10px calc(8px + env(safe-area-inset-bottom,0px));background:var(--ion-background-color, #fff);border-top:1px solid var(--ion-border-color, rgba(0, 0, 0, .08))}.reply-bar{display:flex;align-items:center;gap:8px;padding:6px 10px;border-left:3px solid var(--ion-color-primary);border-radius:8px;background:var(--ion-color-step-100, rgba(127, 127, 127, .1))}.reply-content{display:flex;flex-direction:column;flex:1;min-width:0;font-size:.8125rem}.reply-name{font-weight:700;color:var(--ion-color-primary)}.reply-body{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--ion-color-medium, #92949c)}.pending-row{display:flex;gap:8px;flex-wrap:wrap;padding:2px 4px}.thumb{position:relative;width:64px;height:64px;border-radius:10px;overflow:hidden;border:1px solid var(--ion-border-color, rgba(0, 0, 0, .1));background:var(--ion-color-step-100, rgba(127, 127, 127, .1))}.thumb img{width:100%;height:100%;object-fit:cover}.thumb.file{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;padding:4px;color:var(--ion-color-medium, #92949c);font-size:1.5rem}.thumb-name{font-size:.5625rem;line-height:1.1;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ion-text-color, #000)}.thumb-remove{position:absolute;top:2px;right:2px;width:20px;height:20px;border:none;border-radius:50%;background:#0000008c;color:#fff;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;font-size:.875rem}.pill{display:flex;align-items:flex-end;gap:4px;background:var(--ion-color-light, #f4f5f8);border:1px solid var(--ion-border-color, rgba(0, 0, 0, .08));border-radius:24px;padding:2px 6px}.field{flex:1;--background: transparent;--padding-start: 6px;--padding-end: 6px;--padding-top: 8px;--padding-bottom: 8px;margin:0;max-height:120px;font-size:.9375rem}.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;flex-shrink:0;border:none;border-radius:50%;background:transparent;color:var(--ion-color-medium, #92949c);cursor:pointer;font-size:1.375rem}.icon-btn:hover,.icon-btn.active{color:var(--ion-color-primary)}.send-btn{display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;flex-shrink:0;border:none;border-radius:50%;background:var(--ion-color-primary);color:var(--ion-color-primary-contrast, #fff);cursor:pointer;font-size:1.25rem;transition:transform .1s ease}.send-btn:active{transform:scale(.92)}.audio-chip{display:flex;align-items:center;gap:6px;width:100%;padding:4px 6px;border-radius:12px;border:1px solid var(--ion-border-color, rgba(0, 0, 0, .1));background:var(--ion-color-step-100, rgba(127, 127, 127, .1))}.audio-chip audio{flex:1;height:36px;min-width:0}.thumb-remove.inline{position:static;flex-shrink:0;background:var(--ion-color-medium, #92949c)}.icon-btn.danger{color:var(--ion-color-danger, #eb445a)}.rec-bar{display:flex;align-items:center;gap:10px;background:var(--ion-color-light, #f4f5f8);border:1px solid var(--ion-border-color, rgba(0, 0, 0, .08));border-radius:24px;padding:2px 6px}.rec-dot{width:10px;height:10px;border-radius:50%;background:var(--ion-color-danger, #eb445a);animation:rec-pulse 1.2s ease-in-out infinite}.rec-time{font-variant-numeric:tabular-nums;font-size:.9375rem;color:var(--ion-text-color, #000)}.rec-spacer{flex:1}@keyframes rec-pulse{0%,to{opacity:1}50%{opacity:.3}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: IonTextarea, selector: "ion-textarea", inputs: ["autoGrow", "autocapitalize", "autofocus", "clearOnEdit", "color", "cols", "counter", "counterFormatter", "debounce", "disabled", "enterkeyhint", "errorText", "fill", "helperText", "inputmode", "label", "labelPlacement", "maxlength", "minlength", "mode", "name", "placeholder", "readonly", "required", "rows", "shape", "spellcheck", "value", "wrap"] }, { kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }] }); }
73152
+ }
73153
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ChatComposerComponent, decorators: [{
73154
+ type: Component,
73155
+ args: [{ selector: 'val-chat-composer', standalone: true, imports: [CommonModule, IonTextarea, IonIcon], template: `
73156
+ <div class="composer">
73157
+ @if (replyingTo()) {
73158
+ <div class="reply-bar">
73159
+ <div class="reply-content">
73160
+ <span class="reply-name">{{ replyingTo()!.senderName }}</span>
73161
+ <span class="reply-body">{{ replyingTo()!.body }}</span>
73162
+ </div>
73163
+ <button class="icon-btn" [attr.aria-label]="t('cancel')" (click)="cancelReply.emit()">
73164
+ <ion-icon name="close-outline" aria-hidden="true" />
73165
+ </button>
73166
+ </div>
73167
+ }
73168
+
73169
+ @if (pending().length > 0) {
73170
+ <div class="pending-row">
73171
+ @for (att of pending(); track att.id) {
73172
+ @if (att.kind === 'audio') {
73173
+ <div class="audio-chip">
73174
+ <audio controls preload="metadata" [src]="att.url"></audio>
73175
+ <button class="thumb-remove inline" [attr.aria-label]="t('remove')" (click)="removePending(att.id)">
73176
+ <ion-icon name="close-outline" aria-hidden="true" />
73177
+ </button>
73178
+ </div>
73179
+ } @else {
73180
+ <div class="thumb" [class.file]="att.kind === 'file'">
73181
+ @if (att.kind === 'image') {
73182
+ <img [src]="att.url" [alt]="att.file.name" />
73183
+ } @else {
73184
+ <ion-icon name="document-outline" aria-hidden="true" />
73185
+ <span class="thumb-name">{{ att.file.name }}</span>
73186
+ }
73187
+ <button class="thumb-remove" [attr.aria-label]="t('remove')" (click)="removePending(att.id)">
73188
+ <ion-icon name="close-outline" aria-hidden="true" />
73189
+ </button>
73190
+ </div>
73191
+ }
73192
+ }
73193
+ </div>
73194
+ }
73195
+
73196
+ @if (recording()) {
73197
+ <div class="rec-bar">
73198
+ <button class="icon-btn danger" [attr.aria-label]="t('cancel')" (click)="cancelRecording()">
73199
+ <ion-icon name="trash-outline" aria-hidden="true" />
73200
+ </button>
73201
+ <span class="rec-dot"></span>
73202
+ <span class="rec-time">{{ recTimeLabel() }}</span>
73203
+ <span class="rec-spacer"></span>
73204
+ <button class="send-btn" [attr.aria-label]="t('done')" (click)="finishRecording()">
73205
+ <ion-icon name="checkmark-outline" aria-hidden="true" />
73206
+ </button>
73207
+ </div>
73208
+ } @else {
73209
+ <div class="pill">
73210
+ @if (showAttach()) {
73211
+ <label class="icon-btn" [attr.aria-label]="t('attach')">
73212
+ <ion-icon name="add-outline" aria-hidden="true" />
73213
+ <input type="file" hidden (change)="onFile($event)" accept="image/*,application/pdf" />
73214
+ </label>
73215
+ }
73216
+
73217
+ <ion-textarea
73218
+ class="field"
73219
+ [placeholder]="placeholderText()"
73220
+ [disabled]="disabled()"
73221
+ [maxlength]="maxLength()"
73222
+ [autoGrow]="true"
73223
+ [rows]="1"
73224
+ [value]="body()"
73225
+ [spellcheck]="false"
73226
+ autocapitalize="sentences"
73227
+ autocorrect="off"
73228
+ enterkeyhint="enter"
73229
+ (ionInput)="onInput($event)"
73230
+ (keydown)="onKeydown($event)"
73231
+ (paste)="onPaste($event)"
73232
+ ></ion-textarea>
73233
+
73234
+ @if (canSend()) {
73235
+ <button class="send-btn" [attr.aria-label]="t('send')" (click)="onSend()">
73236
+ <ion-icon name="arrow-up-outline" aria-hidden="true" />
73237
+ </button>
73238
+ } @else if (showMic()) {
73239
+ <button class="icon-btn mic" [attr.aria-label]="t('voice')" (click)="startRecording()">
73240
+ <ion-icon name="mic-outline" aria-hidden="true" />
73241
+ </button>
73242
+ }
73243
+ </div>
73244
+ }
73245
+ </div>
73246
+ `, styles: [":host{display:block}.composer{display:flex;flex-direction:column;gap:6px;padding:8px 10px calc(8px + env(safe-area-inset-bottom,0px));background:var(--ion-background-color, #fff);border-top:1px solid var(--ion-border-color, rgba(0, 0, 0, .08))}.reply-bar{display:flex;align-items:center;gap:8px;padding:6px 10px;border-left:3px solid var(--ion-color-primary);border-radius:8px;background:var(--ion-color-step-100, rgba(127, 127, 127, .1))}.reply-content{display:flex;flex-direction:column;flex:1;min-width:0;font-size:.8125rem}.reply-name{font-weight:700;color:var(--ion-color-primary)}.reply-body{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--ion-color-medium, #92949c)}.pending-row{display:flex;gap:8px;flex-wrap:wrap;padding:2px 4px}.thumb{position:relative;width:64px;height:64px;border-radius:10px;overflow:hidden;border:1px solid var(--ion-border-color, rgba(0, 0, 0, .1));background:var(--ion-color-step-100, rgba(127, 127, 127, .1))}.thumb img{width:100%;height:100%;object-fit:cover}.thumb.file{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;padding:4px;color:var(--ion-color-medium, #92949c);font-size:1.5rem}.thumb-name{font-size:.5625rem;line-height:1.1;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ion-text-color, #000)}.thumb-remove{position:absolute;top:2px;right:2px;width:20px;height:20px;border:none;border-radius:50%;background:#0000008c;color:#fff;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;font-size:.875rem}.pill{display:flex;align-items:flex-end;gap:4px;background:var(--ion-color-light, #f4f5f8);border:1px solid var(--ion-border-color, rgba(0, 0, 0, .08));border-radius:24px;padding:2px 6px}.field{flex:1;--background: transparent;--padding-start: 6px;--padding-end: 6px;--padding-top: 8px;--padding-bottom: 8px;margin:0;max-height:120px;font-size:.9375rem}.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;flex-shrink:0;border:none;border-radius:50%;background:transparent;color:var(--ion-color-medium, #92949c);cursor:pointer;font-size:1.375rem}.icon-btn:hover,.icon-btn.active{color:var(--ion-color-primary)}.send-btn{display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;flex-shrink:0;border:none;border-radius:50%;background:var(--ion-color-primary);color:var(--ion-color-primary-contrast, #fff);cursor:pointer;font-size:1.25rem;transition:transform .1s ease}.send-btn:active{transform:scale(.92)}.audio-chip{display:flex;align-items:center;gap:6px;width:100%;padding:4px 6px;border-radius:12px;border:1px solid var(--ion-border-color, rgba(0, 0, 0, .1));background:var(--ion-color-step-100, rgba(127, 127, 127, .1))}.audio-chip audio{flex:1;height:36px;min-width:0}.thumb-remove.inline{position:static;flex-shrink:0;background:var(--ion-color-medium, #92949c)}.icon-btn.danger{color:var(--ion-color-danger, #eb445a)}.rec-bar{display:flex;align-items:center;gap:10px;background:var(--ion-color-light, #f4f5f8);border:1px solid var(--ion-border-color, rgba(0, 0, 0, .08));border-radius:24px;padding:2px 6px}.rec-dot{width:10px;height:10px;border-radius:50%;background:var(--ion-color-danger, #eb445a);animation:rec-pulse 1.2s ease-in-out infinite}.rec-time{font-variant-numeric:tabular-nums;font-size:.9375rem;color:var(--ion-text-color, #000)}.rec-spacer{flex:1}@keyframes rec-pulse{0%,to{opacity:1}50%{opacity:.3}}\n"] }]
73247
+ }], ctorParameters: () => [] });
73248
+
73249
+ /**
73250
+ * val-conversation-list-item — fila de lista de conversaciones estilo
73251
+ * Telegram/WhatsApp: avatar (imagen o iniciales), nombre + hora relativa,
73252
+ * preview del último mensaje + badge de no-leídos. Signal inputs, dark-safe.
73253
+ *
73254
+ * @example
73255
+ * <val-conversation-list-item
73256
+ * [name]="conv.name"
73257
+ * [lastMessage]="conv.lastMsgPreview"
73258
+ * [timestamp]="conv.lastMsgAt"
73259
+ * [unreadCount]="conv.unread"
73260
+ * (select)="open(conv.convId)" />
73261
+ */
73262
+ class ConversationListItemComponent {
73263
+ constructor() {
73264
+ this.i18n = inject(I18nService);
73265
+ this.name = input('');
73266
+ this.avatarUrl = input(undefined);
73267
+ this.lastMessage = input('');
73268
+ this.timestamp = input(undefined);
73269
+ this.unreadCount = input(0);
73270
+ this.locale = input('es-CL');
73271
+ this.select = output();
73272
+ this.initials = computed(() => {
73273
+ const name = this.name().trim();
73274
+ if (!name)
73275
+ return '?';
73276
+ const parts = name.split(/\s+/);
73277
+ return (parts[0][0] + (parts[1]?.[0] ?? '')).toUpperCase();
73278
+ });
73279
+ this.timeLabel = computed(() => formatRelativeTime(this.timestamp(), this.locale(), {
73280
+ today: '',
73281
+ yesterday: this.i18n.t('yesterday', '_global'),
73282
+ }));
73283
+ this.i18n.registerDefaults('ConversationListItem', {
73284
+ es: { empty: 'Sin mensajes' },
73285
+ en: { empty: 'No messages' },
73286
+ });
73287
+ }
73288
+ emptyText() {
73289
+ this.i18n.lang();
73290
+ return this.i18n.t('empty', 'ConversationListItem');
73291
+ }
73292
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ConversationListItemComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
73293
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ConversationListItemComponent, isStandalone: true, selector: "val-conversation-list-item", inputs: { name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: false, transformFunction: null }, avatarUrl: { classPropertyName: "avatarUrl", publicName: "avatarUrl", isSignal: true, isRequired: false, transformFunction: null }, lastMessage: { classPropertyName: "lastMessage", publicName: "lastMessage", isSignal: true, isRequired: false, transformFunction: null }, timestamp: { classPropertyName: "timestamp", publicName: "timestamp", isSignal: true, isRequired: false, transformFunction: null }, unreadCount: { classPropertyName: "unreadCount", publicName: "unreadCount", isSignal: true, isRequired: false, transformFunction: null }, locale: { classPropertyName: "locale", publicName: "locale", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { select: "select" }, ngImport: i0, template: `
73294
+ <button class="item" (click)="select.emit()">
73295
+ <div class="avatar">
73296
+ @if (avatarUrl()) {
73297
+ <img [src]="avatarUrl()" [alt]="name()" />
73298
+ } @else {
73299
+ <span>{{ initials() }}</span>
73300
+ }
73301
+ </div>
73302
+ <div class="content">
73303
+ <div class="top">
73304
+ <span class="name">{{ name() }}</span>
73305
+ @if (timeLabel()) {
73306
+ <span class="time" [class.unread]="unreadCount() > 0">{{ timeLabel() }}</span>
73307
+ }
73308
+ </div>
73309
+ <div class="bottom">
73310
+ <span class="preview" [class.muted]="!lastMessage()">
73311
+ {{ lastMessage() || emptyText() }}
73312
+ </span>
73313
+ @if (unreadCount() > 0) {
73314
+ <span class="badge">{{ unreadCount() > 99 ? '99+' : unreadCount() }}</span>
73315
+ }
73316
+ </div>
73317
+ </div>
73318
+ </button>
73319
+ `, isInline: true, styles: [":host{display:block}.item{display:flex;align-items:center;gap:12px;width:100%;padding:10px 12px;border:none;background:transparent;cursor:pointer;text-align:left;border-radius:12px;transition:background .12s ease}.item:hover{background:var(--ion-color-step-50, rgba(127, 127, 127, .06))}.avatar{width:48px;height:48px;flex-shrink:0;border-radius:50%;overflow:hidden;display:flex;align-items:center;justify-content:center;background:var(--ion-color-primary);color:#fff;font-weight:700;font-size:1rem}.avatar img{width:100%;height:100%;object-fit:cover}.content{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.top,.bottom{display:flex;align-items:center;gap:8px}.name{flex:1;min-width:0;font-weight:600;font-size:.9375rem;color:var(--ion-text-color, #000);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.time{flex-shrink:0;font-size:.75rem;color:var(--ion-color-medium, #92949c)}.time.unread{color:var(--ion-color-primary);font-weight:600}.preview{flex:1;min-width:0;font-size:.875rem;color:var(--ion-color-medium, #92949c);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.preview.muted{font-style:italic;opacity:.7}.badge{flex-shrink:0;min-width:20px;height:20px;padding:0 6px;border-radius:10px;background:var(--ion-color-primary);color:var(--ion-color-primary-contrast, #fff);font-size:.6875rem;font-weight:700;display:inline-flex;align-items:center;justify-content:center}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }] }); }
73320
+ }
73321
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ConversationListItemComponent, decorators: [{
73322
+ type: Component,
73323
+ args: [{ selector: 'val-conversation-list-item', standalone: true, imports: [CommonModule], template: `
73324
+ <button class="item" (click)="select.emit()">
73325
+ <div class="avatar">
73326
+ @if (avatarUrl()) {
73327
+ <img [src]="avatarUrl()" [alt]="name()" />
73328
+ } @else {
73329
+ <span>{{ initials() }}</span>
73330
+ }
73331
+ </div>
73332
+ <div class="content">
73333
+ <div class="top">
73334
+ <span class="name">{{ name() }}</span>
73335
+ @if (timeLabel()) {
73336
+ <span class="time" [class.unread]="unreadCount() > 0">{{ timeLabel() }}</span>
73337
+ }
73338
+ </div>
73339
+ <div class="bottom">
73340
+ <span class="preview" [class.muted]="!lastMessage()">
73341
+ {{ lastMessage() || emptyText() }}
73342
+ </span>
73343
+ @if (unreadCount() > 0) {
73344
+ <span class="badge">{{ unreadCount() > 99 ? '99+' : unreadCount() }}</span>
73345
+ }
73346
+ </div>
73347
+ </div>
73348
+ </button>
73349
+ `, styles: [":host{display:block}.item{display:flex;align-items:center;gap:12px;width:100%;padding:10px 12px;border:none;background:transparent;cursor:pointer;text-align:left;border-radius:12px;transition:background .12s ease}.item:hover{background:var(--ion-color-step-50, rgba(127, 127, 127, .06))}.avatar{width:48px;height:48px;flex-shrink:0;border-radius:50%;overflow:hidden;display:flex;align-items:center;justify-content:center;background:var(--ion-color-primary);color:#fff;font-weight:700;font-size:1rem}.avatar img{width:100%;height:100%;object-fit:cover}.content{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.top,.bottom{display:flex;align-items:center;gap:8px}.name{flex:1;min-width:0;font-weight:600;font-size:.9375rem;color:var(--ion-text-color, #000);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.time{flex-shrink:0;font-size:.75rem;color:var(--ion-color-medium, #92949c)}.time.unread{color:var(--ion-color-primary);font-weight:600}.preview{flex:1;min-width:0;font-size:.875rem;color:var(--ion-color-medium, #92949c);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.preview.muted{font-style:italic;opacity:.7}.badge{flex-shrink:0;min-width:20px;height:20px;padding:0 6px;border-radius:10px;background:var(--ion-color-primary);color:var(--ion-color-primary-contrast, #fff);font-size:.6875rem;font-weight:700;display:inline-flex;align-items:center;justify-content:center}\n"] }]
73350
+ }], ctorParameters: () => [] });
73351
+
72671
73352
  const TYPING_I18N = {
72672
73353
  es: {
72673
73354
  one: '{name} esta escribiendo',
@@ -72683,8 +73364,9 @@ const TYPING_I18N = {
72683
73364
  /**
72684
73365
  * val-typing-indicator
72685
73366
  *
72686
- * Muestra quienes estan escribiendo en el chat con animacion de tres puntos.
72687
- * Se oculta automaticamente si typingUsers esta vacio.
73367
+ * Muestra quienes estan escribiendo con animacion de tres puntos. Se oculta
73368
+ * si `typingUsers` esta vacio. Usa signal input para que `typingText` (computed)
73369
+ * recompute al cambiar la lista.
72688
73370
  *
72689
73371
  * @example
72690
73372
  * <val-typing-indicator [typingUsers]="['Maria', 'Carlos']" />
@@ -72692,10 +73374,10 @@ const TYPING_I18N = {
72692
73374
  class TypingIndicatorComponent {
72693
73375
  constructor() {
72694
73376
  this.i18n = inject(I18nService);
72695
- this.typingUsers = [];
73377
+ this.typingUsers = input([]);
72696
73378
  this.typingText = computed(() => {
72697
73379
  this.i18n.lang();
72698
- const users = this.typingUsers ?? [];
73380
+ const users = this.typingUsers();
72699
73381
  if (users.length === 0)
72700
73382
  return '';
72701
73383
  if (users.length === 1) {
@@ -72709,8 +73391,8 @@ class TypingIndicatorComponent {
72709
73391
  this.i18n.registerDefaults('TypingIndicator', TYPING_I18N);
72710
73392
  }
72711
73393
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: TypingIndicatorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
72712
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: TypingIndicatorComponent, isStandalone: true, selector: "val-typing-indicator", inputs: { typingUsers: "typingUsers" }, ngImport: i0, template: `
72713
- @if (typingUsers && typingUsers.length > 0) {
73394
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: TypingIndicatorComponent, isStandalone: true, selector: "val-typing-indicator", inputs: { typingUsers: { classPropertyName: "typingUsers", publicName: "typingUsers", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
73395
+ @if (typingUsers().length > 0) {
72714
73396
  <div class="typing-indicator">
72715
73397
  <div class="dots">
72716
73398
  <span></span>
@@ -72725,7 +73407,7 @@ class TypingIndicatorComponent {
72725
73407
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: TypingIndicatorComponent, decorators: [{
72726
73408
  type: Component,
72727
73409
  args: [{ selector: 'val-typing-indicator', standalone: true, imports: [CommonModule], template: `
72728
- @if (typingUsers && typingUsers.length > 0) {
73410
+ @if (typingUsers().length > 0) {
72729
73411
  <div class="typing-indicator">
72730
73412
  <div class="dots">
72731
73413
  <span></span>
@@ -72736,270 +73418,277 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
72736
73418
  </div>
72737
73419
  }
72738
73420
  `, styles: [".typing-indicator{display:flex;align-items:center;gap:8px;padding:4px 12px;min-height:28px}.typing-text{font-size:.8125rem;color:var(--ion-color-medium, #92949c);font-style:italic}.dots{display:flex;align-items:center;gap:3px}.dots span{display:inline-block;width:5px;height:5px;border-radius:50%;background:var(--ion-color-medium, #92949c);animation:typing-dot 1.2s infinite ease-in-out}.dots span:nth-child(1){animation-delay:0s}.dots span:nth-child(2){animation-delay:.2s}.dots span:nth-child(3){animation-delay:.4s}@keyframes typing-dot{0%,60%,to{transform:translateY(0);opacity:.4}30%{transform:translateY(-4px);opacity:1}}\n"] }]
72739
- }], ctorParameters: () => [], propDecorators: { typingUsers: [{
72740
- type: Input
72741
- }] } });
73421
+ }], ctorParameters: () => [] });
72742
73422
 
72743
- addIcons({ chatbubblesOutline });
73423
+ addIcons({ arrowDownOutline, chatbubblesOutline });
72744
73424
  const CHAT_WINDOW_I18N = {
72745
73425
  es: {
72746
73426
  conversationClosed: 'Esta conversacion esta cerrada',
72747
73427
  loading: 'Cargando mensajes...',
72748
- noMessages: 'No hay mensajes aun',
73428
+ noMessages: 'Aun no hay mensajes. Escribe el primero.',
73429
+ newMessages: 'Mensajes nuevos',
72749
73430
  },
72750
73431
  en: {
72751
73432
  conversationClosed: 'This conversation is closed',
72752
73433
  loading: 'Loading messages...',
72753
- noMessages: 'No messages yet',
73434
+ noMessages: 'No messages yet. Send the first one.',
73435
+ newMessages: 'New messages',
72754
73436
  },
72755
73437
  };
72756
73438
  /**
72757
- * val-chat-window
73439
+ * val-chat-window — ventana de chat completa (signal inputs).
72758
73440
  *
72759
- * Ventana de chat completa. Muestra la lista de mensajes agrupados por fecha,
72760
- * el indicador de escritura, el input de mensaje (solo si isOpen) y un banner
72761
- * cuando la conversacion esta cerrada. Auto-scroll al ultimo mensaje cuando
72762
- * llegan mensajes nuevos.
73441
+ * Agrupa mensajes consecutivos del mismo emisor y por día (separadores sticky),
73442
+ * muestra avatar/nombre solo en los bordes del grupo, scroll-to-bottom inteligente
73443
+ * (auto-scroll si estás abajo, pill "mensajes nuevos" si estás leyendo arriba),
73444
+ * composer moderno con contexto de respuesta, y typing indicator.
72763
73445
  *
72764
- * @example
72765
- * <val-chat-window
72766
- * [convId]="conv.id"
72767
- * [messages]="messages()"
72768
- * [currentUserId]="userId"
72769
- * [isOpen]="conv.status === 'open'"
72770
- * [typingUsers]="typingUsers()"
72771
- * (sendMessage)="onSend($event)"
72772
- * (reactionClick)="onReaction($event)"
72773
- * />
73446
+ * API pública estable (consumida por thread-panel y showcase): inputs convId,
73447
+ * messages, currentUserId, isOpen, typingUsers, isLoading; outputs sendMessage,
73448
+ * loadMore, reactionClick, deleteMessage, replyTo (+ editMessage/attach/voice nuevos).
72774
73449
  */
72775
73450
  class ChatWindowComponent {
72776
73451
  constructor() {
72777
73452
  this.i18n = inject(I18nService);
72778
- this.convId = '';
72779
- this.messages = [];
72780
- this.participants = [];
72781
- this.currentUserId = '';
72782
- this.isOpen = true;
72783
- this.typingUsers = [];
72784
- this.isLoading = false;
72785
- this.sendMessage = new EventEmitter();
72786
- this.loadMore = new EventEmitter();
72787
- this.reactionClick = new EventEmitter();
72788
- this.deleteMessage = new EventEmitter();
72789
- this.replyTo = new EventEmitter();
72790
- this.prevMessageCount = 0;
72791
- this.shouldScrollToBottom = false;
72792
- this.messageGroups = computed(() => {
72793
- const msgs = this.messages ?? [];
73453
+ this.convId = input('');
73454
+ this.messages = input([]);
73455
+ this.currentUserId = input('');
73456
+ this.isOpen = input(true);
73457
+ this.typingUsers = input([]);
73458
+ this.isLoading = input(false);
73459
+ this.sendMessage = output();
73460
+ this.loadMore = output();
73461
+ this.reactionClick = output();
73462
+ this.deleteMessage = output();
73463
+ this.replyTo = output();
73464
+ this.editMessage = output();
73465
+ this.typing = output();
73466
+ this.voice = output();
73467
+ this.msgsEl = viewChild('msgs');
73468
+ this.replyingTo = signal(null);
73469
+ this.atBottom = signal(true);
73470
+ this.hasNew = signal(false);
73471
+ this.locale = computed(() => (this.i18n.lang() === 'en' ? 'en-US' : 'es-CL'));
73472
+ this.rows = computed(() => {
73473
+ const msgs = this.messages();
72794
73474
  if (msgs.length === 0)
72795
73475
  return [];
72796
- const groups = [];
72797
- let currentLabel = '';
72798
- let currentGroup = [];
72799
- for (const msg of msgs) {
72800
- const label = this.formatDateLabel(msg.createdAt);
72801
- if (label !== currentLabel) {
72802
- if (currentGroup.length > 0) {
72803
- groups.push({ dateLabel: currentLabel, messages: currentGroup });
72804
- }
72805
- currentLabel = label;
72806
- currentGroup = [msg];
72807
- }
72808
- else {
72809
- currentGroup.push(msg);
73476
+ const labels = {
73477
+ today: this.i18n.t('today', '_global'),
73478
+ yesterday: this.i18n.t('yesterday', '_global'),
73479
+ };
73480
+ const out = [];
73481
+ let prevDay = '';
73482
+ for (let i = 0; i < msgs.length; i++) {
73483
+ const m = msgs[i];
73484
+ const d = m.createdAt instanceof Date ? m.createdAt : new Date(m.createdAt);
73485
+ const day = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
73486
+ const newDay = day !== prevDay;
73487
+ if (newDay) {
73488
+ out.push({ kind: 'date', id: 'date-' + day, label: formatDateSeparator(d, this.locale(), labels) });
73489
+ prevDay = day;
72810
73490
  }
73491
+ const prev = msgs[i - 1];
73492
+ const next = msgs[i + 1];
73493
+ const firstInGroup = newDay || !prev || prev.senderId !== m.senderId;
73494
+ const nextDay = next
73495
+ ? `${new Date(next.createdAt).getFullYear()}-${new Date(next.createdAt).getMonth()}-${new Date(next.createdAt).getDate()}`
73496
+ : '';
73497
+ const lastInGroup = !next || next.senderId !== m.senderId || nextDay !== day;
73498
+ out.push({
73499
+ kind: 'msg',
73500
+ id: m.msgId,
73501
+ msg: m,
73502
+ showName: firstInGroup && !m.isMine,
73503
+ showAvatar: lastInGroup && !m.isMine,
73504
+ tail: lastInGroup,
73505
+ });
72811
73506
  }
72812
- if (currentGroup.length > 0) {
72813
- groups.push({ dateLabel: currentLabel, messages: currentGroup });
72814
- }
72815
- return groups;
72816
- });
72817
- this.closedText = computed(() => {
72818
- this.i18n.lang();
72819
- return this.i18n.t('conversationClosed', 'ChatWindow');
72820
- });
72821
- this.loadingText = computed(() => {
72822
- this.i18n.lang();
72823
- return this.i18n.t('loading', 'ChatWindow');
73507
+ return out;
72824
73508
  });
72825
73509
  this.i18n.registerDefaults('ChatWindow', CHAT_WINDOW_I18N);
72826
- }
72827
- ngAfterViewInit() {
72828
- if (typeof window !== 'undefined' && window.visualViewport) {
72829
- this.vvListener = () => this.adjustToKeyboard();
72830
- window.visualViewport.addEventListener('resize', this.vvListener);
72831
- this.adjustToKeyboard();
72832
- }
72833
- }
72834
- ngOnDestroy() {
72835
- if (this.vvListener && typeof window !== 'undefined' && window.visualViewport) {
72836
- window.visualViewport.removeEventListener('resize', this.vvListener);
72837
- }
72838
- }
72839
- ngOnChanges(changes) {
72840
- if (changes['messages']) {
72841
- const current = changes['messages'].currentValue ?? [];
72842
- if (current.length !== this.prevMessageCount) {
72843
- this.prevMessageCount = current.length;
72844
- this.shouldScrollToBottom = true;
73510
+ // Auto-scroll al fondo cuando llegan mensajes y el usuario ya estaba abajo
73511
+ // (o el último mensaje es propio). Si está leyendo arriba, marca "nuevos".
73512
+ let prevCount = 0;
73513
+ effect(() => {
73514
+ const rows = this.rows();
73515
+ const count = rows.length;
73516
+ if (count === prevCount)
73517
+ return;
73518
+ const grew = count > prevCount;
73519
+ prevCount = count;
73520
+ const last = this.messages()[this.messages().length - 1];
73521
+ const mine = last?.isMine ?? false;
73522
+ if (this.atBottom() || mine) {
73523
+ setTimeout(() => this.scrollToBottom(false), 0);
72845
73524
  }
72846
- }
72847
- }
72848
- ngAfterViewChecked() {
72849
- if (this.shouldScrollToBottom) {
72850
- this.scrollToBottom();
72851
- this.shouldScrollToBottom = false;
72852
- }
73525
+ else if (grew) {
73526
+ this.hasNew.set(true);
73527
+ }
73528
+ });
72853
73529
  }
72854
- adjustToKeyboard() {
72855
- const el = this.chatWindowElRef?.nativeElement;
72856
- if (!el || !window.visualViewport)
73530
+ onScroll() {
73531
+ const el = this.msgsEl()?.nativeElement;
73532
+ if (!el)
72857
73533
  return;
72858
- const rect = el.getBoundingClientRect();
72859
- const available = window.visualViewport.height - rect.top;
72860
- if (available > 50) {
72861
- el.style.height = `${available}px`;
72862
- }
72863
- }
72864
- onReactionClick(event) {
72865
- this.reactionClick.emit({ msgId: event.msgId, token: event.token });
73534
+ const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
73535
+ this.atBottom.set(nearBottom);
73536
+ if (nearBottom)
73537
+ this.hasNew.set(false);
73538
+ if (el.scrollTop < 40)
73539
+ this.loadMore.emit();
72866
73540
  }
72867
- onEditClick(_msgId) {
72868
- // Implementacion futura: emitir evento de edicion
73541
+ scrollToBottom(smooth) {
73542
+ const el = this.msgsEl()?.nativeElement;
73543
+ if (!el)
73544
+ return;
73545
+ el.scrollTo({ top: el.scrollHeight, behavior: smooth ? 'smooth' : 'auto' });
73546
+ this.atBottom.set(true);
73547
+ this.hasNew.set(false);
72869
73548
  }
72870
- onTyping() {
72871
- // El padre puede suscribirse al output typing si necesita propagar a Firestore
73549
+ onSend(event) {
73550
+ this.sendMessage.emit(event);
73551
+ this.replyingTo.set(null);
72872
73552
  }
72873
- scrollToBottom() {
72874
- const el = this.messagesListRef?.nativeElement;
72875
- if (el) {
72876
- el.scrollTop = el.scrollHeight;
73553
+ onAction(event) {
73554
+ switch (event.type) {
73555
+ case 'reply': {
73556
+ const m = this.messages().find(x => x.msgId === event.msgId);
73557
+ if (m) {
73558
+ this.replyingTo.set({ msgId: m.msgId, senderName: m.senderName, body: m.body });
73559
+ }
73560
+ this.replyTo.emit(event.msgId);
73561
+ break;
73562
+ }
73563
+ case 'edit':
73564
+ this.editMessage.emit(event.msgId);
73565
+ break;
73566
+ case 'delete':
73567
+ this.deleteMessage.emit(event.msgId);
73568
+ break;
73569
+ case 'react':
73570
+ this.reactionClick.emit({ msgId: event.msgId, token: event.token ?? '👍' });
73571
+ break;
72877
73572
  }
72878
73573
  }
72879
- formatDateLabel(date) {
72880
- if (!date)
72881
- return '';
72882
- const d = date instanceof Date ? date : new Date(date);
72883
- const now = new Date();
72884
- const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
72885
- const msgDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
72886
- const diffMs = today.getTime() - msgDay.getTime();
72887
- const diffDays = Math.round(diffMs / 86400000);
72888
- if (diffDays === 0)
72889
- return this.i18n.t('today', '_global') || 'Hoy';
72890
- if (diffDays === 1)
72891
- return this.i18n.t('yesterday', '_global') || 'Ayer';
72892
- return d.toLocaleDateString([], { day: '2-digit', month: 'short', year: 'numeric' });
73574
+ t(key) {
73575
+ return this.i18n.t(key, 'ChatWindow');
72893
73576
  }
72894
73577
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ChatWindowComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
72895
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ChatWindowComponent, isStandalone: true, selector: "val-chat-window", inputs: { convId: "convId", messages: "messages", participants: "participants", currentUserId: "currentUserId", isOpen: "isOpen", typingUsers: "typingUsers", isLoading: "isLoading" }, outputs: { sendMessage: "sendMessage", loadMore: "loadMore", reactionClick: "reactionClick", deleteMessage: "deleteMessage", replyTo: "replyTo" }, viewQueries: [{ propertyName: "messagesListRef", first: true, predicate: ["messagesList"], descendants: true }, { propertyName: "chatWindowElRef", first: true, predicate: ["chatWindowEl"], descendants: true }], usesOnChanges: true, ngImport: i0, template: `
72896
- <div class="chat-window" #chatWindowEl>
72897
- <div class="messages-list" #messagesList>
72898
- @if (isLoading) {
72899
- <div class="loading-state">
72900
- <ion-spinner name="dots"></ion-spinner>
72901
- <span>{{ loadingText() }}</span>
73578
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ChatWindowComponent, isStandalone: true, selector: "val-chat-window", inputs: { convId: { classPropertyName: "convId", publicName: "convId", isSignal: true, isRequired: false, transformFunction: null }, messages: { classPropertyName: "messages", publicName: "messages", isSignal: true, isRequired: false, transformFunction: null }, currentUserId: { classPropertyName: "currentUserId", publicName: "currentUserId", isSignal: true, isRequired: false, transformFunction: null }, isOpen: { classPropertyName: "isOpen", publicName: "isOpen", isSignal: true, isRequired: false, transformFunction: null }, typingUsers: { classPropertyName: "typingUsers", publicName: "typingUsers", isSignal: true, isRequired: false, transformFunction: null }, isLoading: { classPropertyName: "isLoading", publicName: "isLoading", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { sendMessage: "sendMessage", loadMore: "loadMore", reactionClick: "reactionClick", deleteMessage: "deleteMessage", replyTo: "replyTo", editMessage: "editMessage", typing: "typing", voice: "voice" }, viewQueries: [{ propertyName: "msgsEl", first: true, predicate: ["msgs"], descendants: true, isSignal: true }], ngImport: i0, template: `
73579
+ <div class="chat">
73580
+ <div #msgs class="messages" (scroll)="onScroll()">
73581
+ @if (isLoading()) {
73582
+ <div class="state">
73583
+ <span>{{ t('loading') }}</span>
73584
+ </div>
73585
+ } @else if (rows().length === 0) {
73586
+ <div class="state empty">
73587
+ <ion-icon name="chatbubbles-outline" aria-hidden="true" />
73588
+ <span>{{ t('noMessages') }}</span>
72902
73589
  </div>
72903
73590
  } @else {
72904
- @for (group of messageGroups(); track group.dateLabel) {
72905
- <div class="date-separator">
72906
- <div class="date-line"></div>
72907
- <span class="date-label">{{ group.dateLabel }}</span>
72908
- <div class="date-line"></div>
72909
- </div>
72910
- @for (msg of group.messages; track msg.msgId) {
73591
+ @for (row of rows(); track row.id) {
73592
+ @if (row.kind === 'date') {
73593
+ <div class="date-sep">
73594
+ <span>{{ row.label }}</span>
73595
+ </div>
73596
+ } @else {
72911
73597
  <val-message-bubble
72912
- [props]="msg"
72913
- (reactionClick)="onReactionClick($event)"
72914
- (replyClick)="replyTo.emit($event.msgId)"
72915
- (deleteClick)="deleteMessage.emit($event.msgId)"
72916
- (editClick)="onEditClick($event.msgId)"
73598
+ [msg]="row.msg"
73599
+ [showName]="row.showName"
73600
+ [showAvatar]="row.showAvatar"
73601
+ [tail]="row.tail"
73602
+ [locale]="locale()"
73603
+ (action)="onAction($event)"
72917
73604
  />
72918
73605
  }
72919
73606
  }
72920
73607
  }
73608
+ <val-typing-indicator [typingUsers]="typingUsers()" />
72921
73609
  </div>
72922
73610
 
72923
- <val-typing-indicator [typingUsers]="typingUsers" />
73611
+ @if (!atBottom()) {
73612
+ <button class="scroll-down" (click)="scrollToBottom(true)" aria-label="scroll">
73613
+ <ion-icon name="arrow-down-outline" aria-hidden="true" />
73614
+ @if (hasNew()) {
73615
+ <span class="dot"></span>
73616
+ }
73617
+ </button>
73618
+ }
72924
73619
 
72925
- @if (!isOpen) {
72926
- <div class="closed-banner">{{ closedText() }}</div>
73620
+ @if (isOpen()) {
73621
+ <val-chat-composer
73622
+ [replyingTo]="replyingTo()"
73623
+ (send)="onSend($event)"
73624
+ (typing)="typing.emit()"
73625
+ (voice)="voice.emit()"
73626
+ (cancelReply)="replyingTo.set(null)"
73627
+ />
72927
73628
  } @else {
72928
- <val-chat-input [props]="{ disabled: isLoading }" (send)="sendMessage.emit($event)" (typing)="onTyping()" />
73629
+ <div class="closed-banner">{{ t('conversationClosed') }}</div>
72929
73630
  }
72930
73631
  </div>
72931
- `, isInline: true, styles: [".chat-window{display:flex;flex-direction:column;height:100%;background:var(--ion-background-color, #f4f5f8);overflow:hidden}.messages-list{flex:1;overflow-y:auto;padding:12px 8px;scroll-behavior:smooth}.messages-list::-webkit-scrollbar{width:4px}.messages-list::-webkit-scrollbar-thumb{background:var(--ion-color-light-shade, #d7d8da);border-radius:2px}.date-separator{display:flex;align-items:center;gap:8px;margin:12px 0}.date-separator .date-line{flex:1;height:1px;background:var(--ion-border-color, rgba(0, 0, 0, .1))}.date-separator .date-label{font-size:.75rem;color:var(--ion-color-medium, #92949c);white-space:nowrap;padding:0 4px}.loading-state{display:flex;align-items:center;justify-content:center;padding:24px;color:var(--ion-color-medium, #92949c);font-size:.875rem;gap:8px}.closed-banner{padding:10px 16px;background:var(--ion-color-warning-tint, #fff9e6);border-top:1px solid var(--ion-color-warning, #ffc409);text-align:center;font-size:.875rem;color:var(--ion-color-warning-shade, #b29500)}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }, { kind: "component", type: MessageBubbleComponent, selector: "val-message-bubble", inputs: ["props"], outputs: ["reactionClick", "replyClick", "deleteClick", "editClick"] }, { kind: "component", type: ChatInputComponent, selector: "val-chat-input", inputs: ["props"], outputs: ["send", "typing"] }, { kind: "component", type: TypingIndicatorComponent, selector: "val-typing-indicator", inputs: ["typingUsers"] }] }); }
73632
+ `, isInline: true, styles: [":host{display:block;height:100%}.chat{position:relative;display:flex;flex-direction:column;height:100%;min-height:0}.messages{flex:1;min-height:0;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:0}.state{margin:auto;display:flex;flex-direction:column;align-items:center;gap:8px;color:var(--ion-color-medium, #92949c);font-size:.9375rem;text-align:center;padding:24px}.state.empty ion-icon{font-size:2.5rem;opacity:.5}.date-sep{position:sticky;top:4px;z-index:1;display:flex;justify-content:center;margin:8px 0}.date-sep span{font-size:.75rem;color:var(--ion-color-medium, #92949c);background:var(--ion-color-step-100, rgba(127, 127, 127, .14));padding:3px 12px;border-radius:999px;backdrop-filter:blur(4px)}.scroll-down{position:absolute;right:14px;bottom:84px;width:40px;height:40px;border-radius:50%;border:1px solid var(--ion-border-color, rgba(0, 0, 0, .1));background:var(--ion-card-background, var(--ion-background-color, #fff));color:var(--ion-color-medium, #92949c);box-shadow:0 2px 10px #00000026;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;font-size:1.25rem;z-index:3}.scroll-down .dot{position:absolute;top:0;right:0;width:12px;height:12px;border-radius:50%;background:var(--ion-color-primary);border:2px solid var(--ion-card-background, #fff)}.closed-banner{text-align:center;padding:14px;color:var(--ion-color-medium, #92949c);font-size:.875rem;border-top:1px solid var(--ion-border-color, rgba(0, 0, 0, .08))}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }, { kind: "component", type: MessageBubbleComponent, selector: "val-message-bubble", inputs: ["msg", "showAvatar", "showName", "tail", "locale"], outputs: ["action"] }, { kind: "component", type: ChatComposerComponent, selector: "val-chat-composer", inputs: ["placeholder", "disabled", "maxLength", "replyingTo", "showAttach", "showMic"], outputs: ["send", "typing", "voice", "cancelReply"] }, { kind: "component", type: TypingIndicatorComponent, selector: "val-typing-indicator", inputs: ["typingUsers"] }] }); }
72932
73633
  }
72933
73634
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ChatWindowComponent, decorators: [{
72934
73635
  type: Component,
72935
- args: [{ selector: 'val-chat-window', standalone: true, imports: [CommonModule, IonIcon, IonSpinner, MessageBubbleComponent, ChatInputComponent, TypingIndicatorComponent], template: `
72936
- <div class="chat-window" #chatWindowEl>
72937
- <div class="messages-list" #messagesList>
72938
- @if (isLoading) {
72939
- <div class="loading-state">
72940
- <ion-spinner name="dots"></ion-spinner>
72941
- <span>{{ loadingText() }}</span>
73636
+ args: [{ selector: 'val-chat-window', standalone: true, imports: [CommonModule, IonIcon, MessageBubbleComponent, ChatComposerComponent, TypingIndicatorComponent], template: `
73637
+ <div class="chat">
73638
+ <div #msgs class="messages" (scroll)="onScroll()">
73639
+ @if (isLoading()) {
73640
+ <div class="state">
73641
+ <span>{{ t('loading') }}</span>
73642
+ </div>
73643
+ } @else if (rows().length === 0) {
73644
+ <div class="state empty">
73645
+ <ion-icon name="chatbubbles-outline" aria-hidden="true" />
73646
+ <span>{{ t('noMessages') }}</span>
72942
73647
  </div>
72943
73648
  } @else {
72944
- @for (group of messageGroups(); track group.dateLabel) {
72945
- <div class="date-separator">
72946
- <div class="date-line"></div>
72947
- <span class="date-label">{{ group.dateLabel }}</span>
72948
- <div class="date-line"></div>
72949
- </div>
72950
- @for (msg of group.messages; track msg.msgId) {
73649
+ @for (row of rows(); track row.id) {
73650
+ @if (row.kind === 'date') {
73651
+ <div class="date-sep">
73652
+ <span>{{ row.label }}</span>
73653
+ </div>
73654
+ } @else {
72951
73655
  <val-message-bubble
72952
- [props]="msg"
72953
- (reactionClick)="onReactionClick($event)"
72954
- (replyClick)="replyTo.emit($event.msgId)"
72955
- (deleteClick)="deleteMessage.emit($event.msgId)"
72956
- (editClick)="onEditClick($event.msgId)"
73656
+ [msg]="row.msg"
73657
+ [showName]="row.showName"
73658
+ [showAvatar]="row.showAvatar"
73659
+ [tail]="row.tail"
73660
+ [locale]="locale()"
73661
+ (action)="onAction($event)"
72957
73662
  />
72958
73663
  }
72959
73664
  }
72960
73665
  }
73666
+ <val-typing-indicator [typingUsers]="typingUsers()" />
72961
73667
  </div>
72962
73668
 
72963
- <val-typing-indicator [typingUsers]="typingUsers" />
73669
+ @if (!atBottom()) {
73670
+ <button class="scroll-down" (click)="scrollToBottom(true)" aria-label="scroll">
73671
+ <ion-icon name="arrow-down-outline" aria-hidden="true" />
73672
+ @if (hasNew()) {
73673
+ <span class="dot"></span>
73674
+ }
73675
+ </button>
73676
+ }
72964
73677
 
72965
- @if (!isOpen) {
72966
- <div class="closed-banner">{{ closedText() }}</div>
73678
+ @if (isOpen()) {
73679
+ <val-chat-composer
73680
+ [replyingTo]="replyingTo()"
73681
+ (send)="onSend($event)"
73682
+ (typing)="typing.emit()"
73683
+ (voice)="voice.emit()"
73684
+ (cancelReply)="replyingTo.set(null)"
73685
+ />
72967
73686
  } @else {
72968
- <val-chat-input [props]="{ disabled: isLoading }" (send)="sendMessage.emit($event)" (typing)="onTyping()" />
73687
+ <div class="closed-banner">{{ t('conversationClosed') }}</div>
72969
73688
  }
72970
73689
  </div>
72971
- `, styles: [".chat-window{display:flex;flex-direction:column;height:100%;background:var(--ion-background-color, #f4f5f8);overflow:hidden}.messages-list{flex:1;overflow-y:auto;padding:12px 8px;scroll-behavior:smooth}.messages-list::-webkit-scrollbar{width:4px}.messages-list::-webkit-scrollbar-thumb{background:var(--ion-color-light-shade, #d7d8da);border-radius:2px}.date-separator{display:flex;align-items:center;gap:8px;margin:12px 0}.date-separator .date-line{flex:1;height:1px;background:var(--ion-border-color, rgba(0, 0, 0, .1))}.date-separator .date-label{font-size:.75rem;color:var(--ion-color-medium, #92949c);white-space:nowrap;padding:0 4px}.loading-state{display:flex;align-items:center;justify-content:center;padding:24px;color:var(--ion-color-medium, #92949c);font-size:.875rem;gap:8px}.closed-banner{padding:10px 16px;background:var(--ion-color-warning-tint, #fff9e6);border-top:1px solid var(--ion-color-warning, #ffc409);text-align:center;font-size:.875rem;color:var(--ion-color-warning-shade, #b29500)}\n"] }]
72972
- }], ctorParameters: () => [], propDecorators: { convId: [{
72973
- type: Input
72974
- }], messages: [{
72975
- type: Input
72976
- }], participants: [{
72977
- type: Input
72978
- }], currentUserId: [{
72979
- type: Input
72980
- }], isOpen: [{
72981
- type: Input
72982
- }], typingUsers: [{
72983
- type: Input
72984
- }], isLoading: [{
72985
- type: Input
72986
- }], sendMessage: [{
72987
- type: Output
72988
- }], loadMore: [{
72989
- type: Output
72990
- }], reactionClick: [{
72991
- type: Output
72992
- }], deleteMessage: [{
72993
- type: Output
72994
- }], replyTo: [{
72995
- type: Output
72996
- }], messagesListRef: [{
72997
- type: ViewChild,
72998
- args: ['messagesList']
72999
- }], chatWindowElRef: [{
73000
- type: ViewChild,
73001
- args: ['chatWindowEl']
73002
- }] } });
73690
+ `, styles: [":host{display:block;height:100%}.chat{position:relative;display:flex;flex-direction:column;height:100%;min-height:0}.messages{flex:1;min-height:0;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:0}.state{margin:auto;display:flex;flex-direction:column;align-items:center;gap:8px;color:var(--ion-color-medium, #92949c);font-size:.9375rem;text-align:center;padding:24px}.state.empty ion-icon{font-size:2.5rem;opacity:.5}.date-sep{position:sticky;top:4px;z-index:1;display:flex;justify-content:center;margin:8px 0}.date-sep span{font-size:.75rem;color:var(--ion-color-medium, #92949c);background:var(--ion-color-step-100, rgba(127, 127, 127, .14));padding:3px 12px;border-radius:999px;backdrop-filter:blur(4px)}.scroll-down{position:absolute;right:14px;bottom:84px;width:40px;height:40px;border-radius:50%;border:1px solid var(--ion-border-color, rgba(0, 0, 0, .1));background:var(--ion-card-background, var(--ion-background-color, #fff));color:var(--ion-color-medium, #92949c);box-shadow:0 2px 10px #00000026;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;font-size:1.25rem;z-index:3}.scroll-down .dot{position:absolute;top:0;right:0;width:12px;height:12px;border-radius:50%;background:var(--ion-color-primary);border:2px solid var(--ion-card-background, #fff)}.closed-banner{text-align:center;padding:14px;color:var(--ion-color-medium, #92949c);font-size:.875rem;border-top:1px solid var(--ion-border-color, rgba(0, 0, 0, .08))}\n"] }]
73691
+ }], ctorParameters: () => [] });
73003
73692
 
73004
73693
  const THREAD_PANEL_I18N = {
73005
73694
  es: {
@@ -73042,7 +73731,7 @@ class ThreadPanelComponent {
73042
73731
  this.conv = signal(null);
73043
73732
  this.messages = signal([]);
73044
73733
  this.typingUsers = signal([]);
73045
- this.isConvOpen = computed(() => this.conv()?.status === 'open');
73734
+ this.isConvOpen = computed(() => this.conv()?.settings?.isOpen === true);
73046
73735
  this.loadingText = computed(() => {
73047
73736
  this.i18n.lang();
73048
73737
  return this.i18n.t('loading', 'ThreadPanel');
@@ -73080,10 +73769,10 @@ class ThreadPanelComponent {
73080
73769
  const appId = this.chatConfig?.appId ?? '';
73081
73770
  if (orgId && appId) {
73082
73771
  this.msgSub = this.conversationSvc
73083
- .watchMessages(conversation.id, orgId, appId)
73772
+ .watchMessages(conversation.convId, orgId, appId)
73084
73773
  .subscribe(msgs => this.messages.set(msgs));
73085
73774
  this.typingSub = this.conversationSvc
73086
- .watchTyping(conversation.id, orgId, appId)
73775
+ .watchTyping(conversation.convId, orgId, appId)
73087
73776
  .subscribe(users => this.typingUsers.set(users));
73088
73777
  }
73089
73778
  }
@@ -73101,14 +73790,16 @@ class ThreadPanelComponent {
73101
73790
  const orgId = this.getOrgId();
73102
73791
  const appId = this.chatConfig?.appId ?? '';
73103
73792
  if (conv && orgId && appId && this.conversationSvc) {
73104
- this.conversationSvc.stopTyping(conv.id, orgId, appId);
73793
+ this.conversationSvc.stopTyping(conv.convId, orgId, appId);
73105
73794
  }
73106
73795
  }
73107
- onSend(body) {
73796
+ onSend(event) {
73108
73797
  const conv = this.conv();
73109
73798
  if (!conv || !this.conversationSvc)
73110
73799
  return;
73111
- this.conversationSvc.sendMessage(conv.id, body).subscribe();
73800
+ // thread-panel no tiene pipeline de subida de adjuntos; solo envía el texto.
73801
+ if (event.text)
73802
+ this.conversationSvc.sendMessage(conv.convId, event.text).subscribe();
73112
73803
  }
73113
73804
  onReactionClick(event) {
73114
73805
  // Reacciones se implementan a nivel de app consumer
@@ -73118,7 +73809,7 @@ class ThreadPanelComponent {
73118
73809
  const conv = this.conv();
73119
73810
  if (!conv || !this.conversationSvc)
73120
73811
  return;
73121
- this.conversationSvc.deleteMessage(conv.id, msgId).subscribe();
73812
+ this.conversationSvc.deleteMessage(conv.convId, msgId).subscribe();
73122
73813
  }
73123
73814
  onReplyTo(_msgId) {
73124
73815
  // Implementacion futura: seleccionar mensaje de respuesta
@@ -73143,7 +73834,7 @@ class ThreadPanelComponent {
73143
73834
  } @else {
73144
73835
  <div class="thread-chat">
73145
73836
  <val-chat-window
73146
- [convId]="conv()?.id ?? ''"
73837
+ [convId]="conv()?.convId ?? ''"
73147
73838
  [messages]="bubbleMessages()"
73148
73839
  [currentUserId]="currentUserId"
73149
73840
  [isOpen]="isConvOpen()"
@@ -73157,7 +73848,7 @@ class ThreadPanelComponent {
73157
73848
  </div>
73158
73849
  }
73159
73850
  </div>
73160
- `, isInline: true, styles: [".thread-panel{display:flex;flex-direction:column;height:100%;background:var(--ion-background-color, #f4f5f8)}.thread-header{padding:12px 16px;border-bottom:1px solid var(--ion-border-color, rgba(0, 0, 0, .1));background:var(--ion-card-background, var(--ion-background-color, #fff))}.thread-header .thread-title{font-size:1rem;font-weight:600;color:var(--ion-text-color, #000);margin:0}.thread-loading{display:flex;align-items:center;justify-content:center;flex:1;gap:10px;color:var(--ion-color-medium, #92949c);font-size:.875rem}.thread-chat{flex:1;overflow:hidden;display:flex;flex-direction:column}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }, { kind: "component", type: ChatWindowComponent, selector: "val-chat-window", inputs: ["convId", "messages", "participants", "currentUserId", "isOpen", "typingUsers", "isLoading"], outputs: ["sendMessage", "loadMore", "reactionClick", "deleteMessage", "replyTo"] }] }); }
73851
+ `, isInline: true, styles: [".thread-panel{display:flex;flex-direction:column;height:100%;background:var(--ion-background-color, #f4f5f8)}.thread-header{padding:12px 16px;border-bottom:1px solid var(--ion-border-color, rgba(0, 0, 0, .1));background:var(--ion-card-background, var(--ion-background-color, #fff))}.thread-header .thread-title{font-size:1rem;font-weight:600;color:var(--ion-text-color, #000);margin:0}.thread-loading{display:flex;align-items:center;justify-content:center;flex:1;gap:10px;color:var(--ion-color-medium, #92949c);font-size:.875rem}.thread-chat{flex:1;overflow:hidden;display:flex;flex-direction:column}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }, { kind: "component", type: ChatWindowComponent, selector: "val-chat-window", inputs: ["convId", "messages", "currentUserId", "isOpen", "typingUsers", "isLoading"], outputs: ["sendMessage", "loadMore", "reactionClick", "deleteMessage", "replyTo", "editMessage", "typing", "voice"] }] }); }
73161
73852
  }
73162
73853
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ThreadPanelComponent, decorators: [{
73163
73854
  type: Component,
@@ -73177,7 +73868,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
73177
73868
  } @else {
73178
73869
  <div class="thread-chat">
73179
73870
  <val-chat-window
73180
- [convId]="conv()?.id ?? ''"
73871
+ [convId]="conv()?.convId ?? ''"
73181
73872
  [messages]="bubbleMessages()"
73182
73873
  [currentUserId]="currentUserId"
73183
73874
  [isOpen]="isConvOpen()"
@@ -73211,5 +73902,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
73211
73902
  * Generated bundle index. Do not edit.
73212
73903
  */
73213
73904
 
73214
- export { ACTION_CARD_DEFAULTS, AD_SIZE_MAP, API_TABLE_COLUMN_LABELS, APP_VERSION_PLATFORM_PLUGIN, APP_VERSION_REMOTE_PLUGIN, ARTICLE_CARD_DEFAULTS, ARTICLE_SPACING, ARTICLE_STRIP_DEFAULTS, AUTH_CTA_DEFAULTS, AVATAR_UPLOAD_DEFAULTS, AboutViewComponent, AccordionComponent, AccountViewComponent, ActionCardComponent, ActionHeaderComponent, ActionType, AdSlotComponent, AdsLoaderService, AdsService, AlertBoxComponent, AnalyticsErrorHandler, AnalyticsRouterTracker, AnalyticsService, AnimatedTerminalComponent, ApiKeyCreateModalComponent, ApiKeyService, ApiKeysModalComponent, ApiKeysViewComponent, AppConfigService, AppVersionService, ArticleBuilder, ArticleCardComponent, ArticleComponent, ArticleStripComponent, AttachmentUploaderComponent, AuthBackgroundComponent, AuthCtaComponent, AuthService, AuthStateService, AuthStorageService, AuthSyncService, AvatarComponent, AvatarUploadComponent, BOTTOM_NAV_DEFAULTS, BannerComponent, BlogPostBuilder, BottomNavComponent, BoxComponent, BreadcrumbComponent, ButtonComponent, ButtonGroupComponent, CALLOUT_LABELS, CHEV_KEYS, CIRCLE_KEYS, COMMON_COUNTRY_CODES, COMMON_CURRENCIES, CORNER_KEYS, CTA_CARD_DEFAULTS, CURRENCY_INFO, CardComponent, CardSection, CardType, CardsCarouselComponent, ChangeEmailModalComponent, ChangePasswordModalComponent, ChatInputComponent, ChatWindowComponent, CheckInputComponent, CheckboxRadioInputComponent, ChipGroupComponent, CodeDisplayComponent, CommandDisplayComponent, CommentComponent, CommentInputComponent, CommentSectionComponent, CompanyFooterComponent, ComponentStates, ConfirmationDialogService, ContainerComponent, ContentLoaderComponent, ContentReactionComponent, ContentService, ContentTransformer, ConversationService, CookieBannerComponent, CookieSettingsComponent, CountdownComponent, CreateOrgModalComponent, CtaCardComponent, CurrencyInputComponent, DEFAULT_ADS_CONFIG, DEFAULT_APP_CONFIG_SERVICE_CONFIG, DEFAULT_APP_VERSION_SERVICE_CONFIG, DEFAULT_AUTH_CONFIG, DEFAULT_BACK_HEADER, DEFAULT_BUTTON_PRESETS, DEFAULT_CANCEL_BUTTON, DEFAULT_CHECK_INTERVAL_MS, DEFAULT_CONFIRM_BUTTON, DEFAULT_COUNTDOWN_LABELS, DEFAULT_COUNTDOWN_LABELS_EN, DEFAULT_DEBUG_CONSOLE_CONFIG, DEFAULT_DONATION_CONFIG, DEFAULT_EMPTY_STATE, DEFAULT_EMULATOR_CONFIG, DEFAULT_FEEDBACK_CONFIG, DEFAULT_FEEDBACK_TYPE_OPTIONS, DEFAULT_HOME_HEADER, DEFAULT_INFINITE_LIST_METADATA, DEFAULT_LOGIN_LOGO, DEFAULT_MODAL_CANCEL_BUTTON, DEFAULT_MODAL_CONFIRM_BUTTON, DEFAULT_PAGE_SIZE_OPTIONS, DEFAULT_PLATFORMS, DEFAULT_POST_UPDATE_GRACE_MS, DEFAULT_PRESETS, DEFAULT_REFRESHER_METADATA, DEFAULT_SKELETON_CONFIG, DEFAULT_SPLASH_SCREEN_CONFIG, DataTableComponent, DateInputComponent, DatePickerComponent, DateRangeInputComponent, DebugConsoleComponent, DeleteAccountModalComponent, DetailSkeletonComponent, DeviceService, DisplayComponent, DividerComponent, DocsApiTableComponent, DocsBreadcrumbComponent, DocsBuilder, DocsCalloutComponent, DocsCodeExampleComponent, DocsLayoutComponent, DocsNavLinksComponent, DocsNavigationService, DocsPageComponent, DocsSearchComponent, DocsSectionComponent, DocsShellComponent, DocsSidebarComponent, DocsTocComponent, DonationService, DownloadService, EditOrgModalComponent, EmptyStateComponent, EntityCardComponent, EntityFeedService, ExpandableTextComponent, FEATURES_LIST_DEFAULTS, FUN_MODAL_DEFAULTS, FabComponent, FaqComponent, FeaturesListComponent, FeedbackFormComponent, FeedbackService, FieldListComponent, FileInputComponent, FirebaseService, FirestoreCollectionFactory, FirestoreService, FontSizeOption, FontSizeSelectorComponent, FontSizeService, FooterComponent, FooterLinksComponent, FormComponent, FormFieldComponent, FormSkeletonComponent, FunHeaderComponent, FunModalComponent, GlassComponent, GlowCardComponent, GlowComponent, GridSkeletonComponent, HANDOFF_ROUTE_PARAM, HANDOFF_TOKEN_PARAM, HandoffService, HapticsService, HasPermissionDirective, HeaderComponent, HintComponent, HorizontalScrollComponent, HrefComponent, HtmlViewerModalComponent, I18nService, IMAGE_DEFAULTS, INITIAL_AUTH_STATE, INITIAL_MFA_STATE, INVITATION_CARD_DEFAULTS, IONIC_COLORS$5 as IONIC_COLORS, Icon, IconComponent, IconService, ImageComponent, ImageCropComponent, ImageService, InAppBrowserService, InfiniteListComponent, InfoComponent, InputI18nHelper, InputType, InvitationCardComponent, InviteMemberModalComponent, ItemListComponent, LANG_STORAGE_KEY$1 as LANG_STORAGE_KEY, LEGAL_CONTENT_CONFIG, LOGGED_IN_HINT_COOKIE, LOGIN_DEFAULTS, LandingSplitComponent, LandingStepsComponent, LanguageSelectorComponent, LanguageSelectorV2Component, LayeredCardComponent, LegalContentService, LegalLinkService, LinkComponent, LinkProcessorService, LinkedProvidersComponent, LinksAccordionComponent, LinksCakeComponent, ListSkeletonComponent, LoadMoreComponent, LoadingDirective, LocalStorageService, LocaleService, LoginAttemptModalComponent, LoginComponent, MEDIA_OBJECT_DEFAULTS, MEMBER_CARD_DEFAULTS, METRIC_CARD_DEFAULTS, MODAL_SIZES, MOTIF_KEYS, MOTION, MaintenancePageComponent, MarkdownArticleParserService, MediaObjectComponent, MemberCardComponent, MemberDetailModalComponent, MemberImportModalComponent, MenuComponent, MessageBubbleComponent, MessagingService, MetaService, MetricCardComponent, MfaModalComponent, ModalService, ModalShellComponent, MultiSelectSearchComponent, NUM_KEYS, NavigationService, NetworkBannerComponent, NetworkStatusService, NewsBuilder, NoContentComponent, NotesBoxComponent, NotificationActionService, NotificationPreferencesViewComponent, NotificationsService, NotificationsViewComponent, NumberFromToComponent, NumberStepperComponent, OAUTH_PROVIDERS_INFO, OAuthCallbackComponent, OAuthService, OptionCardsComponent, OrgInfoSheetComponent, OrgService, OrgSwitchService, OrganizationViewComponent, PATTERN_MOTIFS, PATTERN_PALETTES, PATTERN_STYLE_CONFIGS, PLATFORM_CONFIGS, POST_UPDATE_TS_KEY, PageContentComponent, PageLinksComponent, PageRefreshService, PageTemplateComponent, PageWavesComponent, PageWrapperComponent, PaginationComponent, PaginationService, PasswordInputComponent, PatternComponent, PdfService, PermissionCatalogService, PermissionSelectorComponent, PermissionsModalComponent, PermissionsViewComponent, PhoneInputComponent, PillComponent, PinInputComponent, PlainCodeBoxComponent, PopoverSelectorComponent, PreferencesService, PreferencesViewComponent, PresetService, PriceTagComponent, ProcessLinksPipe, ProfileSkeletonComponent, ProfileViewComponent, ProgressBarComponent, ProgressRingComponent, ProgressStatusComponent, PrompterComponent, QR_PRESETS, QrCodeComponent, QrGeneratorService, QrScannerComponent, QueryBuilder, QuoteBoxComponent, REQUEST_STATUSES, RadioInputComponent, RangeInputComponent, RatingComponent, ReactionBarComponent, ReactionsService, RefresherComponent, RequestFirestoreService, RequestFormBuilderService, RequestFormComponent, RequestModalComponent, RequestService, RightsFooterComponent, RotatingTextComponent, SEARCH_HEADER_DEFAULTS, SETTINGS_SECTIONS_CATALOG, SHAPE_KEYS, SHARE_PROFILE_MODAL_DEFAULTS, SKELETON_LAYOUT_DEFAULT_ROWS, SKELETON_PRESETS, SOLID_KEYS, STATS_BAR_DEFAULTS, STROKE_KEYS, SearchHeaderComponent, SearchSelectorComponent, SearchbarComponent, SectionHeaderComponent, SecurityViewComponent, SegmentControlComponent, SelectSearchComponent, SessionListModalComponent, SessionService, SettingsHubComponent, ShareButtonsComponent, ShareProfileModalComponent, SimpleComponent, SkeletonComponent, SkeletonLayoutComponent, SkeletonService, SplashComponent, SplashScreenService, StatsBarComponent, StatsCardComponent, StepperComponent, StorageService, SwipeCarouselComponent, SwitchOrgModalComponent, TRI_KEYS, TabbedContentComponent, TableSkeletonComponent, TabsComponent, Terminal404Component, TestimonialCardComponent, TestimonialCarouselComponent, TextComponent, TextInputComponent, TextareaInputComponent, ThemeOption, ThemeService, ThreadPanelComponent, TicketCardComponent, TicketCardImageService, TimelineComponent, TitleBlockComponent, TitleComponent, ToastService, ToggleInputComponent, TokenService, ToolbarActionType, ToolbarComponent, TransferOwnershipModalComponent, TranslatePipe, TypedCollection, TypingIndicatorComponent, UPDATE_BANNER_DEFAULT_CONTENT, UPDATE_BANNER_I18N_NAMESPACE, UpdateBannerComponent, UserAvatarComponent, UsernameInputComponent, VALTECH_ADS_CONFIG, VALTECH_APP_CONFIG, VALTECH_APP_VERSION, VALTECH_AUTH_CONFIG, VALTECH_CHAT_CONFIG, VALTECH_COMPANY_LINKS, VALTECH_CONTENT_CONFIG, VALTECH_DEBUG_CONSOLE, VALTECH_DEFAULT_CONTENT, VALTECH_DONATION_CONFIG, VALTECH_FEEDBACK_CONFIG, VALTECH_FIREBASE_CONFIG, VALTECH_FOOTER_I18N, VALTECH_FOOTER_LOGO, VALTECH_LANGUAGE_SELECTOR, VALTECH_LEGAL_CONFIG, VALTECH_NETWORK_ERROR_KEY, VALTECH_REACTIONS_CONFIG, VALTECH_SETTINGS_MENU_LINKS, VALTECH_SITE_PATHS, VALTECH_SOCIAL_LINKS, VALTECH_SPLASH_SCREEN, VALTECH_WEB_BASE_URLS, VALTECH_WHATSAPP_CONFIG, VERSION, ValtechErrorService, VerifyViewComponent, WhatsappFabComponent, WhatsappService, WizardComponent, WizardFooterComponent, applyDefaultValueToControl, authGuard, authInterceptor, authPasswordValidator, beautifyLegalArticle, blogPost, buildCompanyFooterProps, buildFooterLinks, buildLegalLinkResolver, buildPath, buildSettingsCards, button, canSubmitRequestType, collections, connectPageRefresh, createErrorStateProps, createFirebaseConfig, createGlowCardProps, createInitialPaginationState, createNumberFromToField, createPageState, createPermissionLabeler, createRefreshableStream, createTitleProps, docs, errorLoggingInterceptor, extractPathParams, generatePatternTiles, generateRandomTile, getAppInfo, getAppVersion, getCollectionPath, getDocumentId, getTimeOfDayKey, goToTop, groupPermissionsByScope, guestGuard, hasEmulators, iconButton, interpretError, isAtEnd, isCollectionPath, isDocumentPath, isEmulatorMode, isIonicColor, isValidPath, joinPath, maxLength, mulberry32, news, parseMarkdownArticle, permissionGuard, permissionGuardFromRoute, provideLegalContent, provideSplashScreen, provideValtechAboutRoutes, provideValtechAccountRoutes, provideValtechAds, provideValtechApiKeysRoutes, provideValtechAppConfig, provideValtechAppVersion, provideValtechAppVersionHttp, provideValtechAuth, provideValtechAuthInterceptor, provideValtechChat, provideValtechContent, provideValtechDebugConsole, provideValtechDonations, provideValtechErrorHandling, provideValtechFeedback, provideValtechFirebase, provideValtechI18n, provideValtechLegal, provideValtechNotificationPreferencesRoutes, provideValtechNotificationsRoutes, provideValtechOrganizationRoutes, provideValtechPermissionsRoutes, provideValtechPreferencesRoutes, provideValtechPresets, provideValtechProfileRoutes, provideValtechReactions, provideValtechSecurityRoutes, provideValtechSettingsRoutes, provideValtechSite, provideValtechSkeleton, provideValtechWhatsapp, query, rbacGuard, renderPatternSvgInner, replaceSpecialChars, requestSubmitMode, resolveColor, resolveInputDefaultValue, resolveIonicColor, resolveWebBaseUrl, roleGuard, selectableRequestTypes, storagePaths, superAdminGuard, toArticle };
73905
+ export { ACTION_CARD_DEFAULTS, AD_SIZE_MAP, API_TABLE_COLUMN_LABELS, APP_VERSION_PLATFORM_PLUGIN, APP_VERSION_REMOTE_PLUGIN, ARTICLE_CARD_DEFAULTS, ARTICLE_SPACING, ARTICLE_STRIP_DEFAULTS, AUTH_CTA_DEFAULTS, AVATAR_UPLOAD_DEFAULTS, AboutViewComponent, AccordionComponent, AccountViewComponent, ActionCardComponent, ActionHeaderComponent, ActionType, AdSlotComponent, AdsLoaderService, AdsService, AlertBoxComponent, AnalyticsErrorHandler, AnalyticsRouterTracker, AnalyticsService, AnimatedTerminalComponent, ApiKeyCreateModalComponent, ApiKeyService, ApiKeysModalComponent, ApiKeysViewComponent, AppConfigService, AppVersionService, ArticleBuilder, ArticleCardComponent, ArticleComponent, ArticleStripComponent, AttachmentUploaderComponent, AuthBackgroundComponent, AuthCtaComponent, AuthService, AuthStateService, AuthStorageService, AuthSyncService, AvatarComponent, AvatarUploadComponent, BOTTOM_NAV_DEFAULTS, BannerComponent, BlogPostBuilder, BottomNavComponent, BoxComponent, BreadcrumbComponent, ButtonComponent, ButtonGroupComponent, CALLOUT_LABELS, CHEV_KEYS, CIRCLE_KEYS, COMMON_COUNTRY_CODES, COMMON_CURRENCIES, CORNER_KEYS, CTA_CARD_DEFAULTS, CURRENCY_INFO, CardComponent, CardSection, CardType, CardsCarouselComponent, ChangeEmailModalComponent, ChangePasswordModalComponent, ChatComposerComponent, ChatInputComponent, ChatWindowComponent, CheckInputComponent, CheckboxRadioInputComponent, ChipGroupComponent, CodeDisplayComponent, CommandDisplayComponent, CommentComponent, CommentInputComponent, CommentSectionComponent, CompanyFooterComponent, ComponentStates, ConfirmationDialogService, ContainerComponent, ContentLoaderComponent, ContentReactionComponent, ContentService, ContentTransformer, ConversationListItemComponent, ConversationService, CookieBannerComponent, CookieSettingsComponent, CountdownComponent, CreateOrgModalComponent, CtaCardComponent, CurrencyInputComponent, DEFAULT_ADS_CONFIG, DEFAULT_APP_CONFIG_SERVICE_CONFIG, DEFAULT_APP_VERSION_SERVICE_CONFIG, DEFAULT_AUTH_CONFIG, DEFAULT_BACK_HEADER, DEFAULT_BUTTON_PRESETS, DEFAULT_CANCEL_BUTTON, DEFAULT_CHECK_INTERVAL_MS, DEFAULT_CONFIRM_BUTTON, DEFAULT_COUNTDOWN_LABELS, DEFAULT_COUNTDOWN_LABELS_EN, DEFAULT_DEBUG_CONSOLE_CONFIG, DEFAULT_DONATION_CONFIG, DEFAULT_EMPTY_STATE, DEFAULT_EMULATOR_CONFIG, DEFAULT_FEEDBACK_CONFIG, DEFAULT_FEEDBACK_TYPE_OPTIONS, DEFAULT_HOME_HEADER, DEFAULT_INFINITE_LIST_METADATA, DEFAULT_LOGIN_LOGO, DEFAULT_MODAL_CANCEL_BUTTON, DEFAULT_MODAL_CONFIRM_BUTTON, DEFAULT_PAGE_SIZE_OPTIONS, DEFAULT_PLATFORMS, DEFAULT_POST_UPDATE_GRACE_MS, DEFAULT_PRESETS, DEFAULT_REFRESHER_METADATA, DEFAULT_SKELETON_CONFIG, DEFAULT_SPLASH_SCREEN_CONFIG, DataTableComponent, DateInputComponent, DatePickerComponent, DateRangeInputComponent, DebugConsoleComponent, DeleteAccountModalComponent, DetailSkeletonComponent, DeviceService, DisplayComponent, DividerComponent, DocsApiTableComponent, DocsBreadcrumbComponent, DocsBuilder, DocsCalloutComponent, DocsCodeExampleComponent, DocsLayoutComponent, DocsNavLinksComponent, DocsNavigationService, DocsPageComponent, DocsSearchComponent, DocsSectionComponent, DocsShellComponent, DocsSidebarComponent, DocsTocComponent, DonationService, DownloadService, EditOrgModalComponent, EmptyStateComponent, EntityCardComponent, EntityFeedService, ExpandableTextComponent, FEATURES_LIST_DEFAULTS, FUN_MODAL_DEFAULTS, FabComponent, FaqComponent, FeaturesListComponent, FeedbackFormComponent, FeedbackService, FieldListComponent, FileInputComponent, FirebaseService, FirestoreCollectionFactory, FirestoreService, FontSizeOption, FontSizeSelectorComponent, FontSizeService, FooterComponent, FooterLinksComponent, FormComponent, FormFieldComponent, FormSkeletonComponent, FunHeaderComponent, FunModalComponent, GlassComponent, GlowCardComponent, GlowComponent, GridSkeletonComponent, HANDOFF_ROUTE_PARAM, HANDOFF_TOKEN_PARAM, HandoffService, HapticsService, HasPermissionDirective, HeaderComponent, HintComponent, HorizontalScrollComponent, HrefComponent, HtmlViewerModalComponent, I18nService, IMAGE_DEFAULTS, INITIAL_AUTH_STATE, INITIAL_MFA_STATE, INVITATION_CARD_DEFAULTS, IONIC_COLORS$5 as IONIC_COLORS, Icon, IconComponent, IconService, ImageComponent, ImageCropComponent, ImageService, InAppBrowserService, InfiniteListComponent, InfoComponent, InputI18nHelper, InputType, InvitationCardComponent, InviteMemberModalComponent, ItemListComponent, LANG_STORAGE_KEY$1 as LANG_STORAGE_KEY, LEGAL_CONTENT_CONFIG, LOGGED_IN_HINT_COOKIE, LOGIN_DEFAULTS, LandingSplitComponent, LandingStepsComponent, LanguageSelectorComponent, LanguageSelectorV2Component, LayeredCardComponent, LegalContentService, LegalLinkService, LinkComponent, LinkProcessorService, LinkedProvidersComponent, LinksAccordionComponent, LinksCakeComponent, ListSkeletonComponent, LoadMoreComponent, LoadingDirective, LocalStorageService, LocaleService, LoginAttemptModalComponent, LoginComponent, MEDIA_OBJECT_DEFAULTS, MEMBER_CARD_DEFAULTS, METRIC_CARD_DEFAULTS, MODAL_SIZES, MOTIF_KEYS, MOTION, MaintenancePageComponent, MarkdownArticleParserService, MediaObjectComponent, MemberCardComponent, MemberDetailModalComponent, MemberImportModalComponent, MenuComponent, MessageBubbleComponent, MessagingService, MetaService, MetricCardComponent, MfaModalComponent, ModalService, ModalShellComponent, MultiSelectSearchComponent, NUM_KEYS, NavigationService, NetworkBannerComponent, NetworkStatusService, NewsBuilder, NoContentComponent, NotesBoxComponent, NotificationActionService, NotificationPreferencesViewComponent, NotificationsService, NotificationsViewComponent, NumberFromToComponent, NumberStepperComponent, OAUTH_PROVIDERS_INFO, OAuthCallbackComponent, OAuthService, OptionCardsComponent, OrgInfoSheetComponent, OrgService, OrgSwitchService, OrganizationViewComponent, PATTERN_MOTIFS, PATTERN_PALETTES, PATTERN_STYLE_CONFIGS, PLATFORM_CONFIGS, POST_UPDATE_TS_KEY, PageContentComponent, PageLinksComponent, PageRefreshService, PageTemplateComponent, PageWavesComponent, PageWrapperComponent, PaginationComponent, PaginationService, PasswordInputComponent, PatternComponent, PdfService, PermissionCatalogService, PermissionSelectorComponent, PermissionsModalComponent, PermissionsViewComponent, PhoneInputComponent, PillComponent, PinInputComponent, PlainCodeBoxComponent, PopoverSelectorComponent, PreferencesService, PreferencesViewComponent, PresetService, PriceTagComponent, ProcessLinksPipe, ProfileSkeletonComponent, ProfileViewComponent, ProgressBarComponent, ProgressRingComponent, ProgressStatusComponent, PrompterComponent, QR_PRESETS, QrCodeComponent, QrGeneratorService, QrScannerComponent, QueryBuilder, QuoteBoxComponent, REQUEST_STATUSES, RadioInputComponent, RangeInputComponent, RatingComponent, ReactionBarComponent, ReactionsService, RefresherComponent, RequestFirestoreService, RequestFormBuilderService, RequestFormComponent, RequestModalComponent, RequestService, RightsFooterComponent, RotatingTextComponent, SEARCH_HEADER_DEFAULTS, SETTINGS_SECTIONS_CATALOG, SHAPE_KEYS, SHARE_PROFILE_MODAL_DEFAULTS, SKELETON_LAYOUT_DEFAULT_ROWS, SKELETON_PRESETS, SOLID_KEYS, STATS_BAR_DEFAULTS, STROKE_KEYS, SearchHeaderComponent, SearchSelectorComponent, SearchbarComponent, SectionHeaderComponent, SecurityViewComponent, SegmentControlComponent, SelectSearchComponent, SessionListModalComponent, SessionService, SettingsHubComponent, ShareButtonsComponent, ShareProfileModalComponent, SimpleComponent, SkeletonComponent, SkeletonLayoutComponent, SkeletonService, SplashComponent, SplashScreenService, StatsBarComponent, StatsCardComponent, StepperComponent, StorageService, SwipeCarouselComponent, SwitchOrgModalComponent, TRI_KEYS, TabbedContentComponent, TableSkeletonComponent, TabsComponent, Terminal404Component, TestimonialCardComponent, TestimonialCarouselComponent, TextComponent, TextInputComponent, TextareaInputComponent, ThemeOption, ThemeService, ThreadPanelComponent, TicketCardComponent, TicketCardImageService, TimelineComponent, TitleBlockComponent, TitleComponent, ToastService, ToggleInputComponent, TokenService, ToolbarActionType, ToolbarComponent, TransferOwnershipModalComponent, TranslatePipe, TypedCollection, TypingIndicatorComponent, UPDATE_BANNER_DEFAULT_CONTENT, UPDATE_BANNER_I18N_NAMESPACE, UpdateBannerComponent, UserAvatarComponent, UsernameInputComponent, VALTECH_ADS_CONFIG, VALTECH_APP_CONFIG, VALTECH_APP_VERSION, VALTECH_AUTH_CONFIG, VALTECH_CHAT_CONFIG, VALTECH_COMPANY_LINKS, VALTECH_CONTENT_CONFIG, VALTECH_DEBUG_CONSOLE, VALTECH_DEFAULT_CONTENT, VALTECH_DONATION_CONFIG, VALTECH_FEEDBACK_CONFIG, VALTECH_FIREBASE_CONFIG, VALTECH_FOOTER_I18N, VALTECH_FOOTER_LOGO, VALTECH_LANGUAGE_SELECTOR, VALTECH_LEGAL_CONFIG, VALTECH_NETWORK_ERROR_KEY, VALTECH_REACTIONS_CONFIG, VALTECH_SETTINGS_MENU_LINKS, VALTECH_SITE_PATHS, VALTECH_SOCIAL_LINKS, VALTECH_SPLASH_SCREEN, VALTECH_WEB_BASE_URLS, VALTECH_WHATSAPP_CONFIG, VERSION, ValtechErrorService, VerifyViewComponent, WhatsappFabComponent, WhatsappService, WizardComponent, WizardFooterComponent, applyDefaultValueToControl, authGuard, authInterceptor, authPasswordValidator, beautifyLegalArticle, blogPost, buildCompanyFooterProps, buildFooterLinks, buildLegalLinkResolver, buildPath, buildSettingsCards, button, canSubmitRequestType, collections, connectPageRefresh, createErrorStateProps, createFirebaseConfig, createGlowCardProps, createInitialPaginationState, createNumberFromToField, createPageState, createPermissionLabeler, createRefreshableStream, createTitleProps, docs, errorLoggingInterceptor, extractPathParams, formatClockTime, formatDateSeparator, formatRelativeTime, generatePatternTiles, generateRandomTile, getAppInfo, getAppVersion, getCollectionPath, getDocumentId, getTimeOfDayKey, goToTop, groupPermissionsByScope, guestGuard, hasEmulators, iconButton, interpretError, isAtEnd, isCollectionPath, isDocumentPath, isEmulatorMode, isIonicColor, isValidPath, joinPath, maxLength, mulberry32, news, parseMarkdownArticle, permissionGuard, permissionGuardFromRoute, provideLegalContent, provideSplashScreen, provideValtechAboutRoutes, provideValtechAccountRoutes, provideValtechAds, provideValtechApiKeysRoutes, provideValtechAppConfig, provideValtechAppVersion, provideValtechAppVersionHttp, provideValtechAuth, provideValtechAuthInterceptor, provideValtechChat, provideValtechContent, provideValtechDebugConsole, provideValtechDonations, provideValtechErrorHandling, provideValtechFeedback, provideValtechFirebase, provideValtechI18n, provideValtechLegal, provideValtechNotificationPreferencesRoutes, provideValtechNotificationsRoutes, provideValtechOrganizationRoutes, provideValtechPermissionsRoutes, provideValtechPreferencesRoutes, provideValtechPresets, provideValtechProfileRoutes, provideValtechReactions, provideValtechSecurityRoutes, provideValtechSettingsRoutes, provideValtechSite, provideValtechSkeleton, provideValtechWhatsapp, query, rbacGuard, renderPatternSvgInner, replaceSpecialChars, requestSubmitMode, resolveColor, resolveInputDefaultValue, resolveIonicColor, resolveWebBaseUrl, roleGuard, selectableRequestTypes, storagePaths, superAdminGuard, toArticle };
73215
73906
  //# sourceMappingURL=valtech-components.mjs.map