@joewinke/jatui 0.1.0

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 (62) hide show
  1. package/package.json +46 -0
  2. package/src/lib/components/AudioWaveform.svelte +694 -0
  3. package/src/lib/components/AvailabilityModal.svelte +173 -0
  4. package/src/lib/components/Badge.svelte +38 -0
  5. package/src/lib/components/BookingForm.svelte +276 -0
  6. package/src/lib/components/Button.svelte +72 -0
  7. package/src/lib/components/CalendarPicker.svelte +284 -0
  8. package/src/lib/components/Card.svelte +67 -0
  9. package/src/lib/components/CharacterCounter.svelte +82 -0
  10. package/src/lib/components/ChipInput.svelte +596 -0
  11. package/src/lib/components/ColorSelector.svelte +163 -0
  12. package/src/lib/components/ConfirmModal.svelte +75 -0
  13. package/src/lib/components/CountdownTimer.svelte +94 -0
  14. package/src/lib/components/DateRangePicker.svelte +192 -0
  15. package/src/lib/components/Drawer.svelte +110 -0
  16. package/src/lib/components/FilterDropdown.svelte +202 -0
  17. package/src/lib/components/ImageUpload.svelte +97 -0
  18. package/src/lib/components/InlineEdit.svelte +283 -0
  19. package/src/lib/components/LazyImage.svelte +122 -0
  20. package/src/lib/components/LoadingSpinner.svelte +102 -0
  21. package/src/lib/components/Modal.svelte +208 -0
  22. package/src/lib/components/PhoneInput.svelte +92 -0
  23. package/src/lib/components/ResizableDivider.svelte +305 -0
  24. package/src/lib/components/ResizablePanel.svelte +302 -0
  25. package/src/lib/components/SearchDropdown.svelte +341 -0
  26. package/src/lib/components/SelectInput.svelte +215 -0
  27. package/src/lib/components/SignaturePad.svelte +171 -0
  28. package/src/lib/components/SortDropdown.svelte +148 -0
  29. package/src/lib/components/Sparkline.svelte +107 -0
  30. package/src/lib/components/SpeechForm.svelte +114 -0
  31. package/src/lib/components/StatusBadge.svelte +155 -0
  32. package/src/lib/components/TextArea.svelte +143 -0
  33. package/src/lib/components/TextInput.svelte +108 -0
  34. package/src/lib/components/ThemeSelector.svelte +195 -0
  35. package/src/lib/components/TimeSlotPicker.svelte +162 -0
  36. package/src/lib/components/VoicePlayer.svelte +420 -0
  37. package/src/lib/components/messaging/Avatar.svelte +81 -0
  38. package/src/lib/components/messaging/ChannelInfoModal.svelte +163 -0
  39. package/src/lib/components/messaging/ChannelList.svelte +107 -0
  40. package/src/lib/components/messaging/ChannelMemberAvatarStack.svelte +69 -0
  41. package/src/lib/components/messaging/ChannelMembersModal.svelte +182 -0
  42. package/src/lib/components/messaging/CreateChannelModal.svelte +190 -0
  43. package/src/lib/components/messaging/DirectMessageList.svelte +145 -0
  44. package/src/lib/components/messaging/EmojiSelector.svelte +260 -0
  45. package/src/lib/components/messaging/MentionAutocomplete.svelte +193 -0
  46. package/src/lib/components/messaging/MessageAttachment.svelte +270 -0
  47. package/src/lib/components/messaging/MessageAttachmentUpload.svelte +243 -0
  48. package/src/lib/components/messaging/MessageInput.svelte +451 -0
  49. package/src/lib/components/messaging/MessageItem.svelte +338 -0
  50. package/src/lib/components/messaging/MessageThread.svelte +306 -0
  51. package/src/lib/components/messaging/NotificationSettingsModal.svelte +234 -0
  52. package/src/lib/components/messaging/QuotedMessageDisplay.svelte +118 -0
  53. package/src/lib/components/messaging/StartDMModal.svelte +100 -0
  54. package/src/lib/components/messaging/ThreadPanel.svelte +153 -0
  55. package/src/lib/index.ts +185 -0
  56. package/src/lib/types/booking.ts +143 -0
  57. package/src/lib/types/messaging.ts +459 -0
  58. package/src/lib/utils/currency.ts +20 -0
  59. package/src/lib/utils/daisyuiColors.ts +243 -0
  60. package/src/lib/utils/dateFormatters.ts +153 -0
  61. package/src/lib/utils/mentionParser.ts +188 -0
  62. package/src/lib/utils/phoneFormat.ts +74 -0
