deepspace 0.3.9 → 0.3.11

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 (120) hide show
  1. package/dist/cli.js +96 -18
  2. package/dist/cli.js.map +1 -1
  3. package/dist/index.d.ts +160 -1
  4. package/dist/index.js +283 -126
  5. package/dist/index.js.map +1 -1
  6. package/dist/server.d.ts +172 -2
  7. package/dist/server.js +581 -1
  8. package/dist/server.js.map +1 -1
  9. package/dist/worker.d.ts +252 -2
  10. package/dist/worker.js +588 -1
  11. package/dist/worker.js.map +1 -1
  12. package/features/admin-page/feature.json +29 -0
  13. package/features/admin-page/src/AdminPage.tsx +292 -0
  14. package/features/admin-page/src/admin-schema.ts +20 -0
  15. package/features/ai-chat/feature.json +41 -0
  16. package/features/ai-chat/src/AiChatPage.tsx +692 -0
  17. package/features/ai-chat/src/ChatPanel.tsx +1098 -0
  18. package/features/ai-chat/src/ai-chat-schema.ts +11 -0
  19. package/features/canvas/feature.json +45 -0
  20. package/features/canvas/src/CanvasEditorPage.tsx +87 -0
  21. package/features/canvas/src/CanvasListPage.tsx +202 -0
  22. package/features/canvas/src/canvas-schema.ts +33 -0
  23. package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
  24. package/features/canvas/src/components/CanvasView.tsx +369 -0
  25. package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
  26. package/features/cron/feature.json +33 -0
  27. package/features/cron/src/CronLogPage.tsx +100 -0
  28. package/features/cron/src/cron.ts +25 -0
  29. package/features/docs/feature.json +96 -0
  30. package/features/docs/src/DocsEditorPage.tsx +1012 -0
  31. package/features/docs/src/DocsListPage.tsx +951 -0
  32. package/features/docs/src/DocsOutlinePanel.tsx +68 -0
  33. package/features/docs/src/DocsPresence.tsx +138 -0
  34. package/features/docs/src/InviteDialog.tsx +336 -0
  35. package/features/docs/src/LibrarySidebar.tsx +464 -0
  36. package/features/docs/src/docs-favorites.ts +18 -0
  37. package/features/docs/src/docs-library-types.ts +15 -0
  38. package/features/docs/src/docs-schema.ts +80 -0
  39. package/features/docs/src/docs-ui.css +309 -0
  40. package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
  41. package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
  42. package/features/docs/src/editor/useDocEditor.ts +206 -0
  43. package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
  44. package/features/file-attachments/feature.json +42 -0
  45. package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
  46. package/features/file-attachments/src/attachments-constants.ts +9 -0
  47. package/features/file-attachments/src/attachments-schema.ts +45 -0
  48. package/features/file-attachments/src/useFilePreview.ts +471 -0
  49. package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
  50. package/features/file-manager/feature.json +24 -0
  51. package/features/file-manager/src/FileManagerPage.tsx +330 -0
  52. package/features/integration-test/feature.json +18 -0
  53. package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
  54. package/features/integration-test/src/integration.spec.ts +42 -0
  55. package/features/items/feature.json +33 -0
  56. package/features/items/src/ItemsPage.tsx +325 -0
  57. package/features/items/src/items-constants.ts +10 -0
  58. package/features/items/src/items-schema.ts +27 -0
  59. package/features/kanban/feature.json +18 -0
  60. package/features/kanban/src/Kanban.tsx +283 -0
  61. package/features/landing/feature.json +62 -0
  62. package/features/landing/src/LandingPage.tsx +627 -0
  63. package/features/landing/src/primitives.tsx +389 -0
  64. package/features/landing/src/sections/FAQSection.tsx +110 -0
  65. package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
  66. package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
  67. package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
  68. package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
  69. package/features/landing/src/sections/StatsSection.tsx +31 -0
  70. package/features/landing/src/sections/TeamSection.tsx +86 -0
  71. package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
  72. package/features/landing/src/sections/VideoSection.tsx +71 -0
  73. package/features/leaderboard/feature.json +33 -0
  74. package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
  75. package/features/leaderboard/src/leaderboard-constants.ts +21 -0
  76. package/features/leaderboard/src/leaderboard-schema.ts +36 -0
  77. package/features/messaging/feature.json +111 -0
  78. package/features/messaging/src/ChatMultiPage.tsx +300 -0
  79. package/features/messaging/src/ChatPage.tsx +75 -0
  80. package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
  81. package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
  82. package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
  83. package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
  84. package/features/messaging/src/components/chat/MessageList.tsx +248 -0
  85. package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
  86. package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
  87. package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
  88. package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
  89. package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
  90. package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
  91. package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
  92. package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
  93. package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
  94. package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
  95. package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
  96. package/features/messaging/src/hooks/useChatChannel.ts +64 -0
  97. package/features/messaging/src/hooks/useLongPress.ts +74 -0
  98. package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
  99. package/features/messaging/src/messaging-schema.ts +25 -0
  100. package/features/presence-test/feature.json +21 -0
  101. package/features/presence-test/src/PresenceTestPage.tsx +120 -0
  102. package/features/sidebar/feature.json +19 -0
  103. package/features/sidebar/src/AppSidebar.tsx +558 -0
  104. package/features/tasks/feature.json +33 -0
  105. package/features/tasks/src/TasksPage.tsx +598 -0
  106. package/features/tasks/src/tasks-constants.ts +43 -0
  107. package/features/tasks/src/tasks-schema.ts +56 -0
  108. package/features/testing/feature.json +49 -0
  109. package/features/testing/src/TestPage.tsx +145 -0
  110. package/features/testing/src/cpu-burn-action.ts +51 -0
  111. package/features/testing/src/subrequest-burn-action.ts +53 -0
  112. package/features/testing/src/test-schema.ts +23 -0
  113. package/features/topbar/feature.json +18 -0
  114. package/features/topbar/src/Topbar.tsx +353 -0
  115. package/features/tree/feature.json +26 -0
  116. package/features/tree/src/TreeSidebar.tsx +607 -0
  117. package/features/tree/src/tree-constants.ts +68 -0
  118. package/features/tree/src/tree-hooks.ts +258 -0
  119. package/package.json +4 -2
  120. package/scripts/add-feature.cjs +654 -0
