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.
Files changed (120) hide show
  1. package/dist/cli.js +331 -115
  2. package/dist/cli.js.map +1 -1
  3. package/dist/index.d.ts +199 -16
  4. package/dist/index.js +354 -166
  5. package/dist/index.js.map +1 -1
  6. package/dist/server.d.ts +242 -73
  7. package/dist/server.js +985 -374
  8. package/dist/server.js.map +1 -1
  9. package/dist/worker.d.ts +322 -73
  10. package/dist/worker.js +961 -343
  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,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
+ }