@propbinder/mobile-design 0.2.47 → 0.2.50

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 (221) hide show
  1. package/ng-package.json +24 -0
  2. package/package.json +3 -39
  3. package/src/animations/page-transitions.ts +165 -0
  4. package/src/assets/fonts/brockmann-mediumitalic-webfont.woff2 +0 -0
  5. package/src/assets/fonts/brockmann-regularitalic-webfont.woff2 +0 -0
  6. package/src/assets/fonts/brockmann-semibolditalic-webfont.woff2 +0 -0
  7. package/src/components/action-list-item/ds-mobile-action-list-item.ts +102 -0
  8. package/src/components/action-list-item/index.ts +2 -0
  9. package/src/components/app-icon/ds-app-icon.ts +133 -0
  10. package/src/components/app-icon/index.ts +2 -0
  11. package/src/components/attachment-preview/ds-mobile-attachment-preview.css +139 -0
  12. package/src/components/attachment-preview/ds-mobile-attachment-preview.ts +164 -0
  13. package/src/components/attachment-preview/index.ts +1 -0
  14. package/src/components/avatar-with-badge/ds-avatar-with-badge.ts +142 -0
  15. package/src/components/avatar-with-badge/index.ts +2 -0
  16. package/src/components/booking-modal/ds-mobile-booking-confirmation-wrapper.ts +71 -0
  17. package/src/components/booking-modal/ds-mobile-booking-modal.service.ts +121 -0
  18. package/src/components/booking-modal/ds-mobile-booking-modal.ts +598 -0
  19. package/src/components/booking-modal/ds-mobile-booking-summary.ts +161 -0
  20. package/src/components/booking-modal/index.ts +4 -0
  21. package/src/components/bottom-sheet/ds-mobile-actions-bottom-sheet.ts +266 -0
  22. package/src/components/bottom-sheet/ds-mobile-bottom-sheet-header.ts +146 -0
  23. package/src/components/bottom-sheet/ds-mobile-bottom-sheet-wrapper.ts +156 -0
  24. package/src/components/bottom-sheet/ds-mobile-bottom-sheet.css +101 -0
  25. package/src/components/bottom-sheet/ds-mobile-bottom-sheet.service.ts +169 -0
  26. package/src/components/bottom-sheet/ds-mobile-confirmation-sheet.ts +211 -0
  27. package/src/components/bottom-sheet/ds-mobile-post-create-bottom-sheet.ts +578 -0
  28. package/src/components/bottom-sheet/ds-mobile-profile-actions-sheet.ts +614 -0
  29. package/src/components/bottom-sheet/index.ts +8 -0
  30. package/src/components/bottom-sheet/modal-shadow-fix.ts +42 -0
  31. package/src/components/card-inline/ds-mobile-card-inline.ts +301 -0
  32. package/src/components/card-inline/index.ts +2 -0
  33. package/src/components/card-inline-banner/ds-mobile-card-inline-banner.ts +118 -0
  34. package/src/components/card-inline-banner/index.ts +1 -0
  35. package/src/components/card-inline-contact/ds-mobile-card-inline-contact.ts +120 -0
  36. package/src/components/card-inline-contact/index.ts +1 -0
  37. package/src/components/card-inline-file/ds-mobile-card-inline-file.ts +141 -0
  38. package/src/components/card-inline-file/index.ts +1 -0
  39. package/src/components/chat-modal/ds-mobile-chat-modal.css +159 -0
  40. package/src/components/chat-modal/ds-mobile-chat-modal.service.ts +105 -0
  41. package/src/components/chat-modal/ds-mobile-chat-modal.ts +918 -0
  42. package/src/components/chat-modal/index.ts +8 -0
  43. package/src/components/comment/ds-mobile-comment.ts +568 -0
  44. package/src/components/comment/index.ts +2 -0
  45. package/src/components/contact-list-item/ds-mobile-contact-list-item.ts +182 -0
  46. package/src/components/contact-list-item/index.ts +2 -0
  47. package/src/components/content/ds-mobile-content.ts +139 -0
  48. package/src/components/content/index.ts +2 -0
  49. package/src/components/dropdown/ds-mobile-dropdown.css +199 -0
  50. package/src/components/dropdown/ds-mobile-dropdown.ts +340 -0
  51. package/src/components/dropdown/index.ts +2 -0
  52. package/src/components/ds-mobile-tabs.css +407 -0
  53. package/src/components/ds-mobile-tabs.ts +216 -0
  54. package/src/components/empty-state/ds-mobile-empty-state.ts +120 -0
  55. package/src/components/empty-state/index.ts +2 -0
  56. package/src/components/fab/ds-mobile-fab.ts +315 -0
  57. package/src/components/fab/index.ts +1 -0
  58. package/src/components/facility-creation-modal/ds-mobile-facility-creation-confirmation-wrapper.ts +121 -0
  59. package/src/components/facility-creation-modal/ds-mobile-facility-creation-modal.css +189 -0
  60. package/src/components/facility-creation-modal/ds-mobile-facility-creation-modal.service.ts +135 -0
  61. package/src/components/facility-creation-modal/ds-mobile-facility-creation-modal.ts +656 -0
  62. package/src/components/facility-creation-modal/index.ts +9 -0
  63. package/src/components/facility-creation-modal/sheets/ds-mobile-access-sheet.ts +105 -0
  64. package/src/components/facility-creation-modal/sheets/ds-mobile-price-sheet.ts +188 -0
  65. package/src/components/facility-creation-modal/sheets/ds-mobile-when-can-book-sheet.ts +460 -0
  66. package/src/components/facility-creation-modal/sheets/ds-mobile-who-can-book-sheet.ts +134 -0
  67. package/src/components/facility-detail-modal/ds-mobile-facility-detail-modal.service.ts +69 -0
  68. package/src/components/facility-detail-modal/ds-mobile-facility-detail-modal.ts +379 -0
  69. package/src/components/facility-detail-modal/index.ts +2 -0
  70. package/src/components/file-attachment/ds-mobile-file-attachment.ts +164 -0
  71. package/src/components/file-attachment/index.ts +2 -0
  72. package/src/components/handbook-detail-modal/ds-mobile-handbook-detail-modal.css +214 -0
  73. package/src/components/handbook-detail-modal/ds-mobile-handbook-detail-modal.service.ts +84 -0
  74. package/src/components/handbook-detail-modal/ds-mobile-handbook-detail-modal.ts +424 -0
  75. package/src/components/handbook-detail-modal/index.ts +3 -0
  76. package/src/components/handbook-folder/ds-mobile-handbook-folder-mini.ts +175 -0
  77. package/src/components/handbook-folder/ds-mobile-handbook-folder.ts +533 -0
  78. package/src/components/handbook-folder/index.ts +4 -0
  79. package/src/components/header-content/ds-mobile-header-content.ts +222 -0
  80. package/src/components/header-content/index.ts +2 -0
  81. package/src/components/illustration/ds-mobile-illustration.ts +124 -0
  82. package/src/components/illustration/index.ts +2 -0
  83. package/src/components/index.ts +124 -0
  84. package/src/components/inline-photo/ds-mobile-inline-photo.ts +361 -0
  85. package/src/components/inline-photo/index.ts +1 -0
  86. package/src/components/inline-tabs/ds-mobile-inline-tabs.ts +132 -0
  87. package/src/components/inline-tabs/index.ts +2 -0
  88. package/src/components/interactive-list-item-booking/ds-mobile-interactive-list-item-booking.ts +350 -0
  89. package/src/components/interactive-list-item-booking/index.ts +1 -0
  90. package/src/components/interactive-list-item-inquiry/ds-mobile-interactive-list-item-inquiry.ts +321 -0
  91. package/src/components/interactive-list-item-inquiry/index.ts +2 -0
  92. package/src/components/interactive-list-item-message/ds-mobile-interactive-list-item-message.ts +237 -0
  93. package/src/components/interactive-list-item-message/index.ts +2 -0
  94. package/src/components/interactive-list-item-post/ds-mobile-interactive-list-item-post.ts +549 -0
  95. package/src/components/interactive-list-item-post/ds-mobile-post-pdf-attachment.ts +124 -0
  96. package/src/components/interactive-list-item-post/index.ts +13 -0
  97. package/src/components/lightbox/ds-mobile-lightbox-footer.ts +315 -0
  98. package/src/components/lightbox/ds-mobile-lightbox-header.ts +202 -0
  99. package/src/components/lightbox/ds-mobile-lightbox-image.ts +484 -0
  100. package/src/components/lightbox/ds-mobile-lightbox-pdf.css +377 -0
  101. package/src/components/lightbox/ds-mobile-lightbox-pdf.ts +374 -0
  102. package/src/components/lightbox/ds-mobile-lightbox.css +587 -0
  103. package/src/components/lightbox/ds-mobile-lightbox.service.ts +296 -0
  104. package/src/components/lightbox/ds-mobile-lightbox.ts +529 -0
  105. package/src/components/lightbox/index.ts +22 -0
  106. package/src/components/list-item/ds-mobile-list-item.ts +603 -0
  107. package/src/components/list-item/index.ts +2 -0
  108. package/src/components/list-item-static/ds-mobile-list-item-static.ts +133 -0
  109. package/src/components/list-item-static/index.ts +2 -0
  110. package/src/components/loader-overlay/ds-mobile-loader-overlay.css +49 -0
  111. package/src/components/loader-overlay/ds-mobile-loader-overlay.ts +77 -0
  112. package/src/components/loader-overlay/index.ts +1 -0
  113. package/src/components/logo/ds-logo.ts +95 -0
  114. package/src/components/logo/index.ts +2 -0
  115. package/src/components/message-bubble/ds-mobile-message-bubble.ts +633 -0
  116. package/src/components/message-bubble/index.ts +7 -0
  117. package/src/components/message-composer/ds-mobile-message-composer.ts +1146 -0
  118. package/src/components/message-composer/index.ts +7 -0
  119. package/src/components/modal/ds-mobile-modal.css +163 -0
  120. package/src/components/modal/ds-mobile-modal.service.ts +329 -0
  121. package/src/components/modal/index.ts +8 -0
  122. package/src/components/modal-base/ds-mobile-modal-base.css +378 -0
  123. package/src/components/modal-base/ds-mobile-modal-base.ts +261 -0
  124. package/src/components/modal-base/index.ts +2 -0
  125. package/src/components/new-inquiry-modal/ds-mobile-new-inquiry-modal.css +112 -0
  126. package/src/components/new-inquiry-modal/ds-mobile-new-inquiry-modal.service.ts +93 -0
  127. package/src/components/new-inquiry-modal/ds-mobile-new-inquiry-modal.ts +442 -0
  128. package/src/components/new-inquiry-modal/index.ts +4 -0
  129. package/src/components/offline-banner/ds-mobile-offline-banner.ts +135 -0
  130. package/src/components/offline-banner/index.ts +1 -0
  131. package/src/components/page-details/ds-mobile-page-details.css +83 -0
  132. package/src/components/page-details/ds-mobile-page-details.ts +282 -0
  133. package/src/components/page-details/index.ts +2 -0
  134. package/src/components/page-main/ds-mobile-page-main.css +68 -0
  135. package/src/components/page-main/ds-mobile-page-main.ts +421 -0
  136. package/src/components/page-main/index.ts +2 -0
  137. package/src/components/post-composer/ds-mobile-post-composer.ts +140 -0
  138. package/src/components/post-composer/index.ts +2 -0
  139. package/src/components/post-detail-modal/ds-mobile-post-detail-modal.css +390 -0
  140. package/src/components/post-detail-modal/ds-mobile-post-detail-modal.service.ts +108 -0
  141. package/src/components/post-detail-modal/ds-mobile-post-detail-modal.ts +722 -0
  142. package/src/components/post-detail-modal/index.ts +9 -0
  143. package/src/components/property-banner/ds-mobile-property-banner.ts +95 -0
  144. package/src/components/property-banner/index.ts +2 -0
  145. package/src/components/section/ds-mobile-section.ts +263 -0
  146. package/src/components/section/index.ts +2 -0
  147. package/src/components/shared/directives/index.ts +2 -0
  148. package/src/components/shared/directives/long-press.directive.ts +212 -0
  149. package/src/components/shared/index.ts +3 -0
  150. package/src/components/shared/mobile-modal-base.ts +457 -0
  151. package/src/components/shared/mobile-page-base.ts +204 -0
  152. package/src/components/swiper/ds-mobile-swiper-with-nav.ts +160 -0
  153. package/src/components/swiper/ds-mobile-swiper.ts +327 -0
  154. package/src/components/swiper/index.ts +3 -0
  155. package/src/components/system-message-banner/ds-mobile-system-message-banner.ts +129 -0
  156. package/src/components/system-message-banner/index.ts +2 -0
  157. package/src/components/tab-bar/ds-mobile-tab-bar.css +533 -0
  158. package/src/components/tab-bar/ds-mobile-tab-bar.ts +735 -0
  159. package/src/components/tab-bar/index.ts +2 -0
  160. package/src/components/tabs/ds-mobile-tabs.css +25 -0
  161. package/src/components/tabs/ds-mobile-tabs.ts +89 -0
  162. package/src/components/tabs/index.ts +2 -0
  163. package/src/components/text-input/ds-text-input.ts +287 -0
  164. package/src/components/text-input/index.ts +2 -0
  165. package/src/examples/booking.page.ts +434 -0
  166. package/src/examples/community.page.ts +776 -0
  167. package/src/examples/handbook.page.ts +324 -0
  168. package/src/examples/home.page.ts +347 -0
  169. package/src/examples/index.ts +12 -0
  170. package/src/examples/inquiries.example.ts +273 -0
  171. package/src/examples/inquiry-detail.example.css +189 -0
  172. package/src/examples/inquiry-detail.example.ts +415 -0
  173. package/src/examples/mobile-tabs-example.component.ts +208 -0
  174. package/src/examples/post-create.page.ts +311 -0
  175. package/src/examples/post-detail.page.ts +296 -0
  176. package/src/examples/sign-in.page.ts +291 -0
  177. package/src/examples/whitelabel-demo-modal.component.ts +1094 -0
  178. package/src/examples/whitelabel-demo-modal.service.ts +77 -0
  179. package/src/models/index.ts +7 -0
  180. package/src/models/post.model.ts +41 -0
  181. package/src/pages/community.page.ts +769 -0
  182. package/src/pages/handbook.page.ts +388 -0
  183. package/src/pages/home.page.ts +303 -0
  184. package/src/pages/index.ts +11 -0
  185. package/src/pages/inquiries.example.ts +273 -0
  186. package/src/pages/inquiry-detail.example.css +189 -0
  187. package/src/pages/inquiry-detail.example.ts +415 -0
  188. package/src/pages/mobile-tabs-example.component.ts +179 -0
  189. package/src/pages/post-create.page.ts +311 -0
  190. package/src/pages/post-detail.page.ts +296 -0
  191. package/src/pages/sign-in.page.ts +291 -0
  192. package/src/pages/whitelabel-demo-modal.component.ts +1094 -0
  193. package/src/pages/whitelabel-demo-modal.service.ts +77 -0
  194. package/src/public-api.ts +6 -0
  195. package/src/services/base-modal.service.ts +101 -0
  196. package/src/services/index.ts +11 -0
  197. package/src/services/posts.service.ts +542 -0
  198. package/src/services/tracking-permission.service.ts +88 -0
  199. package/src/services/user.service.ts +60 -0
  200. package/src/services/whitelabel.service.ts +675 -0
  201. package/{styles → src/styles}/ionic.css +25 -0
  202. package/tsconfig.lib.json +17 -0
  203. package/tsconfig.lib.prod.json +9 -0
  204. package/tsconfig.spec.json +13 -0
  205. package/fesm2022/propbinder-mobile-design.mjs +0 -26136
  206. package/fesm2022/propbinder-mobile-design.mjs.map +0 -1
  207. package/index.d.ts +0 -8154
  208. /package/{assets → src/assets}/fonts/Brockmann-Bold.otf +0 -0
  209. /package/{assets → src/assets}/fonts/Brockmann-BoldItalic.otf +0 -0
  210. /package/{assets → src/assets}/fonts/Brockmann-Medium.otf +0 -0
  211. /package/{assets → src/assets}/fonts/Brockmann-MediumItalic.otf +0 -0
  212. /package/{assets → src/assets}/fonts/Brockmann-Regular.otf +0 -0
  213. /package/{assets → src/assets}/fonts/Brockmann-RegularItalic.otf +0 -0
  214. /package/{assets → src/assets}/fonts/Brockmann-SemiBold.otf +0 -0
  215. /package/{assets → src/assets}/fonts/Brockmann-SemiBoldItalic.otf +0 -0
  216. /package/{assets → src/assets}/fonts/Brockmann_desktop_license.pdf +0 -0
  217. /package/{assets → src/assets}/fonts/brockmann-medium-webfont.woff2 +0 -0
  218. /package/{assets → src/assets}/fonts/brockmann-regular-webfont.woff2 +0 -0
  219. /package/{assets → src/assets}/fonts/brockmann-semibold-webfont.woff2 +0 -0
  220. /package/{styles → src/components/shared}/mobile-common.css +0 -0
  221. /package/{styles → src/components/shared}/mobile-page-base.css +0 -0