@@ -0,0 +1,352 @@
1
+ /**
2
+ * MessageItem -- Single message row with avatar, grouped timestamps,
3
+ * desktop hover toolbar, mobile long-press delegation, thread reply count,
4
+ * presence indicator, and user profile popover.
5
+ */
6
+
7
+ import { useCallback, useEffect, useRef, useState } from 'react'
8
+ import { useUserLookup } from 'deepspace'
9
+ import { useUser } from 'deepspace'
10
+ import { usePresence } from 'deepspace'
11
+ import type { Message } from 'deepspace'
12
+ import type { RecordData } from 'deepspace'
13
+ import type { GroupedReaction } from 'deepspace'
14
+ import { UserProfilePopover } from './UserProfilePopover'
15
+ import { useLongPress } from '../hooks/useLongPress'
16
+
17
+ const QUICK_EMOJIS = ['\u{1F44D}', '\u2764\uFE0F', '\u{1F602}', '\u{1F389}', '\u{1F525}', '\u{1F440}']
18
+
19
+ export interface MessageRect {
20
+ top: number
21
+ left: number
22
+ width: number
23
+ height: number
24
+ }
25
+
26
+ interface MessageItemProps {
27
+ message: RecordData<Message>
28
+ reactions: GroupedReaction[]
29
+ threadCount: number
30
+ onToggleReaction: (emoji: string) => void
31
+ onOpenThread: () => void
32
+ onEdit: (newContent: string) => void
33
+ onDelete: () => void
34
+ isFirstInGroup: boolean
35
+ onLongPress?: (rect: MessageRect) => void
36
+ forceEdit?: boolean
37
+ onEditDone?: () => void
38
+ isHighlighted?: boolean
39
+ onStartDM?: (userId: string) => void
40
+ }
41
+
42
+ export function MessageItem({
43
+ message,
44
+ reactions,
45
+ threadCount,
46
+ onToggleReaction,
47
+ onOpenThread,
48
+ onEdit,
49
+ onDelete,
50
+ isFirstInGroup,
51
+ onLongPress,
52
+ forceEdit = false,
53
+ onEditDone,
54
+ isHighlighted = false,
55
+ onStartDM,
56
+ }: MessageItemProps) {
57
+ const { getUser } = useUserLookup()
58
+ const { user: currentUser } = useUser()
59
+ const { isOnline } = usePresence()
60
+ const [isEditing, setIsEditing] = useState(false)
61
+ const [editContent, setEditContent] = useState(message.data.content)
62
+ const editContainerRef = useRef<HTMLDivElement>(null)
63
+ const contentRef = useRef<HTMLDivElement>(null)
64
+
65
+ const authorId = message.data.authorId || message.createdBy
66
+ const author = getUser(authorId)
67
+ const isOwn = currentUser?.id === authorId || currentUser?.id === message.createdBy
68
+ const online = isOnline(authorId)
69
+
70
+ const longPressHandlers = useLongPress(
71
+ () => {
72
+ if (!contentRef.current) return
73
+ const rect = contentRef.current.getBoundingClientRect()
74
+ onLongPress?.({
75
+ top: rect.top,
76
+ left: rect.left,
77
+ width: rect.width,
78
+ height: rect.height,
79
+ })
80
+ },
81
+ { delay: 500, threshold: 10 }
82
+ )
83
+
84
+ useEffect(() => {
85
+ if (forceEdit && !isEditing) {
86
+ setIsEditing(true)
87
+ setEditContent(message.data.content)
88
+ }
89
+ }, [forceEdit]) // eslint-disable-line react-hooks/exhaustive-deps
90
+
91
+ const timestamp = new Date(message.createdAt)
92
+ const timeStr = timestamp.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
93
+ const dateTimeStr = timestamp.toLocaleDateString(undefined, {
94
+ month: '2-digit',
95
+ day: '2-digit',
96
+ year: '2-digit',
97
+ }) + ', ' + timeStr
98
+
99
+ const exitEdit = useCallback(() => {
100
+ setIsEditing(false)
101
+ onEditDone?.()
102
+ }, [onEditDone])
103
+
104
+ const handleSaveEdit = useCallback(() => {
105
+ const trimmed = editContent.trim()
106
+ if (trimmed && trimmed !== message.data.content) {
107
+ onEdit(trimmed)
108
+ }
109
+ exitEdit()
110
+ }, [editContent, message.data.content, onEdit, exitEdit])
111
+
112
+ const handleCancelEdit = useCallback(() => {
113
+ setEditContent(message.data.content)
114
+ exitEdit()
115
+ }, [message.data.content, exitEdit])
116
+
117
+ useEffect(() => {
118
+ if (!isEditing) return
119
+ function handleClickOutside(e: MouseEvent) {
120
+ if (editContainerRef.current && !editContainerRef.current.contains(e.target as Node)) {
121
+ setEditContent(message.data.content)
122
+ exitEdit()
123
+ }
124
+ }
125
+ const timer = setTimeout(() => {
126
+ document.addEventListener('mousedown', handleClickOutside)
127
+ }, 0)
128
+ return () => {
129
+ clearTimeout(timer)
130
+ document.removeEventListener('mousedown', handleClickOutside)
131
+ }
132
+ }, [isEditing, message.data.content, exitEdit])
133
+
134
+ const handleEditKeyDown = (e: React.KeyboardEvent) => {
135
+ if (e.key === 'Enter' && !e.shiftKey) {
136
+ e.preventDefault()
137
+ handleSaveEdit()
138
+ }
139
+ if (e.key === 'Escape') {
140
+ handleCancelEdit()
141
+ }
142
+ }
143
+
144
+ const avatar = (
145
+ <div className="relative">
146
+ {author?.imageUrl ? (
147
+ <img src={author.imageUrl} alt="" className="w-10 h-10 rounded-full" />
148
+ ) : (
149
+ <div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-sm font-semibold text-primary">
150
+ {(author?.name ?? '?')[0]?.toUpperCase()}
151
+ </div>
152
+ )}
153
+ <span
154
+ className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border-2 border-background ${
155
+ online ? 'bg-success' : 'bg-muted-foreground/30'
156
+ }`}
157
+ />
158
+ </div>
159
+ )
160
+
161
+ // Soft-deleted messages render as a tombstone — the row stays so threads,
162
+ // read-receipts, and parent links remain coherent, but no content/actions.
163
+ if (message.data.deleted) {
164
+ return (
165
+ <div
166
+ data-testid={`message-${message.recordId}`}
167
+ className={`group relative flex gap-4 px-4 py-0.5 select-none ${
168
+ isFirstInGroup ? 'mt-4' : ''
169
+ }`}
170
+ >
171
+ <div className="shrink-0 w-10" />
172
+ <p
173
+ data-testid="deleted-tombstone"
174
+ className="text-sm italic text-muted-foreground/70"
175
+ >
176
+ This message was deleted.
177
+ </p>
178
+ </div>
179
+ )
180
+ }
181
+
182
+ return (
183
+ <div
184
+ data-testid={`message-${message.recordId}`}
185
+ className={`group relative flex gap-4 px-4 py-0.5 hover:bg-muted/30 transition-colors select-none ${
186
+ isFirstInGroup ? 'mt-4' : ''
187
+ }`}
188
+ {...longPressHandlers}
189
+ >
190
+ {/* Avatar column */}
191
+ <div className="shrink-0 w-10">
192
+ {isFirstInGroup ? (
193
+ <UserProfilePopover userId={authorId} onStartDM={onStartDM}>
194
+ {avatar}
195
+ </UserProfilePopover>
196
+ ) : (
197
+ <span className="w-full pt-1 flex justify-center text-[10px] text-muted-foreground/0 group-hover:text-muted-foreground/70 transition-colors select-none">
198
+ {timeStr}
199
+ </span>
200
+ )}
201
+ </div>
202
+
203
+ {/* Content column */}
204
+ <div
205
+ ref={contentRef}
206
+ className={`flex-1 min-w-0 ${isHighlighted ? 'invisible' : ''}`}
207
+ >
208
+ {isFirstInGroup && (
209
+ <div className="flex items-baseline gap-2 mb-0.5">
210
+ <UserProfilePopover userId={authorId} onStartDM={onStartDM}>
211
+ <span className="text-sm font-semibold text-foreground hover:underline cursor-pointer">
212
+ {author?.name ?? 'Unknown'}
213
+ </span>
214
+ </UserProfilePopover>
215
+ <span className="text-xs text-muted-foreground">{dateTimeStr}</span>
216
+ </div>
217
+ )}
218
+
219
+ {isEditing ? (
220
+ <div ref={editContainerRef} className="mt-1" data-testid="edit-container">
221
+ <div className="flex items-end gap-1.5">
222
+ <textarea
223
+ data-testid="edit-message-input"
224
+ value={editContent}
225
+ onChange={(e) => setEditContent(e.target.value)}
226
+ onKeyDown={handleEditKeyDown}
227
+ className="flex-1 bg-background border border-border rounded-lg px-3 py-2 text-sm text-foreground resize-none outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/20 min-h-[44px]"
228
+ rows={Math.max(2, editContent.split('\n').length)}
229
+ autoFocus
230
+ />
231
+ <div className="flex flex-col gap-1 pb-0.5">
232
+ <button
233
+ data-testid="save-edit-btn"
234
+ onClick={handleSaveEdit}
235
+ className="p-1.5 rounded-md bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
236
+ title="Save"
237
+ >
238
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
239
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
240
+ </svg>
241
+ </button>
242
+ <button
243
+ data-testid="cancel-edit-btn"
244
+ onClick={handleCancelEdit}
245
+ className="p-1.5 rounded-md bg-muted text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
246
+ title="Cancel"
247
+ >
248
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
249
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
250
+ </svg>
251
+ </button>
252
+ </div>
253
+ </div>
254
+ </div>
255
+ ) : (
256
+ <p className="text-sm text-foreground/90 whitespace-pre-wrap break-words leading-relaxed" data-testid="message-content">
257
+ {message.data.content}
258
+ {message.data.edited ? (
259
+ <span className="text-[10px] ml-1.5 text-muted-foreground italic">(edited)</span>
260
+ ) : null}
261
+ </p>
262
+ )}
263
+
264
+ {/* Reactions */}
265
+ {reactions.length > 0 && (
266
+ <div className="flex flex-wrap gap-1 mt-1" data-testid="message-reactions">
267
+ {reactions.map((r) => (
268
+ <button
269
+ key={r.emoji}
270
+ data-testid={`reaction-${message.recordId}-${r.emoji}`}
271
+ onClick={() => onToggleReaction(r.emoji)}
272
+ className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs border transition-colors ${
273
+ r.currentUserReacted
274
+ ? 'bg-primary/15 border-primary/30 text-primary'
275
+ : 'bg-accent/60 border-border/40 text-muted-foreground hover:border-primary/25'
276
+ }`}
277
+ >
278
+ <span>{r.emoji}</span>
279
+ <span className="font-medium">{r.count}</span>
280
+ </button>
281
+ ))}
282
+ </div>
283
+ )}
284
+
285
+ {/* Thread reply count */}
286
+ {threadCount > 0 ? (
287
+ <button
288
+ data-testid={`thread-count-${message.recordId}`}
289
+ onClick={onOpenThread}
290
+ className="mt-1 text-xs text-primary hover:underline"
291
+ >
292
+ {threadCount} {threadCount === 1 ? 'reply' : 'replies'}
293
+ </button>
294
+ ) : null}
295
+ </div>
296
+
297
+ {/* Desktop hover toolbar */}
298
+ {!isEditing && (
299
+ <div data-testid={`hover-toolbar-${message.recordId}`} className="absolute -top-3 right-5 flex items-center gap-0.5 bg-card border border-border/60 rounded-lg shadow-sm p-0.5 transition-all duration-100 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto">
300
+ {QUICK_EMOJIS.map((emoji) => (
301
+ <button
302
+ key={emoji}
303
+ data-testid={`toolbar-emoji-${emoji}`}
304
+ onClick={() => onToggleReaction(emoji)}
305
+ className="shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-sm hover:bg-muted transition-colors"
306
+ >
307
+ {emoji}
308
+ </button>
309
+ ))}
310
+ <div className="shrink-0 w-px h-4 bg-border/40 mx-0.5" />
311
+ <button
312
+ data-testid={`reply-btn-${message.recordId}`}
313
+ onClick={onOpenThread}
314
+ className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
315
+ title="Reply in thread"
316
+ >
317
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
318
+ <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
319
+ </svg>
320
+ </button>
321
+ {isOwn && (
322
+ <>
323
+ <button
324
+ data-testid={`edit-btn-${message.recordId}`}
325
+ onClick={() => {
326
+ setIsEditing(true)
327
+ setEditContent(message.data.content)
328
+ }}
329
+ className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
330
+ title="Edit message"
331
+ >
332
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
333
+ <path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
334
+ </svg>
335
+ </button>
336
+ <button
337
+ data-testid={`delete-btn-${message.recordId}`}
338
+ onClick={onDelete}
339
+ className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
340
+ title="Delete message"
341
+ >
342
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
343
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
344
+ </svg>
345
+ </button>
346
+ </>
347
+ )}
348
+ </div>
349
+ )}
350
+ </div>
351
+ )
352
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * MessageList -- Self-contained chat panel (feed + composer + thread panel).
3
+ * Do not render a separate MessageInput alongside this component.
4
+ */
5
+
6
+ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
7
+ import { useMessages } from 'deepspace'
8
+ import { useReactions } from 'deepspace'
9
+ import { useUser } from 'deepspace'
10
+ import type { Message } from 'deepspace'
11
+ import type { RecordData } from 'deepspace'
12
+ import { MessageItem } from './MessageItem'
13
+ import type { MessageRect } from './MessageItem'
14
+ import { MessageInput } from './MessageInput'
15
+ import { MessageActionSheet } from './MessageActionSheet'
16
+ import { ThreadPanel } from './ThreadPanel'
17
+
18
+ interface MessageListProps {
19
+ channelId: string
20
+ onStartDM?: (userId: string) => void
21
+ }
22
+
23
+ interface ActionSheetState {
24
+ messageId: string
25
+ content: string
26
+ isOwn: boolean
27
+ rect: MessageRect
28
+ }
29
+
30
+ const GROUP_THRESHOLD_MS = 5 * 60 * 1000
31
+ const TIME_SEPARATOR_GAP_MS = 60 * 60 * 1000
32
+
33
+ function getAuthorId(msg: RecordData<Message>): string {
34
+ return msg.data.authorId || msg.createdBy
35
+ }
36
+
37
+ function isFirstInGroup(msgs: RecordData<Message>[], i: number): boolean {
38
+ if (i === 0) return true
39
+ const prev = msgs[i - 1]
40
+ const curr = msgs[i]
41
+ if (getAuthorId(prev) !== getAuthorId(curr)) return true
42
+ return new Date(curr.createdAt).getTime() - new Date(prev.createdAt).getTime() >= GROUP_THRESHOLD_MS
43
+ }
44
+
45
+ function formatTime(date: Date): string {
46
+ return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
47
+ }
48
+
49
+ function getSeparatorLabel(date: Date): string {
50
+ const today = new Date()
51
+ const yesterday = new Date(today)
52
+ yesterday.setDate(yesterday.getDate() - 1)
53
+
54
+ if (date.toDateString() === today.toDateString()) return `Today ${formatTime(date)}`
55
+ if (date.toDateString() === yesterday.toDateString()) return `Yesterday ${formatTime(date)}`
56
+ return date.toLocaleDateString(undefined, {
57
+ weekday: 'long',
58
+ month: 'long',
59
+ day: 'numeric',
60
+ }) + ` ${formatTime(date)}`
61
+ }
62
+
63
+ function shouldShowSeparator(current: RecordData<Message>, prev: RecordData<Message> | undefined): boolean {
64
+ if (!prev) return true
65
+ const curDate = new Date(current.createdAt)
66
+ const prevDate = new Date(prev.createdAt)
67
+ if (curDate.toDateString() !== prevDate.toDateString()) return true
68
+ return curDate.getTime() - prevDate.getTime() >= TIME_SEPARATOR_GAP_MS
69
+ }
70
+
71
+ export function MessageList({ channelId, onStartDM }: MessageListProps) {
72
+ const { messages, status, send, edit, softDelete } = useMessages(channelId)
73
+ const { getReactionsForMessage, toggle: toggleReaction } = useReactions(channelId)
74
+ const { user: currentUser } = useUser()
75
+ const bottomRef = useRef<HTMLDivElement>(null)
76
+ const feedRef = useRef<HTMLDivElement>(null)
77
+ const isInitialMount = useRef(true)
78
+ const prevMessageCount = useRef(0)
79
+ const [actionSheet, setActionSheet] = useState<ActionSheetState | null>(null)
80
+ const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
81
+ const [threadMessageId, setThreadMessageId] = useState<string | null>(null)
82
+
83
+ const topLevelMessages = useMemo(
84
+ () =>
85
+ messages
86
+ .filter((m: RecordData<Message>) => !m.data.parentMessageId)
87
+ .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()),
88
+ [messages]
89
+ )
90
+
91
+ const threadCounts = useMemo(() => {
92
+ const counts = new Map<string, number>()
93
+ for (const m of messages) {
94
+ if (m.data.parentMessageId) {
95
+ counts.set(
96
+ m.data.parentMessageId,
97
+ (counts.get(m.data.parentMessageId) ?? 0) + 1
98
+ )
99
+ }
100
+ }
101
+ return counts
102
+ }, [messages])
103
+
104
+ useEffect(() => {
105
+ const count = topLevelMessages.length
106
+ const wasNewMessage = count > prevMessageCount.current
107
+ prevMessageCount.current = count
108
+
109
+ if (count === 0) return
110
+
111
+ if (isInitialMount.current) {
112
+ isInitialMount.current = false
113
+ bottomRef.current?.scrollIntoView({ behavior: 'auto' })
114
+ return
115
+ }
116
+
117
+ if (!wasNewMessage) return
118
+
119
+ const feed = feedRef.current
120
+ if (!feed) return
121
+ const distanceFromBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight
122
+ const NEAR_BOTTOM_THRESHOLD = 150
123
+ if (distanceFromBottom <= NEAR_BOTTOM_THRESHOLD) {
124
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
125
+ }
126
+ }, [topLevelMessages.length])
127
+
128
+ const handleLongPress = useCallback(
129
+ (msg: RecordData<Message>, rect: MessageRect) => {
130
+ const authorId = msg.data.authorId || msg.createdBy
131
+ const isOwn = currentUser?.id === authorId || currentUser?.id === msg.createdBy
132
+ setActionSheet({
133
+ messageId: msg.recordId,
134
+ content: msg.data.content,
135
+ isOwn,
136
+ rect,
137
+ })
138
+ },
139
+ [currentUser]
140
+ )
141
+
142
+ if (status === 'loading') {
143
+ return (
144
+ <div className="flex-1 flex items-center justify-center">
145
+ <div className="text-muted-foreground text-sm">Loading messages...</div>
146
+ </div>
147
+ )
148
+ }
149
+
150
+ if (status === 'error') {
151
+ return (
152
+ <div className="flex-1 flex items-center justify-center">
153
+ <div className="text-center text-destructive">
154
+ <p className="text-sm font-medium">Failed to load messages</p>
155
+ <p className="text-xs text-muted-foreground mt-1">Check your connection and try again</p>
156
+ </div>
157
+ </div>
158
+ )
159
+ }
160
+
161
+ const threadOpen = !!threadMessageId
162
+
163
+ return (
164
+ <div className="flex flex-1 min-h-0">
165
+ <div className={`flex flex-col flex-1 min-w-0 ${threadOpen ? 'hidden md:flex' : 'flex'}`}>
166
+ <div ref={feedRef} className="flex-1 overflow-y-auto overflow-x-hidden flex flex-col" data-testid="messages-feed">
167
+ {topLevelMessages.length === 0 ? (
168
+ <div className="flex items-center justify-center flex-1">
169
+ <div className="text-center text-muted-foreground">
170
+ <p className="text-lg mb-1">No messages yet</p>
171
+ <p className="text-sm">Be the first to send a message!</p>
172
+ </div>
173
+ </div>
174
+ ) : (
175
+ <div className="mt-auto pt-6 pb-4">
176
+ {topLevelMessages.map((msg: RecordData<Message>, i: number) => {
177
+ const prev = i > 0 ? topLevelMessages[i - 1] : undefined
178
+ const showSeparator = shouldShowSeparator(msg, prev)
179
+ const firstInGroup = showSeparator || isFirstInGroup(topLevelMessages, i)
180
+
181
+ return (
182
+ <div key={msg.recordId}>
183
+ {showSeparator && (
184
+ <div className="relative flex items-center justify-center my-2 mx-4" data-testid="date-separator">
185
+ <div className="absolute inset-0 flex items-center">
186
+ <div className="w-full border-t border-border" />
187
+ </div>
188
+ <span className="relative text-[11px] font-medium text-muted-foreground bg-background px-2">
189
+ {getSeparatorLabel(new Date(msg.createdAt))}
190
+ </span>
191
+ </div>
192
+ )}
193
+ <MessageItem
194
+ message={msg}
195
+ reactions={getReactionsForMessage(msg.recordId)}
196
+ threadCount={threadCounts.get(msg.recordId) ?? 0}
197
+ onToggleReaction={(emoji) => toggleReaction(msg.recordId, emoji)}
198
+ onOpenThread={() => setThreadMessageId(msg.recordId)}
199
+ onEdit={(newContent) => edit(msg.recordId, newContent)}
200
+ onDelete={() => softDelete(msg.recordId)}
201
+ isFirstInGroup={firstInGroup}
202
+ onLongPress={(rect) => handleLongPress(msg, rect)}
203
+ forceEdit={editingMessageId === msg.recordId}
204
+ onEditDone={() => setEditingMessageId(null)}
205
+ isHighlighted={actionSheet?.messageId === msg.recordId}
206
+ onStartDM={onStartDM}
207
+ />
208
+ </div>
209
+ )
210
+ })}
211
+ <div ref={bottomRef} />
212
+ </div>
213
+ )}
214
+ </div>
215
+
216
+ <MessageInput onSend={(content) => send(content)} placeholder="Type a message..." />
217
+ </div>
218
+
219
+ {threadMessageId && (
220
+ <ThreadPanel
221
+ channelId={channelId}
222
+ parentMessageId={threadMessageId}
223
+ onClose={() => setThreadMessageId(null)}
224
+ />
225
+ )}
226
+
227
+ <MessageActionSheet
228
+ visible={!!actionSheet}
229
+ onClose={() => setActionSheet(null)}
230
+ isOwn={actionSheet?.isOwn ?? false}
231
+ onReaction={(emoji) => {
232
+ if (actionSheet) toggleReaction(actionSheet.messageId, emoji)
233
+ }}
234
+ onEdit={() => {
235
+ if (actionSheet) setEditingMessageId(actionSheet.messageId)
236
+ }}
237
+ onDelete={() => {
238
+ if (actionSheet) remove(actionSheet.messageId)
239
+ }}
240
+ onReply={() => {
241
+ if (actionSheet) setThreadMessageId(actionSheet.messageId)
242
+ }}
243
+ messageContent={actionSheet?.content ?? ''}
244
+ messageRect={actionSheet?.rect ?? null}
245
+ />
246
+ </div>
247
+ )
248
+ }
@@ -0,0 +1,2 @@
1
+ /** Shared emoji list used by the hover toolbar and mobile action sheet. */
2
+ export const EMOJI_LIST = ['\u{1F44D}', '\u{1F44E}', '\u{1F604}', '\u{1F389}', '\u2764\uFE0F', '\u{1F680}', '\u{1F440}', '\u{1F525}', '\u{1F4AF}', '\u{1F914}', '\u{1F622}', '\u{1F64F}']