deepspace 0.3.10 → 0.4.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.
- package/dist/cli.js +331 -115
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +199 -16
- package/dist/index.js +354 -166
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +242 -73
- package/dist/server.js +985 -374
- package/dist/server.js.map +1 -1
- package/dist/worker.d.ts +322 -73
- package/dist/worker.js +961 -343
- package/dist/worker.js.map +1 -1
- package/features/admin-page/feature.json +29 -0
- package/features/admin-page/src/AdminPage.tsx +292 -0
- package/features/admin-page/src/admin-schema.ts +20 -0
- package/features/ai-chat/feature.json +41 -0
- package/features/ai-chat/src/AiChatPage.tsx +692 -0
- package/features/ai-chat/src/ChatPanel.tsx +1098 -0
- package/features/ai-chat/src/ai-chat-schema.ts +11 -0
- package/features/canvas/feature.json +45 -0
- package/features/canvas/src/CanvasEditorPage.tsx +87 -0
- package/features/canvas/src/CanvasListPage.tsx +202 -0
- package/features/canvas/src/canvas-schema.ts +33 -0
- package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
- package/features/canvas/src/components/CanvasView.tsx +369 -0
- package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
- package/features/cron/feature.json +33 -0
- package/features/cron/src/CronLogPage.tsx +100 -0
- package/features/cron/src/cron.ts +25 -0
- package/features/docs/feature.json +96 -0
- package/features/docs/src/DocsEditorPage.tsx +1012 -0
- package/features/docs/src/DocsListPage.tsx +951 -0
- package/features/docs/src/DocsOutlinePanel.tsx +68 -0
- package/features/docs/src/DocsPresence.tsx +138 -0
- package/features/docs/src/InviteDialog.tsx +336 -0
- package/features/docs/src/LibrarySidebar.tsx +464 -0
- package/features/docs/src/docs-favorites.ts +18 -0
- package/features/docs/src/docs-library-types.ts +15 -0
- package/features/docs/src/docs-schema.ts +80 -0
- package/features/docs/src/docs-ui.css +309 -0
- package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
- package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
- package/features/docs/src/editor/useDocEditor.ts +206 -0
- package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
- package/features/file-attachments/feature.json +42 -0
- package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
- package/features/file-attachments/src/attachments-constants.ts +9 -0
- package/features/file-attachments/src/attachments-schema.ts +45 -0
- package/features/file-attachments/src/useFilePreview.ts +471 -0
- package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
- package/features/file-manager/feature.json +24 -0
- package/features/file-manager/src/FileManagerPage.tsx +330 -0
- package/features/integration-test/feature.json +18 -0
- package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
- package/features/integration-test/src/integration.spec.ts +42 -0
- package/features/items/feature.json +33 -0
- package/features/items/src/ItemsPage.tsx +325 -0
- package/features/items/src/items-constants.ts +10 -0
- package/features/items/src/items-schema.ts +27 -0
- package/features/kanban/feature.json +18 -0
- package/features/kanban/src/Kanban.tsx +283 -0
- package/features/landing/feature.json +62 -0
- package/features/landing/src/LandingPage.tsx +627 -0
- package/features/landing/src/primitives.tsx +389 -0
- package/features/landing/src/sections/FAQSection.tsx +110 -0
- package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
- package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
- package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
- package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
- package/features/landing/src/sections/StatsSection.tsx +31 -0
- package/features/landing/src/sections/TeamSection.tsx +86 -0
- package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
- package/features/landing/src/sections/VideoSection.tsx +71 -0
- package/features/leaderboard/feature.json +33 -0
- package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
- package/features/leaderboard/src/leaderboard-constants.ts +21 -0
- package/features/leaderboard/src/leaderboard-schema.ts +36 -0
- package/features/messaging/feature.json +111 -0
- package/features/messaging/src/ChatMultiPage.tsx +300 -0
- package/features/messaging/src/ChatPage.tsx +75 -0
- package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
- package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
- package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
- package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
- package/features/messaging/src/components/chat/MessageList.tsx +248 -0
- package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
- package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
- package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
- package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
- package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
- package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
- package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
- package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
- package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
- package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
- package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
- package/features/messaging/src/hooks/useChatChannel.ts +64 -0
- package/features/messaging/src/hooks/useLongPress.ts +74 -0
- package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
- package/features/messaging/src/messaging-schema.ts +25 -0
- package/features/presence-test/feature.json +21 -0
- package/features/presence-test/src/PresenceTestPage.tsx +120 -0
- package/features/sidebar/feature.json +19 -0
- package/features/sidebar/src/AppSidebar.tsx +558 -0
- package/features/tasks/feature.json +33 -0
- package/features/tasks/src/TasksPage.tsx +598 -0
- package/features/tasks/src/tasks-constants.ts +43 -0
- package/features/tasks/src/tasks-schema.ts +56 -0
- package/features/testing/feature.json +49 -0
- package/features/testing/src/TestPage.tsx +145 -0
- package/features/testing/src/cpu-burn-action.ts +51 -0
- package/features/testing/src/subrequest-burn-action.ts +53 -0
- package/features/testing/src/test-schema.ts +23 -0
- package/features/topbar/feature.json +18 -0
- package/features/topbar/src/Topbar.tsx +353 -0
- package/features/tree/feature.json +26 -0
- package/features/tree/src/TreeSidebar.tsx +607 -0
- package/features/tree/src/tree-constants.ts +68 -0
- package/features/tree/src/tree-hooks.ts +258 -0
- package/package.json +4 -2
- 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
|
+
}
|