@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.
- package/ng-package.json +24 -0
- package/package.json +3 -39
- package/src/animations/page-transitions.ts +165 -0
- package/src/assets/fonts/brockmann-mediumitalic-webfont.woff2 +0 -0
- package/src/assets/fonts/brockmann-regularitalic-webfont.woff2 +0 -0
- package/src/assets/fonts/brockmann-semibolditalic-webfont.woff2 +0 -0
- package/src/components/action-list-item/ds-mobile-action-list-item.ts +102 -0
- package/src/components/action-list-item/index.ts +2 -0
- package/src/components/app-icon/ds-app-icon.ts +133 -0
- package/src/components/app-icon/index.ts +2 -0
- package/src/components/attachment-preview/ds-mobile-attachment-preview.css +139 -0
- package/src/components/attachment-preview/ds-mobile-attachment-preview.ts +164 -0
- package/src/components/attachment-preview/index.ts +1 -0
- package/src/components/avatar-with-badge/ds-avatar-with-badge.ts +142 -0
- package/src/components/avatar-with-badge/index.ts +2 -0
- package/src/components/booking-modal/ds-mobile-booking-confirmation-wrapper.ts +71 -0
- package/src/components/booking-modal/ds-mobile-booking-modal.service.ts +121 -0
- package/src/components/booking-modal/ds-mobile-booking-modal.ts +598 -0
- package/src/components/booking-modal/ds-mobile-booking-summary.ts +161 -0
- package/src/components/booking-modal/index.ts +4 -0
- package/src/components/bottom-sheet/ds-mobile-actions-bottom-sheet.ts +266 -0
- package/src/components/bottom-sheet/ds-mobile-bottom-sheet-header.ts +146 -0
- package/src/components/bottom-sheet/ds-mobile-bottom-sheet-wrapper.ts +156 -0
- package/src/components/bottom-sheet/ds-mobile-bottom-sheet.css +101 -0
- package/src/components/bottom-sheet/ds-mobile-bottom-sheet.service.ts +169 -0
- package/src/components/bottom-sheet/ds-mobile-confirmation-sheet.ts +211 -0
- package/src/components/bottom-sheet/ds-mobile-post-create-bottom-sheet.ts +578 -0
- package/src/components/bottom-sheet/ds-mobile-profile-actions-sheet.ts +614 -0
- package/src/components/bottom-sheet/index.ts +8 -0
- package/src/components/bottom-sheet/modal-shadow-fix.ts +42 -0
- package/src/components/card-inline/ds-mobile-card-inline.ts +301 -0
- package/src/components/card-inline/index.ts +2 -0
- package/src/components/card-inline-banner/ds-mobile-card-inline-banner.ts +118 -0
- package/src/components/card-inline-banner/index.ts +1 -0
- package/src/components/card-inline-contact/ds-mobile-card-inline-contact.ts +120 -0
- package/src/components/card-inline-contact/index.ts +1 -0
- package/src/components/card-inline-file/ds-mobile-card-inline-file.ts +141 -0
- package/src/components/card-inline-file/index.ts +1 -0
- package/src/components/chat-modal/ds-mobile-chat-modal.css +159 -0
- package/src/components/chat-modal/ds-mobile-chat-modal.service.ts +105 -0
- package/src/components/chat-modal/ds-mobile-chat-modal.ts +918 -0
- package/src/components/chat-modal/index.ts +8 -0
- package/src/components/comment/ds-mobile-comment.ts +568 -0
- package/src/components/comment/index.ts +2 -0
- package/src/components/contact-list-item/ds-mobile-contact-list-item.ts +182 -0
- package/src/components/contact-list-item/index.ts +2 -0
- package/src/components/content/ds-mobile-content.ts +139 -0
- package/src/components/content/index.ts +2 -0
- package/src/components/dropdown/ds-mobile-dropdown.css +199 -0
- package/src/components/dropdown/ds-mobile-dropdown.ts +340 -0
- package/src/components/dropdown/index.ts +2 -0
- package/src/components/ds-mobile-tabs.css +407 -0
- package/src/components/ds-mobile-tabs.ts +216 -0
- package/src/components/empty-state/ds-mobile-empty-state.ts +120 -0
- package/src/components/empty-state/index.ts +2 -0
- package/src/components/fab/ds-mobile-fab.ts +315 -0
- package/src/components/fab/index.ts +1 -0
- package/src/components/facility-creation-modal/ds-mobile-facility-creation-confirmation-wrapper.ts +121 -0
- package/src/components/facility-creation-modal/ds-mobile-facility-creation-modal.css +189 -0
- package/src/components/facility-creation-modal/ds-mobile-facility-creation-modal.service.ts +135 -0
- package/src/components/facility-creation-modal/ds-mobile-facility-creation-modal.ts +656 -0
- package/src/components/facility-creation-modal/index.ts +9 -0
- package/src/components/facility-creation-modal/sheets/ds-mobile-access-sheet.ts +105 -0
- package/src/components/facility-creation-modal/sheets/ds-mobile-price-sheet.ts +188 -0
- package/src/components/facility-creation-modal/sheets/ds-mobile-when-can-book-sheet.ts +460 -0
- package/src/components/facility-creation-modal/sheets/ds-mobile-who-can-book-sheet.ts +134 -0
- package/src/components/facility-detail-modal/ds-mobile-facility-detail-modal.service.ts +69 -0
- package/src/components/facility-detail-modal/ds-mobile-facility-detail-modal.ts +379 -0
- package/src/components/facility-detail-modal/index.ts +2 -0
- package/src/components/file-attachment/ds-mobile-file-attachment.ts +164 -0
- package/src/components/file-attachment/index.ts +2 -0
- package/src/components/handbook-detail-modal/ds-mobile-handbook-detail-modal.css +214 -0
- package/src/components/handbook-detail-modal/ds-mobile-handbook-detail-modal.service.ts +84 -0
- package/src/components/handbook-detail-modal/ds-mobile-handbook-detail-modal.ts +424 -0
- package/src/components/handbook-detail-modal/index.ts +3 -0
- package/src/components/handbook-folder/ds-mobile-handbook-folder-mini.ts +175 -0
- package/src/components/handbook-folder/ds-mobile-handbook-folder.ts +533 -0
- package/src/components/handbook-folder/index.ts +4 -0
- package/src/components/header-content/ds-mobile-header-content.ts +222 -0
- package/src/components/header-content/index.ts +2 -0
- package/src/components/illustration/ds-mobile-illustration.ts +124 -0
- package/src/components/illustration/index.ts +2 -0
- package/src/components/index.ts +124 -0
- package/src/components/inline-photo/ds-mobile-inline-photo.ts +361 -0
- package/src/components/inline-photo/index.ts +1 -0
- package/src/components/inline-tabs/ds-mobile-inline-tabs.ts +132 -0
- package/src/components/inline-tabs/index.ts +2 -0
- package/src/components/interactive-list-item-booking/ds-mobile-interactive-list-item-booking.ts +350 -0
- package/src/components/interactive-list-item-booking/index.ts +1 -0
- package/src/components/interactive-list-item-inquiry/ds-mobile-interactive-list-item-inquiry.ts +321 -0
- package/src/components/interactive-list-item-inquiry/index.ts +2 -0
- package/src/components/interactive-list-item-message/ds-mobile-interactive-list-item-message.ts +237 -0
- package/src/components/interactive-list-item-message/index.ts +2 -0
- package/src/components/interactive-list-item-post/ds-mobile-interactive-list-item-post.ts +549 -0
- package/src/components/interactive-list-item-post/ds-mobile-post-pdf-attachment.ts +124 -0
- package/src/components/interactive-list-item-post/index.ts +13 -0
- package/src/components/lightbox/ds-mobile-lightbox-footer.ts +315 -0
- package/src/components/lightbox/ds-mobile-lightbox-header.ts +202 -0
- package/src/components/lightbox/ds-mobile-lightbox-image.ts +484 -0
- package/src/components/lightbox/ds-mobile-lightbox-pdf.css +377 -0
- package/src/components/lightbox/ds-mobile-lightbox-pdf.ts +374 -0
- package/src/components/lightbox/ds-mobile-lightbox.css +587 -0
- package/src/components/lightbox/ds-mobile-lightbox.service.ts +296 -0
- package/src/components/lightbox/ds-mobile-lightbox.ts +529 -0
- package/src/components/lightbox/index.ts +22 -0
- package/src/components/list-item/ds-mobile-list-item.ts +603 -0
- package/src/components/list-item/index.ts +2 -0
- package/src/components/list-item-static/ds-mobile-list-item-static.ts +133 -0
- package/src/components/list-item-static/index.ts +2 -0
- package/src/components/loader-overlay/ds-mobile-loader-overlay.css +49 -0
- package/src/components/loader-overlay/ds-mobile-loader-overlay.ts +77 -0
- package/src/components/loader-overlay/index.ts +1 -0
- package/src/components/logo/ds-logo.ts +95 -0
- package/src/components/logo/index.ts +2 -0
- package/src/components/message-bubble/ds-mobile-message-bubble.ts +633 -0
- package/src/components/message-bubble/index.ts +7 -0
- package/src/components/message-composer/ds-mobile-message-composer.ts +1146 -0
- package/src/components/message-composer/index.ts +7 -0
- package/src/components/modal/ds-mobile-modal.css +163 -0
- package/src/components/modal/ds-mobile-modal.service.ts +329 -0
- package/src/components/modal/index.ts +8 -0
- package/src/components/modal-base/ds-mobile-modal-base.css +378 -0
- package/src/components/modal-base/ds-mobile-modal-base.ts +261 -0
- package/src/components/modal-base/index.ts +2 -0
- package/src/components/new-inquiry-modal/ds-mobile-new-inquiry-modal.css +112 -0
- package/src/components/new-inquiry-modal/ds-mobile-new-inquiry-modal.service.ts +93 -0
- package/src/components/new-inquiry-modal/ds-mobile-new-inquiry-modal.ts +442 -0
- package/src/components/new-inquiry-modal/index.ts +4 -0
- package/src/components/offline-banner/ds-mobile-offline-banner.ts +135 -0
- package/src/components/offline-banner/index.ts +1 -0
- package/src/components/page-details/ds-mobile-page-details.css +83 -0
- package/src/components/page-details/ds-mobile-page-details.ts +282 -0
- package/src/components/page-details/index.ts +2 -0
- package/src/components/page-main/ds-mobile-page-main.css +68 -0
- package/src/components/page-main/ds-mobile-page-main.ts +421 -0
- package/src/components/page-main/index.ts +2 -0
- package/src/components/post-composer/ds-mobile-post-composer.ts +140 -0
- package/src/components/post-composer/index.ts +2 -0
- package/src/components/post-detail-modal/ds-mobile-post-detail-modal.css +390 -0
- package/src/components/post-detail-modal/ds-mobile-post-detail-modal.service.ts +108 -0
- package/src/components/post-detail-modal/ds-mobile-post-detail-modal.ts +722 -0
- package/src/components/post-detail-modal/index.ts +9 -0
- package/src/components/property-banner/ds-mobile-property-banner.ts +95 -0
- package/src/components/property-banner/index.ts +2 -0
- package/src/components/section/ds-mobile-section.ts +263 -0
- package/src/components/section/index.ts +2 -0
- package/src/components/shared/directives/index.ts +2 -0
- package/src/components/shared/directives/long-press.directive.ts +212 -0
- package/src/components/shared/index.ts +3 -0
- package/src/components/shared/mobile-modal-base.ts +457 -0
- package/src/components/shared/mobile-page-base.ts +204 -0
- package/src/components/swiper/ds-mobile-swiper-with-nav.ts +160 -0
- package/src/components/swiper/ds-mobile-swiper.ts +327 -0
- package/src/components/swiper/index.ts +3 -0
- package/src/components/system-message-banner/ds-mobile-system-message-banner.ts +129 -0
- package/src/components/system-message-banner/index.ts +2 -0
- package/src/components/tab-bar/ds-mobile-tab-bar.css +533 -0
- package/src/components/tab-bar/ds-mobile-tab-bar.ts +735 -0
- package/src/components/tab-bar/index.ts +2 -0
- package/src/components/tabs/ds-mobile-tabs.css +25 -0
- package/src/components/tabs/ds-mobile-tabs.ts +89 -0
- package/src/components/tabs/index.ts +2 -0
- package/src/components/text-input/ds-text-input.ts +287 -0
- package/src/components/text-input/index.ts +2 -0
- package/src/examples/booking.page.ts +434 -0
- package/src/examples/community.page.ts +776 -0
- package/src/examples/handbook.page.ts +324 -0
- package/src/examples/home.page.ts +347 -0
- package/src/examples/index.ts +12 -0
- package/src/examples/inquiries.example.ts +273 -0
- package/src/examples/inquiry-detail.example.css +189 -0
- package/src/examples/inquiry-detail.example.ts +415 -0
- package/src/examples/mobile-tabs-example.component.ts +208 -0
- package/src/examples/post-create.page.ts +311 -0
- package/src/examples/post-detail.page.ts +296 -0
- package/src/examples/sign-in.page.ts +291 -0
- package/src/examples/whitelabel-demo-modal.component.ts +1094 -0
- package/src/examples/whitelabel-demo-modal.service.ts +77 -0
- package/src/models/index.ts +7 -0
- package/src/models/post.model.ts +41 -0
- package/src/pages/community.page.ts +769 -0
- package/src/pages/handbook.page.ts +388 -0
- package/src/pages/home.page.ts +303 -0
- package/src/pages/index.ts +11 -0
- package/src/pages/inquiries.example.ts +273 -0
- package/src/pages/inquiry-detail.example.css +189 -0
- package/src/pages/inquiry-detail.example.ts +415 -0
- package/src/pages/mobile-tabs-example.component.ts +179 -0
- package/src/pages/post-create.page.ts +311 -0
- package/src/pages/post-detail.page.ts +296 -0
- package/src/pages/sign-in.page.ts +291 -0
- package/src/pages/whitelabel-demo-modal.component.ts +1094 -0
- package/src/pages/whitelabel-demo-modal.service.ts +77 -0
- package/src/public-api.ts +6 -0
- package/src/services/base-modal.service.ts +101 -0
- package/src/services/index.ts +11 -0
- package/src/services/posts.service.ts +542 -0
- package/src/services/tracking-permission.service.ts +88 -0
- package/src/services/user.service.ts +60 -0
- package/src/services/whitelabel.service.ts +675 -0
- package/{styles → src/styles}/ionic.css +25 -0
- package/tsconfig.lib.json +17 -0
- package/tsconfig.lib.prod.json +9 -0
- package/tsconfig.spec.json +13 -0
- package/fesm2022/propbinder-mobile-design.mjs +0 -26136
- package/fesm2022/propbinder-mobile-design.mjs.map +0 -1
- package/index.d.ts +0 -8154
- /package/{assets → src/assets}/fonts/Brockmann-Bold.otf +0 -0
- /package/{assets → src/assets}/fonts/Brockmann-BoldItalic.otf +0 -0
- /package/{assets → src/assets}/fonts/Brockmann-Medium.otf +0 -0
- /package/{assets → src/assets}/fonts/Brockmann-MediumItalic.otf +0 -0
- /package/{assets → src/assets}/fonts/Brockmann-Regular.otf +0 -0
- /package/{assets → src/assets}/fonts/Brockmann-RegularItalic.otf +0 -0
- /package/{assets → src/assets}/fonts/Brockmann-SemiBold.otf +0 -0
- /package/{assets → src/assets}/fonts/Brockmann-SemiBoldItalic.otf +0 -0
- /package/{assets → src/assets}/fonts/Brockmann_desktop_license.pdf +0 -0
- /package/{assets → src/assets}/fonts/brockmann-medium-webfont.woff2 +0 -0
- /package/{assets → src/assets}/fonts/brockmann-regular-webfont.woff2 +0 -0
- /package/{assets → src/assets}/fonts/brockmann-semibold-webfont.woff2 +0 -0
- /package/{styles → src/components/shared}/mobile-common.css +0 -0
- /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
|
+
}
|