@@ -0,0 +1,918 @@
1
+ import { Component, signal, Input, Output, EventEmitter, OnInit, AfterViewInit, CUSTOM_ELEMENTS_SCHEMA, computed } from '@angular/core';
2
+ import { CommonModule } from '@angular/common';
3
+ import { Keyboard } from '@capacitor/keyboard';
4
+ import { DsMobileMessageComposerComponent } from '../message-composer';
5
+ import { DsMobileMessageBubbleComponent, ChatAttachment } from '../message-bubble';
6
+ import { DsAvatarWithBadgeComponent } from '../avatar-with-badge';
7
+ import { DsMobileModalBaseComponent } from '../modal-base/ds-mobile-modal-base';
8
+ import { DsMobileCardInlineFileComponent } from '../card-inline-file';
9
+ import { type AttachmentData } from '../attachment-preview';
10
+ import { DsMobileLightboxService, LightboxAuthor, LightboxImage } from '../lightbox';
11
+ import { DsMobileSystemMessageBannerComponent } from '../system-message-banner';
12
+
13
+ import { DsIconComponent } from '@propbinder/design-system';
14
+ import { DsMobileSectionComponent } from '../section';
15
+
16
+ /**
17
+ * Chat message data interface
18
+ */
19
+ export interface ChatMessage {
20
+ id: string;
21
+ content: string;
22
+ senderId: string;
23
+ senderName: string;
24
+ senderRole?: string;
25
+ timestamp: Date;
26
+ avatarInitials?: string;
27
+ avatarType?: 'initials' | 'photo' | 'icon';
28
+ avatarSrc?: string;
29
+ isOwnMessage: boolean;
30
+ attachments?: ChatAttachment[];
31
+ fileAttachments?: AttachmentData[];
32
+ isNewMessage?: boolean;
33
+ }
34
+
35
+ /**
36
+ * Message group interface for grouped timestamp display
37
+ */
38
+ interface MessageGroup {
39
+ timestamp: Date;
40
+ displayTimestamp: string;
41
+ messages: ChatMessage[];
42
+ }
43
+
44
+ /**
45
+ * Extended message interface with display metadata
46
+ */
47
+ interface MessageDisplay extends ChatMessage {
48
+ showAvatar: boolean;
49
+ clusterPosition: 'single' | 'first' | 'middle' | 'last';
50
+ isNewMessage?: boolean;
51
+ }
52
+
53
+ /**
54
+ * Chat participant interface
55
+ */
56
+ export interface ChatParticipant {
57
+ id: string;
58
+ name: string;
59
+ role?: string;
60
+ avatarInitials?: string;
61
+ avatarType?: 'initials' | 'photo' | 'icon';
62
+ avatarSrc?: string;
63
+ badge?: string;
64
+ verified?: boolean;
65
+ lastActive?: string;
66
+ }
67
+
68
+ /**
69
+ * Chat modal data interface
70
+ *
71
+ * Represents the data needed to display a chat conversation.
72
+ *
73
+ * @example
74
+ * ```typescript
75
+ * const chatData: ChatModalData = {
76
+ * participant: {
77
+ * id: 'u-123',
78
+ * name: 'Ricki Meihlen',
79
+ * role: 'Inquiry assignee',
80
+ * avatarInitials: 'RM',
81
+ * avatarType: 'initials'
82
+ * },
83
+ * currentUserId: 'u-456',
84
+ * currentUserInitials: 'SD',
85
+ * currentUserAvatarType: 'initials',
86
+ * autoFocus: true,
87
+ * messages: [
88
+ * {
89
+ * id: 'm-1',
90
+ * content: 'We have received your case. Please see the attached photo.',
91
+ * senderId: 'u-123',
92
+ * senderName: 'Ricki Meihlen',
93
+ * senderRole: 'Case worker',
94
+ * timestamp: '12:34',
95
+ * isOwnMessage: false,
96
+ * avatarInitials: 'RM',
97
+ * avatarType: 'initials',
98
+ * attachments: [
99
+ * {
100
+ * id: 'a-1',
101
+ * type: 'image',
102
+ * url: 'https://example.com/photo.jpg',
103
+ * name: 'photo.jpg',
104
+ * thumbnail: 'https://example.com/photo_thumb.jpg'
105
+ * },
106
+ * {
107
+ * id: 'a-2',
108
+ * type: 'pdf',
109
+ * url: 'https://example.com/report.pdf',
110
+ * name: 'report.pdf'
111
+ * }
112
+ * ]
113
+ * },
114
+ * {
115
+ * id: 'm-2',
116
+ * content: 'Thanks — I will take a look.',
117
+ * senderId: 'u-456',
118
+ * senderName: 'You',
119
+ * timestamp: '12:36',
120
+ * isOwnMessage: true,
121
+ * avatarInitials: 'SD',
122
+ * avatarType: 'initials'
123
+ * }
124
+ * ]
125
+ * };
126
+ * ```
127
+ */
128
+ export interface ChatModalData {
129
+ /** The other participant in the conversation */
130
+ participant: ChatParticipant;
131
+ /** Array of messages in the conversation */
132
+ messages: ChatMessage[];
133
+ /** Current user's ID */
134
+ currentUserId: string;
135
+ /** Current user's avatar initials */
136
+ currentUserInitials?: string;
137
+ /** Current user's avatar type */
138
+ currentUserAvatarType?: 'initials' | 'photo' | 'icon';
139
+ /** Current user's avatar source */
140
+ currentUserAvatarSrc?: string;
141
+ /** Auto-focus input when modal opens */
142
+ autoFocus?: boolean;
143
+ /**
144
+ * Callback executed when a message is sent from the chat.
145
+ * Use this to handle API calls to your backend.
146
+ */
147
+ onSend?: (message: string, attachments: AttachmentData[]) => void | Promise<void>;
148
+ /**
149
+ * Optional callback for when a file attachment is clicked.
150
+ * If not provided, the default behavior will try to open/download the file.
151
+ */
152
+ onFileClick?: (file: AttachmentData) => void;
153
+ }
154
+
155
+ /**
156
+ * DsMobileChatModalComponent
157
+ *
158
+ * Modal component for displaying and managing chat conversations.
159
+ * Follows the same pattern as post-detail-modal for consistent behavior.
160
+ *
161
+ * Features:
162
+ * - Header with participant info and close button
163
+ * - Scrollable message thread
164
+ * - Fixed message composer at bottom
165
+ * - Keyboard handling
166
+ * - Safe area support
167
+ *
168
+ * This component is typically not used directly - use DsMobileChatModalService instead.
169
+ *
170
+ * @example
171
+ * ```typescript
172
+ * // Don't instantiate directly - use the service:
173
+ * constructor(private chatModal: DsMobileChatModalService) {}
174
+ *
175
+ * openChat() {
176
+ * this.chatModal.open({
177
+ * participant: { id: '123', name: 'Ricki Meihlen' },
178
+ * messages: [...],
179
+ * currentUserId: '456'
180
+ * });
181
+ * }
182
+ * ```
183
+ */
184
+ @Component({
185
+ selector: 'ds-mobile-chat-modal',
186
+ standalone: true,
187
+ imports: [
188
+ CommonModule,
189
+ DsAvatarWithBadgeComponent,
190
+ DsMobileMessageComposerComponent,
191
+ DsMobileMessageBubbleComponent,
192
+ DsMobileModalBaseComponent,
193
+ DsMobileCardInlineFileComponent,
194
+ DsMobileSystemMessageBannerComponent,
195
+ DsIconComponent,
196
+ DsMobileSectionComponent,
197
+ ],
198
+ styleUrls: ['../shared/mobile-common.css', './ds-mobile-chat-modal.css'],
199
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
200
+ template: `
201
+ <ds-mobile-modal-base
202
+ [loading]="loading"
203
+ [error]="error"
204
+ [headerTitle]="participant().name"
205
+ [headerMeta]="participant().role || ''"
206
+ [hasFixedBottom]="true"
207
+ [isAutoHeight]="false"
208
+ [enableKeyboardHandling]="true"
209
+ (keyboardWillShow)="handleKeyboardShow($event)"
210
+ closeButtonLabel="Luk chat"
211
+ >
212
+ <!-- Header Avatar -->
213
+ <ds-avatar-with-badge
214
+ header-leading
215
+ [initials]="participant().avatarInitials || ''"
216
+ [type]="participant().avatarType || 'initials'"
217
+ [src]="participant().avatarSrc || ''"
218
+ size="md"
219
+ />
220
+
221
+ <!-- Messages Section -->
222
+ <ds-mobile-section>
223
+ <div class="chat-messages-container" (click)="handleContentClick()">
224
+ <!-- Centered avatar section at top of messages -->
225
+ <div class="chat-avatar-section">
226
+ <ds-avatar-with-badge
227
+ [initials]="participant().avatarInitials || ''"
228
+ [type]="participant().avatarType || 'initials'"
229
+ [src]="participant().avatarSrc || ''"
230
+ [badge]="participant().badge"
231
+ size="xl"
232
+ />
233
+ <div class="chat-avatar-info">
234
+ <div class="chat-avatar-name">
235
+ {{ participant().name }}
236
+ @if (participant().verified) {
237
+ <ds-icon name="remixCheckboxCircleFill" size="24px" [style.color]="'var(--color-primary-base)'"></ds-icon>
238
+ }
239
+ </div>
240
+ @if (participant().role) {
241
+ <div class="chat-avatar-role">{{ participant().role }}</div>
242
+ }
243
+ @if (participant().lastActive) {
244
+ <div class="chat-avatar-meta">{{ participant().lastActive }}</div>
245
+ }
246
+ </div>
247
+ </div>
248
+
249
+ <div class="messages-list">
250
+ @if (messages().length === 0) {
251
+ <!-- Empty State - Timestamp and System Message -->
252
+ <div class="timestamp-header">
253
+ <span class="timestamp-text">{{ getInitialTimestamp() }}</span>
254
+ </div>
255
+
256
+ <ds-mobile-system-message-banner [message]="participant().name + ' har overtaget din henvendelse og vil kontakte dig snart.'" [afterTimestamp]="true">
257
+ </ds-mobile-system-message-banner>
258
+ } @else {
259
+ @for (group of messagesWithDisplay(); track group.timestamp) {
260
+ <!-- Timestamp Header -->
261
+ <div class="timestamp-header">
262
+ <span class="timestamp-text">{{ group.displayTimestamp }}</span>
263
+ </div>
264
+
265
+ <!-- System message example (shown after first timestamp) -->
266
+ @if ($first) {
267
+ <ds-mobile-system-message-banner [message]="participant().name + ' har overtaget din henvendelse og vil kontakte dig snart.'" [afterTimestamp]="true">
268
+ </ds-mobile-system-message-banner>
269
+ }
270
+ @for (message of group.messages; track message.id) {
271
+ <!-- Only show bubble if has content -->
272
+ @if (message.content.trim()) {
273
+ <ds-mobile-message-bubble
274
+ [content]="message.content"
275
+ [isOwnMessage]="message.isOwnMessage"
276
+ [timestamp]="formatMessageTimestamp(message.timestamp)"
277
+ [showTimestamp]="selectedMessageId() === message.id"
278
+ [avatarInitials]="message.avatarInitials || ''"
279
+ [avatarType]="message.avatarType || 'initials'"
280
+ [avatarSrc]="message.avatarSrc || ''"
281
+ [showAvatar]="message.showAvatar"
282
+ [clusterPosition]="message.clusterPosition"
283
+ [attachments]="message.attachments"
284
+ [clickable]="true"
285
+ [isNewMessage]="message.isNewMessage || false"
286
+ (messageClick)="handleMessageClick(message.id)"
287
+ (attachmentClick)="handleAttachmentClick($event)"
288
+ (longPress)="handleMessageLongPress(message)"
289
+ >
290
+ </ds-mobile-message-bubble>
291
+ }
292
+
293
+ <!-- File attachments displayed below message bubble -->
294
+ @if (message.fileAttachments && message.fileAttachments.length > 0) {
295
+ <div class="message-file-attachments" [class.own-message]="message.isOwnMessage">
296
+ @for (fileAttachment of message.fileAttachments; track fileAttachment.id) {
297
+ <!-- Show inline image preview for image attachments -->
298
+ @if (fileAttachment.type === 'image') {
299
+ <div class="message-image-attachment" (click)="handleImageClick(fileAttachment, message)">
300
+ <img [src]="fileAttachment.src" [alt]="fileAttachment.name || 'Image'" class="inline-image" />
301
+ </div>
302
+ } @else {
303
+ <!-- Show file card for non-image attachments -->
304
+ <ds-mobile-card-inline-file
305
+ [fileName]="fileAttachment.name || 'Unknown file'"
306
+ [fileSize]="fileAttachment.size || ''"
307
+ [variant]="getFileVariant(fileAttachment.type)"
308
+ [layout]="'compact'"
309
+ (fileClick)="handleFileAttachmentClick(fileAttachment)"
310
+ >
311
+ </ds-mobile-card-inline-file>
312
+ }
313
+ }
314
+ </div>
315
+ }
316
+ }
317
+ }
318
+ }
319
+ </div>
320
+ </div>
321
+ </ds-mobile-section>
322
+
323
+ <!-- Fixed message composer -->
324
+ <div fixed-bottom>
325
+ <ds-mobile-message-composer
326
+ [avatarInitials]="currentUserInitials()"
327
+ [avatarType]="currentUserAvatarType()"
328
+ [avatarSrc]="currentUserAvatarSrc()"
329
+ [placeholder]="'Skriv en besked...'"
330
+ [autoFocus]="autoFocus()"
331
+ [showAttachmentButton]="true"
332
+ (messageSent)="handleMessageSent($event)"
333
+ (attachmentClicked)="handleComposerAttachmentClick()"
334
+ (attachmentsChanged)="handleAttachmentsChanged()"
335
+ >
336
+ </ds-mobile-message-composer>
337
+ </div>
338
+ </ds-mobile-modal-base>
339
+ `,
340
+ })
341
+ export class DsMobileChatModalComponent implements OnInit, AfterViewInit {
342
+ // Chat data passed from service
343
+ @Input() chatData!: ChatModalData;
344
+
345
+ /**
346
+ * Loading state - when true, shows loading indicator
347
+ */
348
+ @Input() loading: boolean = false;
349
+
350
+ /**
351
+ * Error state - when set, shows error message
352
+ */
353
+ @Input() error?: string;
354
+
355
+ /**
356
+ * Back button click event
357
+ */
358
+ @Output() back = new EventEmitter<void>();
359
+
360
+ // Signal for reactive chat data
361
+ participant = signal<ChatParticipant>({
362
+ id: '',
363
+ name: '',
364
+ avatarInitials: '',
365
+ });
366
+
367
+ messages = signal<ChatMessage[]>([]);
368
+ currentUserInitials = signal<string>('');
369
+ currentUserAvatarType = signal<'initials' | 'photo' | 'icon'>('initials');
370
+ currentUserAvatarSrc = signal<string>('');
371
+ autoFocus = signal<boolean>(false);
372
+
373
+ /**
374
+ * Selected message ID for showing timestamp
375
+ */
376
+ selectedMessageId = signal<string | null>(null);
377
+
378
+ /**
379
+ * Track if keyboard is currently visible
380
+ */
381
+ private isKeyboardVisible = false;
382
+
383
+ /**
384
+ * Timeout for auto-hiding timestamp
385
+ */
386
+ private timestampTimeout?: ReturnType<typeof setTimeout>;
387
+
388
+ /**
389
+ * Computed signal for grouped messages with timestamp headers
390
+ */
391
+ messageGroups = computed(() => {
392
+ return this.groupMessagesByTime(this.messages(), 5);
393
+ });
394
+
395
+ /**
396
+ * Computed signal for messages with display metadata (avatar visibility)
397
+ */
398
+ messagesWithDisplay = computed(() => {
399
+ return this.addDisplayMetadata(this.messageGroups());
400
+ });
401
+
402
+ constructor(private lightboxService: DsMobileLightboxService) {}
403
+
404
+ ngOnInit(): void {
405
+ // Initialize chat data from input
406
+ if (this.chatData) {
407
+ this.participant.set(this.chatData.participant);
408
+ this.messages.set(this.chatData.messages || []);
409
+ this.currentUserInitials.set(this.chatData.currentUserInitials || '');
410
+ this.currentUserAvatarType.set(this.chatData.currentUserAvatarType || 'initials');
411
+ this.currentUserAvatarSrc.set(this.chatData.currentUserAvatarSrc || '');
412
+ this.autoFocus.set(this.chatData.autoFocus || false);
413
+ }
414
+ }
415
+
416
+ ngAfterViewInit(): void {
417
+ // Scroll to bottom when messages load
418
+ if (this.messages().length > 0) {
419
+ setTimeout(() => {
420
+ this.scrollToBottom();
421
+ }, 500);
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Handle back button click
427
+ */
428
+ handleBack(): void {
429
+ this.back.emit();
430
+ }
431
+
432
+ /**
433
+ * Handle keyboard show event from base modal
434
+ * Only scrolls to bottom if user is already near the bottom, otherwise lets
435
+ * the natural padding expansion push content up (like Messenger/Signal/Telegram)
436
+ */
437
+ handleKeyboardShow(keyboardHeight: number): void {
438
+ // Track keyboard state
439
+ this.isKeyboardVisible = true;
440
+
441
+ // Check if user is near bottom before auto-scrolling
442
+ this.isNearBottom().then((isNear) => {
443
+ if (isNear) {
444
+ // User is already viewing latest messages, maintain that position
445
+ // Small delay to synchronize with keyboard animation
446
+ setTimeout(() => {
447
+ this.scrollToBottom();
448
+ }, 50);
449
+ }
450
+ // Otherwise, let the natural padding expansion push content up smoothly
451
+ });
452
+ }
453
+
454
+ /**
455
+ * Check if scroll position is near the bottom
456
+ * Used to determine if we should auto-scroll when keyboard appears
457
+ */
458
+ private async isNearBottom(): Promise<boolean> {
459
+ const ionContent = document.querySelector('ds-mobile-chat-modal ion-content');
460
+ if (ionContent) {
461
+ try {
462
+ const scrollElement = await (ionContent as any).getScrollElement();
463
+ const scrollTop = scrollElement.scrollTop;
464
+ const scrollHeight = scrollElement.scrollHeight;
465
+ const clientHeight = scrollElement.clientHeight;
466
+ const threshold = 150; // pixels - consider "near bottom" within 150px
467
+
468
+ const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
469
+ return distanceFromBottom <= threshold;
470
+ } catch (e) {
471
+ console.log('[ChatModal] Could not check scroll position:', e);
472
+ // The provided snippet was syntactically incorrect for this location.
473
+ // Assuming the intent was to add `auto-height` to the modal's CSS class,
474
+ // this change should be applied where the modal is opened or in its template.
475
+ // As per the instruction, the `isAutoHeight` property is added to the component.
476
+ }
477
+ }
478
+ return true;
479
+ }
480
+
481
+ /**
482
+ * Scroll to bottom of messages
483
+ */
484
+ private async scrollToBottom(): Promise<void> {
485
+ const ionContent = document.querySelector('ds-mobile-chat-modal ion-content');
486
+ if (ionContent) {
487
+ // Scroll with smooth animation (400ms) and add a small offset to ensure visibility
488
+ await (ionContent as any).scrollToBottom(400);
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Handle message sent from composer
494
+ */
495
+ handleMessageSent(event: { content: string; isReply?: boolean; replyTo?: string; isEdit?: boolean; attachments?: AttachmentData[] }): void {
496
+ const newMessage: ChatMessage = {
497
+ id: Date.now().toString(),
498
+ content: event.content,
499
+ senderId: this.chatData.currentUserId,
500
+ senderName: 'You',
501
+ timestamp: new Date(),
502
+ isOwnMessage: true,
503
+ avatarInitials: this.currentUserInitials(),
504
+ avatarType: this.currentUserAvatarType(),
505
+ avatarSrc: this.currentUserAvatarSrc(),
506
+ fileAttachments: event.attachments || [],
507
+ isNewMessage: true, // Mark as new message to trigger animation
508
+ };
509
+
510
+ // Add message to list
511
+ const updatedMessages = [...this.messages(), newMessage];
512
+ this.messages.set(updatedMessages);
513
+
514
+ // Remove the isNewMessage flag after animation completes (using spring-bouncy duration ~600ms)
515
+ setTimeout(() => {
516
+ const msgs = this.messages();
517
+ const msgIndex = msgs.findIndex((m) => m.id === newMessage.id);
518
+ if (msgIndex !== -1) {
519
+ msgs[msgIndex].isNewMessage = false;
520
+ this.messages.set([...msgs]);
521
+ }
522
+ }, 700);
523
+
524
+ // Scroll to bottom after message is added and DOM updates
525
+ // Multiple delays to ensure:
526
+ // 1. DOM update from new message
527
+ // 2. Composer height change (clears text/attachments)
528
+ // 3. ResizeObserver updates padding
529
+ requestAnimationFrame(() => {
530
+ requestAnimationFrame(() => {
531
+ setTimeout(() => {
532
+ this.scrollToBottom();
533
+ console.log('[ChatModal] Scrolled to bottom after message sent');
534
+ }, 150);
535
+ });
536
+ });
537
+
538
+ // Executing the onSend callback if provided
539
+ if (this.chatData.onSend) {
540
+ this.chatData.onSend(event.content, event.attachments || []);
541
+ }
542
+ }
543
+
544
+ /**
545
+ * Handle attachment click
546
+ */
547
+ handleAttachmentClick(attachment: ChatAttachment): void {
548
+ if (attachment.type !== 'image') {
549
+ if (attachment.url) {
550
+ window.open(attachment.url, '_blank', 'noopener,noreferrer');
551
+ }
552
+ return;
553
+ }
554
+
555
+ const msgs = this.messages();
556
+
557
+ const allImages: Array<{ msg: ChatMessage; att: ChatAttachment }> = [];
558
+ for (const m of msgs) {
559
+ for (const att of m.attachments ?? []) {
560
+ if (att.type === 'image') {
561
+ allImages.push({ msg: m, att });
562
+ }
563
+ }
564
+ }
565
+
566
+ const initialIndex = Math.max(
567
+ 0,
568
+ allImages.findIndex((x) => x.att.id === attachment.id),
569
+ );
570
+
571
+ const images: LightboxImage[] = allImages.map((x) => ({
572
+ type: 'image',
573
+ src: x.att.url,
574
+ title: x.att.name,
575
+ alt: x.att.name,
576
+ thumbnail: x.att.thumbnail,
577
+ }));
578
+
579
+ const ownerMessage = allImages[initialIndex]?.msg;
580
+
581
+ const author: LightboxAuthor | undefined = ownerMessage
582
+ ? {
583
+ name: ownerMessage.senderName,
584
+ role: ownerMessage.senderRole,
585
+ timestamp: this.formatMessageTimestamp(ownerMessage.timestamp),
586
+ avatarInitials: ownerMessage.avatarInitials,
587
+ avatarSrc: ownerMessage.avatarSrc,
588
+ avatarType: ownerMessage.avatarType === 'photo' || ownerMessage.avatarType === 'initials' ? ownerMessage.avatarType : undefined,
589
+ }
590
+ : undefined;
591
+
592
+ this.lightboxService.openImages({
593
+ images,
594
+ author,
595
+ initialIndex,
596
+ enableZoom: true,
597
+ enableSwipe: true,
598
+ showControls: true,
599
+ showInfo: true,
600
+ animation: 'fade',
601
+ });
602
+ }
603
+
604
+ /**
605
+ * Handle composer attachment button click
606
+ */
607
+ handleComposerAttachmentClick(): void {
608
+ console.log('[ChatModal] Composer attachment button clicked');
609
+ // In a real app, you would open a file picker or show attachment options
610
+ }
611
+
612
+ /**
613
+ * Handle attachments changed (added or removed)
614
+ * ResizeObserver automatically updates padding, which naturally pushes content up.
615
+ * Only scroll to bottom if user is already viewing the latest messages.
616
+ */
617
+ handleAttachmentsChanged(): void {
618
+ console.log('[ChatModal] Attachments changed - ResizeObserver will handle padding naturally');
619
+ // ResizeObserver automatically:
620
+ // 1. Detects composer height change
621
+ // 2. Updates --fixed-bottom-height CSS variable
622
+ // 3. Updates scroll element padding
623
+ // 4. Smoothly pushes content up
624
+
625
+ // Only scroll if user is already at bottom (optional - can be removed entirely)
626
+ requestAnimationFrame(() => {
627
+ requestAnimationFrame(() => {
628
+ setTimeout(() => {
629
+ this.isNearBottom().then((isNear) => {
630
+ if (isNear) {
631
+ // User is at bottom, maintain that position as composer expands
632
+ this.scrollToBottom();
633
+ }
634
+ // Otherwise, let natural padding expansion push content up
635
+ });
636
+ }, 100);
637
+ });
638
+ });
639
+ }
640
+
641
+ /**
642
+ * Handle content area click - dismiss keyboard when tapping messages
643
+ */
644
+ handleContentClick(): void {
645
+ // Hide keyboard when tapping outside the composer
646
+ Keyboard.hide()
647
+ .then(() => {
648
+ this.isKeyboardVisible = false;
649
+ })
650
+ .catch((e) => console.log('[ChatModal] Keyboard.hide() not available:', e));
651
+ }
652
+
653
+ /**
654
+ * Get file variant for card-inline-file component
655
+ */
656
+ getFileVariant(type: string): 'pdf' | 'doc' {
657
+ return type === 'pdf' ? 'pdf' : 'doc';
658
+ }
659
+
660
+ /**
661
+ * Handle file attachment click
662
+ */
663
+ handleFileAttachmentClick(fileAttachment: AttachmentData): void {
664
+ // If a custom handler is provided, use it
665
+ if (this.chatData.onFileClick) {
666
+ this.chatData.onFileClick(fileAttachment);
667
+ return;
668
+ }
669
+
670
+ // Default behavior: Try to open/download the file
671
+ const url = fileAttachment.src || (fileAttachment as any).url;
672
+ if (url) {
673
+ const link = document.createElement('a');
674
+ link.href = url;
675
+ link.target = '_blank';
676
+ // If it has a name, setting download attribute suggests downloading
677
+ if (fileAttachment.name) {
678
+ link.download = fileAttachment.name;
679
+ }
680
+
681
+ document.body.appendChild(link);
682
+ link.click();
683
+ document.body.removeChild(link);
684
+ } else {
685
+ console.warn('[ChatModal] No URL or source for file attachment:', fileAttachment);
686
+ }
687
+ }
688
+
689
+ /**
690
+ * Handle image attachment click - opens lightbox
691
+ */
692
+ async handleImageClick(attachment: AttachmentData, message: ChatMessage): Promise<void> {
693
+ await this.lightboxService.openImages({
694
+ images: [
695
+ {
696
+ type: 'image',
697
+ src: attachment.src,
698
+ title: attachment.name || 'Image',
699
+ alt: attachment.name || 'Chat image',
700
+ },
701
+ ],
702
+ author: {
703
+ name: message.senderName,
704
+ role: message.senderRole,
705
+ avatarInitials: message.avatarInitials,
706
+ avatarType: message.avatarType === 'initials' ? 'initials' : 'photo',
707
+ avatarSrc: message.avatarSrc,
708
+ timestamp: this.formatMessageTimestamp(message.timestamp),
709
+ },
710
+ initialIndex: 0,
711
+ enableZoom: true,
712
+ showControls: false,
713
+ enableSwipe: false,
714
+ showInfo: false,
715
+ showActions: false,
716
+ });
717
+ }
718
+
719
+ /**
720
+ * Handle message long press
721
+ */
722
+ handleMessageLongPress(message: ChatMessage): void {
723
+ console.log('[ChatModal] Message long pressed:', message);
724
+ // In a real app, you would show an action sheet with options (copy, delete, etc.)
725
+ }
726
+
727
+ /**
728
+ * Handle message click to show/hide timestamp
729
+ * Only shows timestamp if keyboard is already hidden
730
+ */
731
+ handleMessageClick(messageId: string): void {
732
+ // If keyboard is visible, dismiss it and don't show timestamp
733
+ if (this.isKeyboardVisible) {
734
+ Keyboard.hide()
735
+ .then(() => {
736
+ this.isKeyboardVisible = false;
737
+ })
738
+ .catch((e) => console.log('[ChatModal] Keyboard.hide() not available:', e));
739
+ return; // Exit early, don't toggle timestamp
740
+ }
741
+
742
+ // Keyboard is hidden, proceed with timestamp toggle
743
+ // Clear existing timeout
744
+ if (this.timestampTimeout) {
745
+ clearTimeout(this.timestampTimeout);
746
+ }
747
+
748
+ // Toggle timestamp - if clicking same message, hide it; otherwise show new one
749
+ this.selectedMessageId.update((current) => (current === messageId ? null : messageId));
750
+
751
+ // Auto-hide after 3 seconds if showing
752
+ if (this.selectedMessageId() === messageId) {
753
+ this.timestampTimeout = setTimeout(() => {
754
+ this.selectedMessageId.set(null);
755
+ }, 3000);
756
+ }
757
+ }
758
+
759
+ /**
760
+ * Format message timestamp for display (EU 24-hour format, Danish)
761
+ */
762
+ formatMessageTimestamp(date: Date): string {
763
+ return date.toLocaleTimeString('da-DK', {
764
+ hour: '2-digit',
765
+ minute: '2-digit',
766
+ hour12: false,
767
+ });
768
+ }
769
+
770
+ /**
771
+ * Get initial timestamp for empty chat state
772
+ * Returns current time formatted with smart date display
773
+ */
774
+ getInitialTimestamp(): string {
775
+ return this.formatGroupTimestamp(new Date());
776
+ }
777
+
778
+ /**
779
+ * Group messages by time threshold
780
+ * Messages within the threshold and on the same day are grouped together
781
+ */
782
+ private groupMessagesByTime(messages: ChatMessage[], thresholdMinutes: number): MessageGroup[] {
783
+ const groups: MessageGroup[] = [];
784
+ let currentGroup: MessageGroup | null = null;
785
+
786
+ messages.forEach((message) => {
787
+ const messageDate = message.timestamp;
788
+
789
+ // Start a new group if:
790
+ // 1. It's the first message
791
+ // 2. More than threshold minutes have passed since last message
792
+ // 3. Date changed (new day)
793
+ if (!currentGroup || this.shouldStartNewGroup(currentGroup.timestamp, messageDate, thresholdMinutes)) {
794
+ currentGroup = {
795
+ timestamp: messageDate,
796
+ displayTimestamp: this.formatGroupTimestamp(messageDate),
797
+ messages: [],
798
+ };
799
+ groups.push(currentGroup);
800
+ }
801
+
802
+ currentGroup.messages.push(message);
803
+ });
804
+
805
+ return groups;
806
+ }
807
+
808
+ /**
809
+ * Determine if a new message group should be started
810
+ */
811
+ private shouldStartNewGroup(lastTime: Date, currentTime: Date, thresholdMinutes: number): boolean {
812
+ const diffMinutes = (currentTime.getTime() - lastTime.getTime()) / (1000 * 60);
813
+
814
+ // New group if different day
815
+ if (lastTime.toDateString() !== currentTime.toDateString()) {
816
+ return true;
817
+ }
818
+
819
+ // New group if threshold exceeded
820
+ return diffMinutes > thresholdMinutes;
821
+ }
822
+
823
+ /**
824
+ * Format group timestamp header with smart date display
825
+ * Uses 24-hour EU time format with Danish locale
826
+ */
827
+ private formatGroupTimestamp(date: Date): string {
828
+ const now = new Date();
829
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
830
+ const yesterday = new Date(today);
831
+ yesterday.setDate(yesterday.getDate() - 1);
832
+
833
+ const messageDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
834
+
835
+ // Format time in 24-hour EU format
836
+ const timeStr = date.toLocaleTimeString('da-DK', {
837
+ hour: '2-digit',
838
+ minute: '2-digit',
839
+ hour12: false,
840
+ });
841
+
842
+ // Today: "14:34"
843
+ if (messageDate.getTime() === today.getTime()) {
844
+ return timeStr;
845
+ }
846
+
847
+ // Yesterday: "I går, 14:34"
848
+ if (messageDate.getTime() === yesterday.getTime()) {
849
+ return `I går, ${timeStr}`;
850
+ }
851
+
852
+ // This week: "Mandag, 14:34"
853
+ const daysAgo = Math.floor((today.getTime() - messageDate.getTime()) / (1000 * 60 * 60 * 24));
854
+ if (daysAgo < 7) {
855
+ return date.toLocaleDateString('da-DK', { weekday: 'long' }) + `, ${timeStr}`;
856
+ }
857
+
858
+ // Older: "15. jan, 14:34" or "20. dec. 2024, 14:34" if different year
859
+ const dateFormat: Intl.DateTimeFormatOptions = {
860
+ month: 'short',
861
+ day: 'numeric',
862
+ };
863
+
864
+ if (date.getFullYear() !== now.getFullYear()) {
865
+ dateFormat.year = 'numeric';
866
+ }
867
+
868
+ return date.toLocaleDateString('da-DK', dateFormat) + `, ${timeStr}`;
869
+ }
870
+
871
+ /**
872
+ * Add display metadata to message groups
873
+ * Determines which messages should show avatars (only last in cluster)
874
+ * and calculates cluster positions for border radius styling
875
+ */
876
+ private addDisplayMetadata(groups: MessageGroup[]): Array<MessageGroup & { messages: MessageDisplay[] }> {
877
+ return groups.map((group) => {
878
+ const messagesWithDisplay: MessageDisplay[] = [];
879
+
880
+ for (let i = 0; i < group.messages.length; i++) {
881
+ const currentMsg = group.messages[i];
882
+ const prevMsg = group.messages[i - 1];
883
+ const nextMsg = group.messages[i + 1];
884
+
885
+ // Determine if this message starts a new cluster
886
+ const startsCluster = !prevMsg || prevMsg.senderId !== currentMsg.senderId;
887
+ // Determine if this message ends the cluster
888
+ const endsCluster = !nextMsg || nextMsg.senderId !== currentMsg.senderId;
889
+
890
+ // Show avatar only at the end of a cluster
891
+ const showAvatar = endsCluster;
892
+
893
+ // Determine cluster position for border radius
894
+ let clusterPosition: 'single' | 'first' | 'middle' | 'last';
895
+ if (startsCluster && endsCluster) {
896
+ clusterPosition = 'single';
897
+ } else if (startsCluster) {
898
+ clusterPosition = 'first';
899
+ } else if (endsCluster) {
900
+ clusterPosition = 'last';
901
+ } else {
902
+ clusterPosition = 'middle';
903
+ }
904
+
905
+ messagesWithDisplay.push({
906
+ ...currentMsg,
907
+ showAvatar,
908
+ clusterPosition,
909
+ });
910
+ }
911
+
912
+ return {
913
+ ...group,
914
+ messages: messagesWithDisplay,
915
+ };
916
+ });
917
+ }
918
+ }