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,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatMultiPage -- Full-featured messaging page with sidebar navigation.
|
|
3
|
+
*
|
|
4
|
+
* Layout:
|
|
5
|
+
* Desktop: [Sidebar 280px] | [Chat Area flex-1] | [Settings/Thread Panel]
|
|
6
|
+
* Mobile: Sidebar (full) or Chat (full) -- only one visible at a time.
|
|
7
|
+
*
|
|
8
|
+
* Reuses MessageList from the same messaging feature.
|
|
9
|
+
* Parent container must have a definite height (h-full / flex-1).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useState, useCallback, useEffect, useRef, type ReactNode } from 'react'
|
|
13
|
+
import { useMultiChannel } from '../components/messaging/hooks/useMultiChannel'
|
|
14
|
+
import { useUser } from 'deepspace'
|
|
15
|
+
import { useChannelMembers } from 'deepspace'
|
|
16
|
+
import { useChannels } from 'deepspace'
|
|
17
|
+
import { useReadReceipts } from 'deepspace'
|
|
18
|
+
import { useMutations } from 'deepspace'
|
|
19
|
+
import { RecordScope } from 'deepspace'
|
|
20
|
+
import type { Channel, ChannelMember } from 'deepspace'
|
|
21
|
+
import type { RecordData } from 'deepspace'
|
|
22
|
+
import type { CollectionSchema } from 'deepspace/worker'
|
|
23
|
+
import { messagingSchemas } from '../schemas/messaging-schema'
|
|
24
|
+
import { ChannelSidebar } from '../components/messaging/chat-multi/ChannelSidebar'
|
|
25
|
+
import { MessageList } from '../components/messaging/chat/MessageList'
|
|
26
|
+
import { ChannelSettingsPanel } from '../components/messaging/chat-multi/ChannelSettingsPanel'
|
|
27
|
+
|
|
28
|
+
interface ChatMultiPageProps {
|
|
29
|
+
schemas?: CollectionSchema[]
|
|
30
|
+
appId?: string
|
|
31
|
+
className?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default function ChatMultiPage({
|
|
35
|
+
schemas = messagingSchemas,
|
|
36
|
+
appId = '',
|
|
37
|
+
className,
|
|
38
|
+
}: ChatMultiPageProps) {
|
|
39
|
+
const {
|
|
40
|
+
activeChannelId,
|
|
41
|
+
selectChannel,
|
|
42
|
+
channels,
|
|
43
|
+
groups,
|
|
44
|
+
dms,
|
|
45
|
+
pendingInvitations,
|
|
46
|
+
status,
|
|
47
|
+
hasUnread,
|
|
48
|
+
getDMDisplayName,
|
|
49
|
+
} = useMultiChannel()
|
|
50
|
+
|
|
51
|
+
const { user } = useUser()
|
|
52
|
+
const { channels: allChannels, create: createChannel } = useChannels()
|
|
53
|
+
const { create: createMembership } = useMutations<ChannelMember>('channel-members')
|
|
54
|
+
const { isMember, join, status: memberStatus } = useChannelMembers(activeChannelId ?? undefined)
|
|
55
|
+
const { markAsRead } = useReadReceipts()
|
|
56
|
+
|
|
57
|
+
const [showSettings, setShowSettings] = useState(false)
|
|
58
|
+
const [mobileView, setMobileView] = useState<'sidebar' | 'chat'>('sidebar')
|
|
59
|
+
const lastMarkedRef = useRef<string | null>(null)
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!activeChannelId || !isMember) return
|
|
63
|
+
if (lastMarkedRef.current === activeChannelId) return
|
|
64
|
+
lastMarkedRef.current = activeChannelId
|
|
65
|
+
markAsRead(activeChannelId)
|
|
66
|
+
}, [activeChannelId, isMember, markAsRead])
|
|
67
|
+
|
|
68
|
+
const handleSelectChannel = useCallback(
|
|
69
|
+
(channelId: string) => {
|
|
70
|
+
selectChannel(channelId)
|
|
71
|
+
setShowSettings(false)
|
|
72
|
+
setMobileView('chat')
|
|
73
|
+
},
|
|
74
|
+
[selectChannel]
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const handleBack = useCallback(() => {
|
|
78
|
+
setMobileView('sidebar')
|
|
79
|
+
setShowSettings(false)
|
|
80
|
+
}, [])
|
|
81
|
+
|
|
82
|
+
const handleLeave = useCallback(() => {
|
|
83
|
+
setShowSettings(false)
|
|
84
|
+
setMobileView('sidebar')
|
|
85
|
+
}, [])
|
|
86
|
+
|
|
87
|
+
const handleStartDM = useCallback(
|
|
88
|
+
async (targetUserId: string) => {
|
|
89
|
+
if (!user) return
|
|
90
|
+
const ids = [user.id, targetUserId].sort()
|
|
91
|
+
const dmChannelName = `dm-${ids[0]}-${ids[1]}`
|
|
92
|
+
|
|
93
|
+
const existingDM = allChannels.find(
|
|
94
|
+
(c: RecordData<Channel>) => c.data.type === 'dm' && c.data.name === dmChannelName
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if (existingDM) {
|
|
98
|
+
handleSelectChannel(existingDM.recordId)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const channelId = await createChannel({
|
|
103
|
+
name: dmChannelName,
|
|
104
|
+
type: 'dm',
|
|
105
|
+
description: '',
|
|
106
|
+
})
|
|
107
|
+
if (channelId && user) {
|
|
108
|
+
await createMembership({
|
|
109
|
+
channelId,
|
|
110
|
+
userId: user.id,
|
|
111
|
+
joinedAt: new Date().toISOString(),
|
|
112
|
+
} as unknown as ChannelMember)
|
|
113
|
+
handleSelectChannel(channelId)
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
[user, allChannels, createChannel, createMembership, handleSelectChannel]
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const activeChannelData = channels.find(
|
|
120
|
+
(c: RecordData<Channel>) => c.recordId === activeChannelId
|
|
121
|
+
)
|
|
122
|
+
const isDM = activeChannelData?.data.type === 'dm'
|
|
123
|
+
const isPrivate = activeChannelData?.data.type === 'private'
|
|
124
|
+
|
|
125
|
+
if (status !== 'ready') {
|
|
126
|
+
return (
|
|
127
|
+
<div className={`flex items-center justify-center h-full ${className ?? ''}`}>
|
|
128
|
+
<div className="text-muted-foreground text-sm">Loading...</div>
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div className={`flex h-full ${className ?? ''}`} data-testid="chat-multi-page">
|
|
135
|
+
{/* Sidebar -- visible on desktop always; on mobile only when mobileView='sidebar' */}
|
|
136
|
+
<div
|
|
137
|
+
className={`w-full md:w-72 lg:w-80 shrink-0 border-r border-border/40 ${
|
|
138
|
+
mobileView === 'sidebar' ? 'flex' : 'hidden md:flex'
|
|
139
|
+
}`}
|
|
140
|
+
>
|
|
141
|
+
<div className="w-full">
|
|
142
|
+
<ChannelSidebar
|
|
143
|
+
groups={groups}
|
|
144
|
+
dms={dms}
|
|
145
|
+
pendingInvitations={pendingInvitations}
|
|
146
|
+
activeChannelId={activeChannelId}
|
|
147
|
+
onSelectChannel={handleSelectChannel}
|
|
148
|
+
hasUnread={hasUnread}
|
|
149
|
+
getDMDisplayName={getDMDisplayName}
|
|
150
|
+
/>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{/* Chat area -- on mobile only when mobileView='chat' */}
|
|
155
|
+
<div
|
|
156
|
+
className={`flex-1 flex flex-col min-w-0 ${
|
|
157
|
+
mobileView === 'chat' ? 'flex' : 'hidden md:flex'
|
|
158
|
+
}`}
|
|
159
|
+
>
|
|
160
|
+
{activeChannelId ? (
|
|
161
|
+
<RecordScope key={activeChannelId} roomId={`chat:${activeChannelId}`} schemas={schemas} appId={appId}>
|
|
162
|
+
{/* Multi-channel header with back button on mobile */}
|
|
163
|
+
<div
|
|
164
|
+
data-testid="multi-chat-header"
|
|
165
|
+
className="shrink-0 px-4 py-3 border-b border-border bg-card/80 backdrop-blur-sm flex items-center gap-3"
|
|
166
|
+
>
|
|
167
|
+
{/* Mobile back button */}
|
|
168
|
+
<button
|
|
169
|
+
data-testid="back-to-sidebar"
|
|
170
|
+
onClick={handleBack}
|
|
171
|
+
className="md:hidden p-1 -ml-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
172
|
+
>
|
|
173
|
+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
174
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
|
175
|
+
</svg>
|
|
176
|
+
</button>
|
|
177
|
+
|
|
178
|
+
<div className="flex-1 min-w-0">
|
|
179
|
+
<h1 className="text-base font-semibold text-foreground truncate">
|
|
180
|
+
{isDM && activeChannelData
|
|
181
|
+
? getDMDisplayName(activeChannelData)
|
|
182
|
+
: activeChannelData?.data.name ?? 'Chat'}
|
|
183
|
+
</h1>
|
|
184
|
+
{activeChannelData?.data.description && !isDM && (
|
|
185
|
+
<p className="text-xs text-muted-foreground truncate">
|
|
186
|
+
{activeChannelData.data.description}
|
|
187
|
+
</p>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
{/* Settings button for groups (not DMs) */}
|
|
192
|
+
{!isDM && isMember && (
|
|
193
|
+
<button
|
|
194
|
+
data-testid="toggle-settings-btn"
|
|
195
|
+
onClick={() => setShowSettings((prev) => !prev)}
|
|
196
|
+
className={`p-1.5 rounded-md transition-colors ${
|
|
197
|
+
showSettings
|
|
198
|
+
? 'text-primary bg-primary/10'
|
|
199
|
+
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
200
|
+
}`}
|
|
201
|
+
title="Group settings"
|
|
202
|
+
>
|
|
203
|
+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
204
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
205
|
+
</svg>
|
|
206
|
+
</button>
|
|
207
|
+
)}
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
{/* Channel content */}
|
|
211
|
+
{memberStatus === 'ready' && !isMember ? (
|
|
212
|
+
isDM ? (
|
|
213
|
+
<DmAutoJoin join={join} channelId={activeChannelId}>
|
|
214
|
+
<div className="flex flex-1 min-h-0">
|
|
215
|
+
<div className={`flex flex-col flex-1 min-w-0 ${showSettings ? 'hidden md:flex' : 'flex'}`}>
|
|
216
|
+
<MessageList channelId={activeChannelId} onStartDM={handleStartDM} />
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
</DmAutoJoin>
|
|
220
|
+
) : (
|
|
221
|
+
<div className="flex-1 flex items-center justify-center">
|
|
222
|
+
<div className="text-center">
|
|
223
|
+
{isPrivate ? (
|
|
224
|
+
<>
|
|
225
|
+
<p className="text-foreground mb-1">This is a private group</p>
|
|
226
|
+
<p className="text-sm text-muted-foreground">
|
|
227
|
+
You need an invitation to join.
|
|
228
|
+
</p>
|
|
229
|
+
</>
|
|
230
|
+
) : (
|
|
231
|
+
<>
|
|
232
|
+
<p className="text-foreground mb-3">You're not a member of this group</p>
|
|
233
|
+
<button
|
|
234
|
+
data-testid="join-channel-btn"
|
|
235
|
+
onClick={join}
|
|
236
|
+
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
|
237
|
+
>
|
|
238
|
+
Join Group
|
|
239
|
+
</button>
|
|
240
|
+
</>
|
|
241
|
+
)}
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
)
|
|
245
|
+
) : (
|
|
246
|
+
<div className="flex flex-1 min-h-0">
|
|
247
|
+
<div className={`flex flex-col flex-1 min-w-0 ${showSettings ? 'hidden md:flex' : 'flex'}`}>
|
|
248
|
+
<MessageList channelId={activeChannelId} onStartDM={handleStartDM} />
|
|
249
|
+
</div>
|
|
250
|
+
{showSettings && activeChannelId && (
|
|
251
|
+
<ChannelSettingsPanel
|
|
252
|
+
channelId={activeChannelId}
|
|
253
|
+
onClose={() => setShowSettings(false)}
|
|
254
|
+
onLeave={handleLeave}
|
|
255
|
+
/>
|
|
256
|
+
)}
|
|
257
|
+
</div>
|
|
258
|
+
)}
|
|
259
|
+
</RecordScope>
|
|
260
|
+
) : (
|
|
261
|
+
<div className="flex-1 flex items-center justify-center">
|
|
262
|
+
<div className="text-center text-muted-foreground">
|
|
263
|
+
<div className="w-16 h-16 rounded-2xl bg-muted/60 flex items-center justify-center mx-auto mb-4">
|
|
264
|
+
<svg className="w-8 h-8 text-muted-foreground/50" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
265
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
|
266
|
+
</svg>
|
|
267
|
+
</div>
|
|
268
|
+
<p className="text-base font-medium mb-1">Select a conversation</p>
|
|
269
|
+
<p className="text-sm">Choose a group or message from the sidebar</p>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Auto-joins the current user to a DM channel and renders children immediately.
|
|
280
|
+
* DMs don't require an explicit join gate -- if you can see the DM, you belong in it.
|
|
281
|
+
*/
|
|
282
|
+
function DmAutoJoin({
|
|
283
|
+
join,
|
|
284
|
+
channelId,
|
|
285
|
+
children,
|
|
286
|
+
}: {
|
|
287
|
+
join: () => void
|
|
288
|
+
channelId: string
|
|
289
|
+
children: ReactNode
|
|
290
|
+
}) {
|
|
291
|
+
const joined = useRef<string | null>(null)
|
|
292
|
+
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
if (joined.current === channelId) return
|
|
295
|
+
joined.current = channelId
|
|
296
|
+
join()
|
|
297
|
+
}, [channelId, join])
|
|
298
|
+
|
|
299
|
+
return <>{children}</>
|
|
300
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatPage -- Routable messaging page with header and MessageList.
|
|
3
|
+
* Parent container must have a definite height (h-full / flex-1).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useEffect, useRef } from 'react'
|
|
7
|
+
import { useChatChannel } from '../components/messaging/hooks/useChatChannel'
|
|
8
|
+
import { useReadReceipts } from 'deepspace'
|
|
9
|
+
import { RecordScope } from 'deepspace'
|
|
10
|
+
import type { CollectionSchema } from 'deepspace/worker'
|
|
11
|
+
import { messagingSchemas } from '../schemas/messaging-schema'
|
|
12
|
+
import { ChatHeader } from '../components/messaging/chat/ChatHeader'
|
|
13
|
+
import { MessageList } from '../components/messaging/chat/MessageList'
|
|
14
|
+
|
|
15
|
+
interface ChatPageProps {
|
|
16
|
+
schemas?: CollectionSchema[]
|
|
17
|
+
appId?: string
|
|
18
|
+
channelName?: string
|
|
19
|
+
className?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default function ChatPage({
|
|
23
|
+
schemas = messagingSchemas,
|
|
24
|
+
appId = '',
|
|
25
|
+
channelName = 'general',
|
|
26
|
+
className,
|
|
27
|
+
}: ChatPageProps) {
|
|
28
|
+
const { channelId, status, isMember, join } = useChatChannel(channelName)
|
|
29
|
+
const { markAsRead } = useReadReceipts()
|
|
30
|
+
const lastMarkedRef = useRef<string | null>(null)
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!channelId || !isMember) return
|
|
34
|
+
if (lastMarkedRef.current === channelId) return
|
|
35
|
+
lastMarkedRef.current = channelId
|
|
36
|
+
markAsRead(channelId)
|
|
37
|
+
}, [channelId, isMember, markAsRead])
|
|
38
|
+
|
|
39
|
+
if (status !== 'ready' || !channelId) {
|
|
40
|
+
return (
|
|
41
|
+
<div className={`flex items-center justify-center h-full ${className ?? ''}`}>
|
|
42
|
+
<div className="text-muted-foreground text-sm">Loading chat...</div>
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!isMember) {
|
|
48
|
+
return (
|
|
49
|
+
<div className={`flex flex-col h-full ${className ?? ''}`} data-testid="chat-page">
|
|
50
|
+
<ChatHeader channelId={channelId} />
|
|
51
|
+
<div className="flex-1 flex items-center justify-center">
|
|
52
|
+
<div className="text-center">
|
|
53
|
+
<p className="text-foreground mb-3">You're not a member of this channel</p>
|
|
54
|
+
<button
|
|
55
|
+
data-testid="join-channel-btn"
|
|
56
|
+
onClick={join}
|
|
57
|
+
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
|
|
58
|
+
>
|
|
59
|
+
Join Channel
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className={`flex flex-col h-full ${className ?? ''}`} data-testid="chat-page">
|
|
69
|
+
<ChatHeader channelId={channelId} />
|
|
70
|
+
<RecordScope roomId={`chat:${channelId}`} schemas={schemas} appId={appId}>
|
|
71
|
+
<MessageList channelId={channelId} />
|
|
72
|
+
</RecordScope>
|
|
73
|
+
</div>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatHeader -- Channel header showing name, description, and member count.
|
|
3
|
+
* Generic styling (no Slack-specific prefixes like #).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useChannels } from 'deepspace'
|
|
7
|
+
import { useChannelMembers } from 'deepspace'
|
|
8
|
+
import type { Channel } from 'deepspace'
|
|
9
|
+
import type { RecordData } from 'deepspace'
|
|
10
|
+
|
|
11
|
+
interface ChatHeaderProps {
|
|
12
|
+
channelId: string
|
|
13
|
+
onToggleThread?: () => void
|
|
14
|
+
threadOpen?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ChatHeader({ channelId, onToggleThread, threadOpen }: ChatHeaderProps) {
|
|
18
|
+
const { channels } = useChannels()
|
|
19
|
+
const { members } = useChannelMembers(channelId)
|
|
20
|
+
|
|
21
|
+
const channel = channels.find((c: RecordData<Channel>) => c.recordId === channelId)
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
data-testid="chat-header"
|
|
26
|
+
className="shrink-0 px-4 py-3 border-b border-border bg-card/80 backdrop-blur-sm flex items-center justify-between gap-3"
|
|
27
|
+
>
|
|
28
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
29
|
+
<h1
|
|
30
|
+
className="text-base font-semibold text-foreground truncate"
|
|
31
|
+
data-testid="chat-header-name"
|
|
32
|
+
>
|
|
33
|
+
{channel?.data.name ?? 'Chat'}
|
|
34
|
+
</h1>
|
|
35
|
+
{channel?.data.description && (
|
|
36
|
+
<>
|
|
37
|
+
<div className="w-px h-4 bg-border shrink-0" />
|
|
38
|
+
<span className="text-sm text-muted-foreground truncate">
|
|
39
|
+
{channel.data.description}
|
|
40
|
+
</span>
|
|
41
|
+
</>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
46
|
+
<span
|
|
47
|
+
className="text-xs text-muted-foreground"
|
|
48
|
+
data-testid="chat-header-member-count"
|
|
49
|
+
>
|
|
50
|
+
{members.length} {members.length === 1 ? 'member' : 'members'}
|
|
51
|
+
</span>
|
|
52
|
+
{onToggleThread && (
|
|
53
|
+
<button
|
|
54
|
+
data-testid="toggle-thread-btn"
|
|
55
|
+
onClick={onToggleThread}
|
|
56
|
+
className={`p-1.5 rounded-md transition-colors ${
|
|
57
|
+
threadOpen
|
|
58
|
+
? 'text-primary bg-primary/10'
|
|
59
|
+
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
60
|
+
}`}
|
|
61
|
+
title="Threads"
|
|
62
|
+
>
|
|
63
|
+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
64
|
+
<path
|
|
65
|
+
strokeLinecap="round"
|
|
66
|
+
strokeLinejoin="round"
|
|
67
|
+
strokeWidth={1.5}
|
|
68
|
+
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
|
|
69
|
+
/>
|
|
70
|
+
</svg>
|
|
71
|
+
</button>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MessageActionSheet -- Mobile long-press context menu.
|
|
3
|
+
* Dims the screen, highlights the message in-place, and shows
|
|
4
|
+
* an action bar with edit/delete + emoji reactions.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useEffect, useCallback, useState } from 'react'
|
|
8
|
+
import { EMOJI_LIST } from './ReactionPicker'
|
|
9
|
+
import type { MessageRect } from './MessageItem'
|
|
10
|
+
|
|
11
|
+
interface MessageActionSheetProps {
|
|
12
|
+
visible: boolean
|
|
13
|
+
onClose: () => void
|
|
14
|
+
isOwn: boolean
|
|
15
|
+
onReaction: (emoji: string) => void
|
|
16
|
+
onEdit: () => void
|
|
17
|
+
onDelete: () => void
|
|
18
|
+
onReply: () => void
|
|
19
|
+
messageContent: string
|
|
20
|
+
messageRect: MessageRect | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ACTION_BAR_HEIGHT = 44
|
|
24
|
+
const VIEWPORT_PADDING = 12
|
|
25
|
+
|
|
26
|
+
export function MessageActionSheet({
|
|
27
|
+
visible,
|
|
28
|
+
onClose,
|
|
29
|
+
isOwn,
|
|
30
|
+
onReaction,
|
|
31
|
+
onEdit,
|
|
32
|
+
onDelete,
|
|
33
|
+
onReply,
|
|
34
|
+
messageContent,
|
|
35
|
+
messageRect,
|
|
36
|
+
}: MessageActionSheetProps) {
|
|
37
|
+
const [layout, setLayout] = useState<'above' | 'below'>('above')
|
|
38
|
+
|
|
39
|
+
const handleAction = useCallback(
|
|
40
|
+
(action: () => void) => {
|
|
41
|
+
action()
|
|
42
|
+
onClose()
|
|
43
|
+
},
|
|
44
|
+
[onClose]
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!visible || !messageRect) return
|
|
49
|
+
const spaceAbove = messageRect.top
|
|
50
|
+
const needed = ACTION_BAR_HEIGHT + VIEWPORT_PADDING
|
|
51
|
+
setLayout(spaceAbove >= needed ? 'above' : 'below')
|
|
52
|
+
}, [visible, messageRect])
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!visible) return
|
|
56
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
57
|
+
if (e.key === 'Escape') onClose()
|
|
58
|
+
}
|
|
59
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
60
|
+
document.body.style.overflow = 'hidden'
|
|
61
|
+
return () => {
|
|
62
|
+
document.removeEventListener('keydown', handleKeyDown)
|
|
63
|
+
document.body.style.overflow = ''
|
|
64
|
+
}
|
|
65
|
+
}, [visible, onClose])
|
|
66
|
+
|
|
67
|
+
if (!visible || !messageRect) return null
|
|
68
|
+
|
|
69
|
+
const actionBar = (
|
|
70
|
+
<div
|
|
71
|
+
data-testid="context-action-bar"
|
|
72
|
+
data-context-bar
|
|
73
|
+
className="flex items-center gap-0.5 bg-card border border-border rounded-xl shadow-lg px-1 py-1 overflow-x-auto"
|
|
74
|
+
style={{
|
|
75
|
+
scrollbarWidth: 'none',
|
|
76
|
+
width: messageRect.width,
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
{isOwn && (
|
|
80
|
+
<>
|
|
81
|
+
<button
|
|
82
|
+
data-testid="action-sheet-edit"
|
|
83
|
+
onClick={() => handleAction(onEdit)}
|
|
84
|
+
className="shrink-0 p-1.5 rounded-lg active:bg-muted text-muted-foreground active:text-foreground transition-colors"
|
|
85
|
+
title="Edit"
|
|
86
|
+
>
|
|
87
|
+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
88
|
+
<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" />
|
|
89
|
+
</svg>
|
|
90
|
+
</button>
|
|
91
|
+
<button
|
|
92
|
+
data-testid="action-sheet-delete"
|
|
93
|
+
onClick={() => handleAction(onDelete)}
|
|
94
|
+
className="shrink-0 p-1.5 rounded-lg active:bg-destructive/10 text-muted-foreground active:text-destructive transition-colors"
|
|
95
|
+
title="Delete"
|
|
96
|
+
>
|
|
97
|
+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
98
|
+
<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" />
|
|
99
|
+
</svg>
|
|
100
|
+
</button>
|
|
101
|
+
<div className="shrink-0 w-px h-6 bg-border mx-0.5" />
|
|
102
|
+
</>
|
|
103
|
+
)}
|
|
104
|
+
<button
|
|
105
|
+
data-testid="action-sheet-reply"
|
|
106
|
+
onClick={() => handleAction(onReply)}
|
|
107
|
+
className="shrink-0 p-1.5 rounded-lg active:bg-muted text-muted-foreground active:text-foreground transition-colors"
|
|
108
|
+
title="Reply"
|
|
109
|
+
>
|
|
110
|
+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
111
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
|
112
|
+
</svg>
|
|
113
|
+
</button>
|
|
114
|
+
<div className="shrink-0 w-px h-6 bg-border mx-0.5" />
|
|
115
|
+
{EMOJI_LIST.map((emoji) => (
|
|
116
|
+
<button
|
|
117
|
+
key={emoji}
|
|
118
|
+
data-testid={`context-emoji-${emoji}`}
|
|
119
|
+
onClick={() => handleAction(() => onReaction(emoji))}
|
|
120
|
+
className="shrink-0 w-8 h-8 rounded-lg flex items-center justify-center text-base active:scale-110 active:bg-muted transition-all"
|
|
121
|
+
>
|
|
122
|
+
{emoji}
|
|
123
|
+
</button>
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div
|
|
130
|
+
data-testid="message-action-sheet-backdrop"
|
|
131
|
+
className="fixed inset-0 z-[9999]"
|
|
132
|
+
onClick={onClose}
|
|
133
|
+
>
|
|
134
|
+
<div className="absolute inset-0 bg-black/40 animate-in fade-in duration-150" />
|
|
135
|
+
|
|
136
|
+
<div
|
|
137
|
+
className="absolute animate-in fade-in zoom-in-95 duration-150"
|
|
138
|
+
style={{
|
|
139
|
+
top: messageRect.top,
|
|
140
|
+
left: messageRect.left,
|
|
141
|
+
width: messageRect.width,
|
|
142
|
+
}}
|
|
143
|
+
onClick={(e) => e.stopPropagation()}
|
|
144
|
+
>
|
|
145
|
+
{layout === 'above' && (
|
|
146
|
+
<div className="absolute bottom-full mb-2 left-0 w-full">
|
|
147
|
+
{actionBar}
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
<div
|
|
152
|
+
data-testid="highlighted-message"
|
|
153
|
+
className="bg-card rounded-lg shadow-xl border border-border px-4 py-2.5"
|
|
154
|
+
>
|
|
155
|
+
<p className="text-sm text-foreground/90 whitespace-pre-wrap break-words leading-relaxed">
|
|
156
|
+
{messageContent}
|
|
157
|
+
</p>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{layout === 'below' && (
|
|
161
|
+
<div className="mt-2 w-full">
|
|
162
|
+
{actionBar}
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** MessageInput -- Auto-resizing chat composer. Enter sends, Shift+Enter newlines. */
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useCallback } from 'react'
|
|
4
|
+
|
|
5
|
+
interface MessageInputProps {
|
|
6
|
+
onSend: (content: string) => void
|
|
7
|
+
placeholder?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function MessageInput({ onSend, placeholder = 'Type a message...' }: MessageInputProps) {
|
|
11
|
+
const [value, setValue] = useState('')
|
|
12
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
13
|
+
|
|
14
|
+
const handleSend = useCallback(() => {
|
|
15
|
+
const trimmed = value.trim()
|
|
16
|
+
if (!trimmed) return
|
|
17
|
+
onSend(trimmed)
|
|
18
|
+
setValue('')
|
|
19
|
+
if (textareaRef.current) {
|
|
20
|
+
textareaRef.current.style.height = 'auto'
|
|
21
|
+
}
|
|
22
|
+
}, [value, onSend])
|
|
23
|
+
|
|
24
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
25
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
26
|
+
e.preventDefault()
|
|
27
|
+
handleSend()
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const handleInput = () => {
|
|
32
|
+
const el = textareaRef.current
|
|
33
|
+
if (!el) return
|
|
34
|
+
el.style.height = 'auto'
|
|
35
|
+
el.style.height = Math.min(el.scrollHeight, 160) + 'px'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="px-4 py-3 bg-background" data-testid="message-input-container">
|
|
40
|
+
<div className="flex items-end gap-2 bg-muted/50 border border-border rounded-lg focus-within:border-primary/40 transition-colors">
|
|
41
|
+
<textarea
|
|
42
|
+
ref={textareaRef}
|
|
43
|
+
data-testid="message-input"
|
|
44
|
+
value={value}
|
|
45
|
+
onChange={(e) => setValue(e.target.value)}
|
|
46
|
+
onKeyDown={handleKeyDown}
|
|
47
|
+
onInput={handleInput}
|
|
48
|
+
placeholder={placeholder}
|
|
49
|
+
rows={1}
|
|
50
|
+
className="flex-1 bg-transparent px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground resize-none outline-none max-h-40"
|
|
51
|
+
/>
|
|
52
|
+
<button
|
|
53
|
+
data-testid="send-message-btn"
|
|
54
|
+
onClick={handleSend}
|
|
55
|
+
disabled={!value.trim()}
|
|
56
|
+
className="shrink-0 px-3 py-2 mr-1 mb-0.5 rounded-md flex items-center justify-center text-muted-foreground disabled:opacity-30 disabled:cursor-not-allowed hover:text-foreground transition-colors"
|
|
57
|
+
>
|
|
58
|
+
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
|
59
|
+
<path d="M3.478 2.405a.75.75 0 00-.926.94l2.432 7.905H13.5a.75.75 0 010 1.5H4.984l-2.432 7.905a.75.75 0 00.926.94 60.519 60.519 0 0018.445-8.986.75.75 0 000-1.218A60.517 60.517 0 003.478 2.405z" />
|
|
60
|
+
</svg>
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|