@@ -0,0 +1,234 @@
1
+ <!--
2
+ NotificationSettingsModal Component
3
+
4
+ Manages user notification preferences for messaging.
5
+ Supports both global settings and per-channel preferences.
6
+ All operations via callbacks.
7
+ -->
8
+
9
+ <script lang="ts">
10
+ import type { NotificationCallbacks } from '../../types/messaging'
11
+
12
+ interface GlobalSettings {
13
+ enable_notifications: boolean
14
+ enable_sound: boolean
15
+ enable_desktop: boolean
16
+ quiet_hours_enabled: boolean
17
+ quiet_hours_start: string
18
+ quiet_hours_end: string
19
+ weekend_notifications: boolean
20
+ show_typing_indicators: boolean
21
+ show_read_receipts: boolean
22
+ }
23
+
24
+ interface ChannelPreferences {
25
+ muted: boolean
26
+ mute_until?: string
27
+ notification_level: 'all' | 'mentions' | 'none'
28
+ desktop_notifications: boolean
29
+ sound_enabled: boolean
30
+ }
31
+
32
+ interface Props {
33
+ type: 'global' | 'channel'
34
+ channelId?: string
35
+ channelName?: string
36
+ open: boolean
37
+ notificationCallbacks: NotificationCallbacks
38
+ onclose?: () => void
39
+ onsettingsupdate?: () => void
40
+ }
41
+
42
+ let {
43
+ type,
44
+ channelId,
45
+ channelName,
46
+ open = false,
47
+ notificationCallbacks,
48
+ onclose,
49
+ onsettingsupdate,
50
+ }: Props = $props()
51
+
52
+ let loading = $state(true)
53
+ let saving = $state(false)
54
+ let error = $state<string | null>(null)
55
+
56
+ // Global settings
57
+ let globalSettings = $state<GlobalSettings>({
58
+ enable_notifications: true,
59
+ enable_sound: true,
60
+ enable_desktop: false,
61
+ quiet_hours_enabled: false,
62
+ quiet_hours_start: '22:00',
63
+ quiet_hours_end: '08:00',
64
+ weekend_notifications: true,
65
+ show_typing_indicators: true,
66
+ show_read_receipts: true,
67
+ })
68
+
69
+ // Channel preferences
70
+ let channelPrefs = $state<ChannelPreferences>({
71
+ muted: false,
72
+ notification_level: 'all',
73
+ desktop_notifications: true,
74
+ sound_enabled: true,
75
+ })
76
+
77
+ // Load settings when modal opens
78
+ $effect(() => {
79
+ if (open) {
80
+ loadSettings()
81
+ }
82
+ })
83
+
84
+ async function loadSettings() {
85
+ loading = true
86
+ error = null
87
+ try {
88
+ const settings = await notificationCallbacks.loadSettings({ type, channelId })
89
+ if (type === 'global' && settings) {
90
+ globalSettings = { ...globalSettings, ...settings } as unknown as GlobalSettings
91
+ } else if (type === 'channel' && settings) {
92
+ channelPrefs = { ...channelPrefs, ...settings } as unknown as ChannelPreferences
93
+ }
94
+ } catch (err) {
95
+ error = err instanceof Error ? err.message : 'Failed to load settings'
96
+ } finally {
97
+ loading = false
98
+ }
99
+ }
100
+
101
+ async function saveSettings() {
102
+ saving = true
103
+ error = null
104
+ try {
105
+ const settings = type === 'global' ? globalSettings : channelPrefs
106
+ await notificationCallbacks.saveSettings(
107
+ { type, channelId },
108
+ settings as any,
109
+ )
110
+ onsettingsupdate?.()
111
+ onclose?.()
112
+ } catch (err) {
113
+ error = err instanceof Error ? err.message : 'Failed to save settings'
114
+ } finally {
115
+ saving = false
116
+ }
117
+ }
118
+ </script>
119
+
120
+ {#if open}
121
+ <dialog class="modal modal-open">
122
+ <div class="modal-box max-w-md">
123
+ <div class="flex items-center justify-between mb-4">
124
+ <h3 class="font-bold text-lg">
125
+ {type === 'global' ? 'Notification Settings' : `Notifications: #${channelName}`}
126
+ </h3>
127
+ <button class="btn btn-ghost btn-sm btn-circle" onclick={onclose}>✕</button>
128
+ </div>
129
+
130
+ {#if error}
131
+ <div class="alert alert-error mb-4"><span class="text-sm">{error}</span></div>
132
+ {/if}
133
+
134
+ {#if loading}
135
+ <div class="flex justify-center py-8">
136
+ <span class="loading loading-spinner loading-md"></span>
137
+ </div>
138
+ {:else if type === 'global'}
139
+ <div class="space-y-4">
140
+ <div class="form-control">
141
+ <label class="label cursor-pointer">
142
+ <span class="label-text">Enable Notifications</span>
143
+ <input type="checkbox" bind:checked={globalSettings.enable_notifications} class="toggle toggle-primary" />
144
+ </label>
145
+ </div>
146
+ <div class="form-control">
147
+ <label class="label cursor-pointer">
148
+ <span class="label-text">Sound</span>
149
+ <input type="checkbox" bind:checked={globalSettings.enable_sound} class="toggle toggle-primary" />
150
+ </label>
151
+ </div>
152
+ <div class="form-control">
153
+ <label class="label cursor-pointer">
154
+ <span class="label-text">Desktop Notifications</span>
155
+ <input type="checkbox" bind:checked={globalSettings.enable_desktop} class="toggle toggle-primary" />
156
+ </label>
157
+ </div>
158
+ <div class="divider text-xs text-base-content/50">Quiet Hours</div>
159
+ <div class="form-control">
160
+ <label class="label cursor-pointer">
161
+ <span class="label-text">Enable Quiet Hours</span>
162
+ <input type="checkbox" bind:checked={globalSettings.quiet_hours_enabled} class="toggle toggle-primary" />
163
+ </label>
164
+ </div>
165
+ {#if globalSettings.quiet_hours_enabled}
166
+ <div class="flex gap-3">
167
+ <div class="flex-1">
168
+ <label class="label"><span class="label-text text-xs">Start</span></label>
169
+ <input type="time" bind:value={globalSettings.quiet_hours_start} class="input input-bordered input-sm w-full" />
170
+ </div>
171
+ <div class="flex-1">
172
+ <label class="label"><span class="label-text text-xs">End</span></label>
173
+ <input type="time" bind:value={globalSettings.quiet_hours_end} class="input input-bordered input-sm w-full" />
174
+ </div>
175
+ </div>
176
+ {/if}
177
+ <div class="divider text-xs text-base-content/50">Display</div>
178
+ <div class="form-control">
179
+ <label class="label cursor-pointer">
180
+ <span class="label-text">Show Typing Indicators</span>
181
+ <input type="checkbox" bind:checked={globalSettings.show_typing_indicators} class="toggle toggle-primary" />
182
+ </label>
183
+ </div>
184
+ <div class="form-control">
185
+ <label class="label cursor-pointer">
186
+ <span class="label-text">Show Read Receipts</span>
187
+ <input type="checkbox" bind:checked={globalSettings.show_read_receipts} class="toggle toggle-primary" />
188
+ </label>
189
+ </div>
190
+ </div>
191
+ {:else}
192
+ <div class="space-y-4">
193
+ <div class="form-control">
194
+ <label class="label cursor-pointer">
195
+ <span class="label-text">Mute Channel</span>
196
+ <input type="checkbox" bind:checked={channelPrefs.muted} class="toggle toggle-warning" />
197
+ </label>
198
+ </div>
199
+ <div>
200
+ <label class="label"><span class="label-text font-medium">Notification Level</span></label>
201
+ <select bind:value={channelPrefs.notification_level} class="select select-bordered w-full">
202
+ <option value="all">All Messages</option>
203
+ <option value="mentions">Mentions Only</option>
204
+ <option value="none">None</option>
205
+ </select>
206
+ </div>
207
+ <div class="form-control">
208
+ <label class="label cursor-pointer">
209
+ <span class="label-text">Desktop Notifications</span>
210
+ <input type="checkbox" bind:checked={channelPrefs.desktop_notifications} class="toggle toggle-primary" />
211
+ </label>
212
+ </div>
213
+ <div class="form-control">
214
+ <label class="label cursor-pointer">
215
+ <span class="label-text">Sound</span>
216
+ <input type="checkbox" bind:checked={channelPrefs.sound_enabled} class="toggle toggle-primary" />
217
+ </label>
218
+ </div>
219
+ </div>
220
+ {/if}
221
+
222
+ <div class="modal-action">
223
+ <button class="btn btn-ghost" onclick={onclose}>Cancel</button>
224
+ <button class="btn btn-primary" onclick={saveSettings} disabled={saving || loading}>
225
+ {#if saving}<span class="loading loading-spinner loading-xs"></span>{/if}
226
+ Save
227
+ </button>
228
+ </div>
229
+ </div>
230
+ <form method="dialog" class="modal-backdrop">
231
+ <button onclick={onclose}>close</button>
232
+ </form>
233
+ </dialog>
234
+ {/if}
@@ -0,0 +1,118 @@
1
+ <!--
2
+ QuotedMessageDisplay Component
3
+
4
+ Displays a quoted message with sender info, truncated content, and timestamp.
5
+ Used for both quote previews in input and quoted messages in conversation.
6
+ -->
7
+
8
+ <script lang="ts">
9
+ import type { QuotedMessage } from '../../types/messaging'
10
+ import { renderMentionsAsHTML } from '../../utils/mentionParser'
11
+ import Avatar from './Avatar.svelte'
12
+
13
+ interface Props {
14
+ quotedMessage: QuotedMessage
15
+ mode?: 'inline' | 'preview'
16
+ class?: string
17
+ onremove?: () => void
18
+ }
19
+
20
+ let {
21
+ quotedMessage,
22
+ mode = 'inline',
23
+ class: className = '',
24
+ onremove,
25
+ }: Props = $props()
26
+
27
+ function formatTime(dateString: string): string {
28
+ const date = new Date(dateString)
29
+ const now = new Date()
30
+
31
+ if (date.toDateString() === now.toDateString()) {
32
+ return date.toLocaleTimeString('en-US', {
33
+ hour: 'numeric',
34
+ minute: '2-digit',
35
+ hour12: true,
36
+ })
37
+ }
38
+
39
+ return date.toLocaleDateString('en-US', {
40
+ month: 'short',
41
+ day: 'numeric',
42
+ })
43
+ }
44
+
45
+ function truncateContent(content: string, maxLength: number = 150): string {
46
+ if (content.length <= maxLength) return content
47
+ return content.substring(0, maxLength) + '...'
48
+ }
49
+
50
+ const renderedContent = $derived(
51
+ renderMentionsAsHTML(truncateContent(quotedMessage.content)),
52
+ )
53
+ </script>
54
+
55
+ <div class="quoted-message-display {mode} {className}">
56
+ <div
57
+ class="flex items-start gap-2 p-3 bg-base-200/50 rounded-lg border-l-4 border-primary/40 relative"
58
+ >
59
+ {#if mode === 'preview' && onremove}
60
+ <button
61
+ class="absolute top-1 right-1 btn btn-ghost btn-xs btn-circle text-base-content/50 hover:text-error"
62
+ onclick={onremove}
63
+ title="Remove quote"
64
+ >
65
+
66
+ </button>
67
+ {/if}
68
+
69
+ <div class="flex-shrink-0">
70
+ <Avatar
71
+ name={quotedMessage.senderName}
72
+ avatarUrl={quotedMessage.senderAvatar}
73
+ size="xs"
74
+ />
75
+ </div>
76
+
77
+ <div class="flex-1 min-w-0">
78
+ <div class="flex items-center gap-2 mb-1">
79
+ <span class="font-medium text-sm text-base-content/80">
80
+ {quotedMessage.senderName}
81
+ </span>
82
+ <span class="text-xs text-base-content/50">
83
+ {formatTime(quotedMessage.createdAt)}
84
+ </span>
85
+ </div>
86
+
87
+ <div class="text-sm text-base-content/70 line-clamp-3 break-words message-content">
88
+ {@html renderedContent}
89
+ </div>
90
+ </div>
91
+ </div>
92
+ </div>
93
+
94
+ <style>
95
+ .line-clamp-3 {
96
+ display: -webkit-box;
97
+ -webkit-line-clamp: 3;
98
+ -webkit-box-orient: vertical;
99
+ overflow: hidden;
100
+ }
101
+
102
+ .quoted-message-display.preview {
103
+ margin-bottom: 8px;
104
+ }
105
+
106
+ .quoted-message-display.inline {
107
+ margin-bottom: 12px;
108
+ }
109
+
110
+ .message-content :global(.mention-link) {
111
+ color: oklch(var(--p) / 0.6);
112
+ transition: color 0.2s;
113
+ }
114
+
115
+ .message-content :global(.mention-link):hover {
116
+ color: oklch(var(--p) / 0.8);
117
+ }
118
+ </style>
@@ -0,0 +1,100 @@
1
+ <!--
2
+ StartDMModal Component
3
+
4
+ Modal for starting a new direct message conversation.
5
+ Shows list of organization members to message.
6
+ -->
7
+
8
+ <script lang="ts">
9
+ import type { OrgMember } from '../../types/messaging'
10
+ import Avatar from './Avatar.svelte'
11
+
12
+ interface Props {
13
+ open: boolean
14
+ currentUserId: string
15
+ orgMembers?: OrgMember[]
16
+ onclose?: () => void
17
+ onstartconversation?: (data: { userId: string; userName: string }) => void
18
+ }
19
+
20
+ let {
21
+ open = false,
22
+ currentUserId,
23
+ orgMembers = [],
24
+ onclose,
25
+ onstartconversation,
26
+ }: Props = $props()
27
+
28
+ let searchQuery = $state('')
29
+
30
+ const filteredMembers = $derived(
31
+ orgMembers
32
+ .filter((m) => m.id !== currentUserId)
33
+ .filter((m) => !searchQuery || m.full_name.toLowerCase().includes(searchQuery.toLowerCase()))
34
+ .sort((a, b) => a.full_name.localeCompare(b.full_name)),
35
+ )
36
+
37
+ function handleSelect(member: OrgMember) {
38
+ onstartconversation?.({ userId: member.id, userName: member.full_name })
39
+ onclose?.()
40
+ searchQuery = ''
41
+ }
42
+
43
+ function handleClose() {
44
+ searchQuery = ''
45
+ onclose?.()
46
+ }
47
+ </script>
48
+
49
+ {#if open}
50
+ <dialog class="modal modal-open">
51
+ <div class="modal-box max-w-md">
52
+ <div class="flex items-center justify-between mb-4">
53
+ <h3 class="font-bold text-lg">New Message</h3>
54
+ <button class="btn btn-ghost btn-sm btn-circle" onclick={handleClose}>✕</button>
55
+ </div>
56
+
57
+ <!-- Search -->
58
+ <input
59
+ type="text"
60
+ bind:value={searchQuery}
61
+ class="input input-bordered w-full mb-4"
62
+ placeholder="Search team members..."
63
+ />
64
+
65
+ <!-- Members list -->
66
+ <div class="max-h-80 overflow-y-auto space-y-1">
67
+ {#each filteredMembers as member}
68
+ <button
69
+ class="w-full flex items-center gap-3 px-3 py-2 rounded hover:bg-base-200 transition-colors"
70
+ onclick={() => handleSelect(member)}
71
+ >
72
+ <Avatar
73
+ name={member.full_name}
74
+ avatarUrl={member.avatar_url}
75
+ size="small"
76
+ showStatus={true}
77
+ status={member.active ? 'online' : 'offline'}
78
+ />
79
+ <div class="flex-1 text-left">
80
+ <div class="text-sm font-medium">{member.full_name}</div>
81
+ <div class="text-xs text-base-content/50 capitalize">{member.role}</div>
82
+ </div>
83
+ </button>
84
+ {/each}
85
+
86
+ {#if filteredMembers.length === 0}
87
+ <div class="text-center py-8 text-base-content/50">
88
+ <div class="text-2xl mb-2">💬</div>
89
+ <div class="text-sm">
90
+ {searchQuery ? `No members matching "${searchQuery}"` : 'No team members available'}
91
+ </div>
92
+ </div>
93
+ {/if}
94
+ </div>
95
+ </div>
96
+ <form method="dialog" class="modal-backdrop">
97
+ <button onclick={handleClose}>close</button>
98
+ </form>
99
+ </dialog>
100
+ {/if}
@@ -0,0 +1,153 @@
1
+ <!--
2
+ ThreadPanel Component
3
+
4
+ Shows threaded replies to a parent message in a sidebar view.
5
+ All data operations are via callbacks.
6
+ -->
7
+
8
+ <script lang="ts">
9
+ import type {
10
+ Message,
11
+ PendingAttachment,
12
+ ReactionSummary,
13
+ StandardEmoji,
14
+ MentionItem,
15
+ ThreadCallbacks,
16
+ ReactionCallbacks,
17
+ AttachmentCallbacks,
18
+ } from '../../types/messaging'
19
+ import { renderMentionsAsHTML } from '../../utils/mentionParser'
20
+ import MessageItem from './MessageItem.svelte'
21
+ import MessageInput from './MessageInput.svelte'
22
+ import Avatar from './Avatar.svelte'
23
+
24
+ interface Props {
25
+ parentMessage: Message
26
+ replies?: Message[]
27
+ currentUserId: string
28
+ quickReactions?: StandardEmoji[]
29
+ threadCallbacks: ThreadCallbacks
30
+ reactionCallbacks?: ReactionCallbacks
31
+ attachmentCallbacks?: AttachmentCallbacks
32
+ getReactions?: (messageId: string) => ReactionSummary[]
33
+ onsearchmentions?: (query: string) => Promise<MentionItem[]>
34
+ onclose?: () => void
35
+ }
36
+
37
+ let {
38
+ parentMessage,
39
+ replies = [],
40
+ currentUserId,
41
+ quickReactions = [],
42
+ threadCallbacks,
43
+ reactionCallbacks,
44
+ attachmentCallbacks,
45
+ getReactions,
46
+ onsearchmentions,
47
+ onclose,
48
+ }: Props = $props()
49
+
50
+ let sending = $state(false)
51
+ let messagesContainer: HTMLElement
52
+
53
+ const renderedParentContent = $derived(renderMentionsAsHTML(parentMessage.content))
54
+
55
+ function isConsecutive(msg: Message, index: number): boolean {
56
+ if (index === 0) return false
57
+ const prev = replies[index - 1]
58
+ if (!prev || prev.senderId !== msg.senderId) return false
59
+ const diff = new Date(msg.createdAt).getTime() - new Date(prev.createdAt).getTime()
60
+ return diff < 5 * 60 * 1000
61
+ }
62
+
63
+ async function handleSend(data: { content: string; attachments?: PendingAttachment[] }) {
64
+ if (sending) return
65
+ sending = true
66
+ try {
67
+ await threadCallbacks.sendReply(parentMessage.id, data.content, data.attachments)
68
+ scrollToBottom()
69
+ } catch (err) {
70
+ console.error('Failed to send reply:', err)
71
+ } finally {
72
+ sending = false
73
+ }
74
+ }
75
+
76
+ function scrollToBottom() {
77
+ setTimeout(() => {
78
+ if (messagesContainer) messagesContainer.scrollTop = messagesContainer.scrollHeight
79
+ }, 50)
80
+ }
81
+
82
+ function formatTime(dateString: string): string {
83
+ const date = new Date(dateString)
84
+ return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })
85
+ }
86
+ </script>
87
+
88
+ <div class="flex flex-col h-full">
89
+ <!-- Header -->
90
+ <div class="flex items-center justify-between px-4 py-3 border-b border-base-300">
91
+ <h3 class="font-semibold text-sm">Thread</h3>
92
+ {#if onclose}
93
+ <button class="btn btn-ghost btn-xs btn-circle" onclick={onclose} title="Close thread">✕</button>
94
+ {/if}
95
+ </div>
96
+
97
+ <!-- Parent message -->
98
+ <div class="px-4 py-3 border-b border-base-300 bg-base-200/30">
99
+ <div class="flex items-start gap-3">
100
+ <Avatar name={parentMessage.senderName} avatarUrl={parentMessage.senderAvatar} size="small" />
101
+ <div class="flex-1 min-w-0">
102
+ <div class="flex items-center gap-2 mb-1">
103
+ <span class="font-medium text-sm">{parentMessage.senderName}</span>
104
+ <span class="text-xs text-base-content/50">{formatTime(parentMessage.createdAt)}</span>
105
+ </div>
106
+ <div class="text-sm whitespace-pre-wrap break-words message-content">
107
+ {@html renderedParentContent}
108
+ </div>
109
+ </div>
110
+ </div>
111
+
112
+ {#if replies.length > 0}
113
+ <div class="mt-2 text-xs text-base-content/50">
114
+ {replies.length} {replies.length === 1 ? 'reply' : 'replies'}
115
+ </div>
116
+ {/if}
117
+ </div>
118
+
119
+ <!-- Replies -->
120
+ <div bind:this={messagesContainer} class="flex-1 overflow-y-auto px-4 py-2">
121
+ {#if replies.length === 0}
122
+ <div class="flex flex-col items-center justify-center h-full text-base-content/50">
123
+ <div class="text-2xl mb-2">💬</div>
124
+ <p class="text-sm">No replies yet</p>
125
+ </div>
126
+ {:else}
127
+ {#each replies as reply, index (reply.id)}
128
+ <MessageItem
129
+ message={reply}
130
+ isOwn={reply.senderId === currentUserId}
131
+ isConsecutive={isConsecutive(reply, index)}
132
+ {currentUserId}
133
+ reactions={getReactions?.(reply.id) || []}
134
+ {quickReactions}
135
+ {reactionCallbacks}
136
+ {attachmentCallbacks}
137
+ />
138
+ {/each}
139
+ {/if}
140
+ </div>
141
+
142
+ <!-- Reply input -->
143
+ <div class="px-4 py-3 border-t border-base-300">
144
+ <MessageInput
145
+ placeholder="Reply in thread..."
146
+ compact={true}
147
+ disabled={sending}
148
+ onsend={handleSend}
149
+ {onsearchmentions}
150
+ {attachmentCallbacks}
151
+ />
152
+ </div>
153
+ </div>