deepspace 0.25.0 → 0.26.1

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 (53) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +7 -6
  3. package/dist/{chunk-2GUGZLAX.js → chunk-YBXIUDZJ.js} +2 -2
  4. package/dist/{chunk-2GUGZLAX.js.map → chunk-YBXIUDZJ.js.map} +1 -1
  5. package/dist/cli.js +1036 -966
  6. package/dist/cli.js.map +1 -1
  7. package/dist/documentation-client-core.js +1 -1
  8. package/dist/documentation-default-renderer.cjs +1 -1
  9. package/dist/documentation-react.js +1 -1
  10. package/dist/documentation-runtime.js +1 -1
  11. package/dist/documentation-server-core.js +1 -1
  12. package/dist/documentation.js +1 -1
  13. package/dist/documentation.js.map +1 -1
  14. package/dist/index.d.ts +26 -434
  15. package/dist/index.js +99 -864
  16. package/dist/index.js.map +1 -1
  17. package/dist/schema.d.ts +4 -41
  18. package/dist/schema.js +2 -242
  19. package/dist/schema.js.map +1 -1
  20. package/dist/server.d.ts +6 -162
  21. package/dist/server.js +13 -281
  22. package/dist/server.js.map +1 -1
  23. package/dist/worker.d.ts +4 -160
  24. package/dist/worker.js +15 -281
  25. package/dist/worker.js.map +1 -1
  26. package/features/messaging/feature.json +3 -43
  27. package/features/messaging/src/ChatPage.tsx +12 -16
  28. package/features/messaging/src/components/chat/ChatHeader.tsx +5 -13
  29. package/features/messaging/src/components/chat/MessageItem.tsx +7 -9
  30. package/features/messaging/src/components/chat/MessageList.tsx +57 -40
  31. package/features/messaging/src/components/chat/ThreadPanel.tsx +25 -11
  32. package/features/messaging/src/components/chat/UserProfilePopover.tsx +10 -29
  33. package/features/messaging/src/hooks/useChatChannel.ts +18 -23
  34. package/features/messaging/src/messaging-schema.ts +0 -2
  35. package/features/search-bar/feature.json +9 -20
  36. package/features/search-bar/src/InlineSearch.tsx +101 -0
  37. package/features/search-bar/src/__tests__/search-model.test.tsx +38 -0
  38. package/features/search-bar/src/search-model.ts +2 -149
  39. package/package.json +1 -1
  40. package/features/messaging/src/ChatMultiPage.tsx +0 -298
  41. package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +0 -188
  42. package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +0 -163
  43. package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +0 -147
  44. package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +0 -344
  45. package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +0 -460
  46. package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +0 -190
  47. package/features/messaging/src/components/chat-multi/InvitationList.tsx +0 -149
  48. package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +0 -159
  49. package/features/messaging/src/hooks/useMultiChannel.ts +0 -142
  50. package/features/search-bar/src/SearchOverlay.tsx +0 -566
  51. package/features/search-bar/src/__tests__/search-model.test.ts +0 -40
  52. package/features/sidebar/feature.json +0 -19
  53. package/features/sidebar/src/AppSidebar.tsx +0 -558
@@ -2,8 +2,8 @@
2
2
  "id": "messaging",
3
3
  "name": "Messaging",
4
4
  "category": "data",
5
- "description": "Real-time chat with channels, threads, reactions, and multi-channel support",
6
- "details": "Complete messaging system with single-channel chat (ChatPage) and multi-channel Slack-like interface (ChatMultiPage). Includes message threads, reactions, read receipts, user profile popovers, channel management (create, browse, settings), DMs, invitations, and member management. Uses SDK messaging hooks (useChannels, useMessages, useReadReceipts).",
5
+ "description": "Public real-time chat with threads and reactions",
6
+ "details": "A focused public-channel chat page with threads, reactions, presence, read receipts, and optional confirmed channel membership hooks. Viewers can read public chat without joining; private groups and DMs are deliberately excluded because client-side membership checks do not provide server-side record isolation.",
7
7
  "files": [
8
8
  {
9
9
  "src": "src/messaging-schema.ts",
@@ -13,10 +13,6 @@
13
13
  "src": "src/ChatPage.tsx",
14
14
  "dest": "src/pages/chat.tsx"
15
15
  },
16
- {
17
- "src": "src/ChatMultiPage.tsx",
18
- "dest": "src/pages/chat-multi.tsx"
19
- },
20
16
  {
21
17
  "src": "src/hooks/useChatChannel.ts",
22
18
  "dest": "src/components/messaging/hooks/useChatChannel.ts"
@@ -25,10 +21,6 @@
25
21
  "src": "src/hooks/useLongPress.ts",
26
22
  "dest": "src/components/messaging/hooks/useLongPress.ts"
27
23
  },
28
- {
29
- "src": "src/hooks/useMultiChannel.ts",
30
- "dest": "src/components/messaging/hooks/useMultiChannel.ts"
31
- },
32
24
  {
33
25
  "src": "src/components/chat/ChatHeader.tsx",
34
26
  "dest": "src/components/messaging/chat/ChatHeader.tsx"
@@ -56,38 +48,6 @@
56
48
  {
57
49
  "src": "src/components/chat/MessageActionSheet.tsx",
58
50
  "dest": "src/components/messaging/chat/MessageActionSheet.tsx"
59
- },
60
- {
61
- "src": "src/components/chat-multi/ChannelSidebar.tsx",
62
- "dest": "src/components/messaging/chat-multi/ChannelSidebar.tsx"
63
- },
64
- {
65
- "src": "src/components/chat-multi/ChannelListItem.tsx",
66
- "dest": "src/components/messaging/chat-multi/ChannelListItem.tsx"
67
- },
68
- {
69
- "src": "src/components/chat-multi/CreateGroupModal.tsx",
70
- "dest": "src/components/messaging/chat-multi/CreateGroupModal.tsx"
71
- },
72
- {
73
- "src": "src/components/chat-multi/NewMessageModal.tsx",
74
- "dest": "src/components/messaging/chat-multi/NewMessageModal.tsx"
75
- },
76
- {
77
- "src": "src/components/chat-multi/BrowseGroupsModal.tsx",
78
- "dest": "src/components/messaging/chat-multi/BrowseGroupsModal.tsx"
79
- },
80
- {
81
- "src": "src/components/chat-multi/ChannelSettingsPanel.tsx",
82
- "dest": "src/components/messaging/chat-multi/ChannelSettingsPanel.tsx"
83
- },
84
- {
85
- "src": "src/components/chat-multi/AddMemberModal.tsx",
86
- "dest": "src/components/messaging/chat-multi/AddMemberModal.tsx"
87
- },
88
- {
89
- "src": "src/components/chat-multi/InvitationList.tsx",
90
- "dest": "src/components/messaging/chat-multi/InvitationList.tsx"
91
51
  }
92
52
  ],
93
53
  "schema": {
@@ -101,7 +61,7 @@
101
61
  },
102
62
  "requires": [],
103
63
  "instructions": [
104
- "Optionally add a nav entry for /chat-multi (the multi-channel view). Its route is created automatically from the bundled page file — only the nav link is manual."
64
+ "The included schemas and UI are for public channels. Add server-enforced tenant or participant scoping before implementing private groups or direct messages."
105
65
  ],
106
66
  "patterns": []
107
67
  }
@@ -5,8 +5,7 @@
5
5
 
6
6
  import { useEffect, useRef } from 'react'
7
7
  import { useChatChannel } from '../components/messaging/hooks/useChatChannel'
8
- import { useReadReceipts } from 'deepspace'
9
- import { RecordScope } from 'deepspace'
8
+ import { RecordScope, useReadReceipts, useUser, isWriterRole } from 'deepspace'
10
9
  import type { CollectionSchema } from 'deepspace/schema'
11
10
  import { messagingSchemas } from '../schemas/messaging-schema'
12
11
  import { ChatHeader } from '../components/messaging/chat/ChatHeader'
@@ -23,18 +22,20 @@ export default function ChatPage({
23
22
  channelName = 'general',
24
23
  className,
25
24
  }: ChatPageProps) {
26
- const { channelId, status, isMember, join } = useChatChannel(channelName)
25
+ const { channelId, status } = useChatChannel(channelName)
27
26
  const { markAsRead } = useReadReceipts()
27
+ const { user } = useUser()
28
28
  const lastMarkedRef = useRef<string | null>(null)
29
+ const canTrackReadState = isWriterRole(user?.role)
29
30
 
30
31
  useEffect(() => {
31
- if (!channelId || !isMember) return
32
+ if (!channelId || !canTrackReadState) return
32
33
  if (lastMarkedRef.current === channelId) return
33
34
  lastMarkedRef.current = channelId
34
35
  markAsRead(channelId)
35
- }, [channelId, isMember, markAsRead])
36
+ }, [canTrackReadState, channelId, markAsRead])
36
37
 
37
- if (status !== 'ready' || !channelId) {
38
+ if (status !== 'ready') {
38
39
  // data-testid lets tests (and humans reading a trace) distinguish "stuck
39
40
  // waiting for the default channel" from "route never mounted".
40
41
  return (
@@ -47,20 +48,15 @@ export default function ChatPage({
47
48
  )
48
49
  }
49
50
 
50
- if (!isMember) {
51
+ if (!channelId) {
51
52
  return (
52
53
  <div className={`flex flex-col h-full ${className ?? ''}`} data-testid="chat-page">
53
- <ChatHeader channelId={channelId} />
54
54
  <div className="flex-1 flex items-center justify-center">
55
55
  <div className="text-center">
56
- <p className="text-foreground mb-3">You're not a member of this channel</p>
57
- <button
58
- data-testid="join-channel-btn"
59
- onClick={join}
60
- className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
61
- >
62
- Join Channel
63
- </button>
56
+ <p className="text-foreground">No public channel exists yet.</p>
57
+ <p className="mt-1 text-sm text-muted-foreground">
58
+ A member or admin can create the first channel.
59
+ </p>
64
60
  </div>
65
61
  </div>
66
62
  </div>
@@ -1,10 +1,9 @@
1
1
  /**
2
- * ChatHeader -- Channel header showing name, description, and member count.
2
+ * ChatHeader -- Channel header showing the public channel name and description.
3
3
  * Generic styling (no Slack-specific prefixes like #).
4
4
  */
5
5
 
6
6
  import { useChannels } from 'deepspace'
7
- import { useChannelMembers } from 'deepspace'
8
7
  import type { Channel } from 'deepspace'
9
8
  import type { RecordData } from 'deepspace'
10
9
 
@@ -16,7 +15,6 @@ interface ChatHeaderProps {
16
15
 
17
16
  export function ChatHeader({ channelId, onToggleThread, threadOpen }: ChatHeaderProps) {
18
17
  const { channels } = useChannels()
19
- const { members } = useChannelMembers(channelId)
20
18
 
21
19
  const channel = channels.find((c: RecordData<Channel>) => c.recordId === channelId)
22
20
 
@@ -42,14 +40,8 @@ export function ChatHeader({ channelId, onToggleThread, threadOpen }: ChatHeader
42
40
  )}
43
41
  </div>
44
42
 
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 && (
43
+ {onToggleThread && (
44
+ <div className="flex items-center gap-2 shrink-0">
53
45
  <button
54
46
  data-testid="toggle-thread-btn"
55
47
  onClick={onToggleThread}
@@ -69,8 +61,8 @@ export function ChatHeader({ channelId, onToggleThread, threadOpen }: ChatHeader
69
61
  />
70
62
  </svg>
71
63
  </button>
72
- )}
73
- </div>
64
+ </div>
65
+ )}
74
66
  </div>
75
67
  )
76
68
  }
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { useCallback, useEffect, useRef, useState } from 'react'
8
- import { useUserLookup } from 'deepspace'
8
+ import { useUserLookup, isWriterRole } from 'deepspace'
9
9
  import { useUser } from 'deepspace'
10
10
  import { usePresence } from 'deepspace'
11
11
  import type { Message } from 'deepspace'
@@ -43,7 +43,6 @@ interface MessageItemProps {
43
43
  forceEdit?: boolean
44
44
  onEditDone?: () => void
45
45
  isHighlighted?: boolean
46
- onStartDM?: (userId: string) => void
47
46
  }
48
47
 
49
48
  export function MessageItem({
@@ -59,7 +58,6 @@ export function MessageItem({
59
58
  forceEdit = false,
60
59
  onEditDone,
61
60
  isHighlighted = false,
62
- onStartDM,
63
61
  }: MessageItemProps) {
64
62
  const { getUser } = useUserLookup()
65
63
  const { user: currentUser } = useUser()
@@ -74,11 +72,12 @@ export function MessageItem({
74
72
  const authorId = message.data.authorId || message.createdBy
75
73
  const author = getUser(authorId)
76
74
  const isOwn = currentUser?.id === authorId || currentUser?.id === message.createdBy
75
+ const canWrite = isWriterRole(currentUser?.role)
77
76
  const online = isOnline(authorId)
78
77
 
79
78
  const longPressHandlers = useLongPress(
80
79
  () => {
81
- if (!contentRef.current) return
80
+ if (!canWrite || !contentRef.current) return
82
81
  const rect = contentRef.current.getBoundingClientRect()
83
82
  onLongPress?.({
84
83
  top: rect.top,
@@ -198,9 +197,7 @@ export function MessageItem({
198
197
  {/* Avatar column */}
199
198
  <div className="shrink-0 w-10">
200
199
  {isFirstInGroup ? (
201
- <UserProfilePopover userId={authorId} onStartDM={onStartDM}>
202
- {avatar}
203
- </UserProfilePopover>
200
+ <UserProfilePopover userId={authorId}>{avatar}</UserProfilePopover>
204
201
  ) : (
205
202
  <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">
206
203
  {timeStr}
@@ -212,7 +209,7 @@ export function MessageItem({
212
209
  <div ref={contentRef} className={`flex-1 min-w-0 ${isHighlighted ? 'invisible' : ''}`}>
213
210
  {isFirstInGroup && (
214
211
  <div className="flex items-baseline gap-2 mb-0.5">
215
- <UserProfilePopover userId={authorId} onStartDM={onStartDM}>
212
+ <UserProfilePopover userId={authorId}>
216
213
  <span className="text-sm font-semibold text-foreground hover:underline cursor-pointer">
217
214
  {author?.name ?? 'Unknown'}
218
215
  </span>
@@ -289,6 +286,7 @@ export function MessageItem({
289
286
  key={r.emoji}
290
287
  data-testid={`reaction-${message.recordId}-${r.emoji}`}
291
288
  onClick={() => onToggleReaction(r.emoji)}
289
+ disabled={!canWrite}
292
290
  className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs border transition-colors ${
293
291
  r.currentUserReacted
294
292
  ? 'bg-primary/15 border-primary/30 text-primary'
@@ -315,7 +313,7 @@ export function MessageItem({
315
313
  </div>
316
314
 
317
315
  {/* Desktop hover toolbar */}
318
- {!isEditing && (
316
+ {!isEditing && canWrite && (
319
317
  <div
320
318
  data-testid={`hover-toolbar-${message.recordId}`}
321
319
  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"
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
7
- import { useMessages } from 'deepspace'
7
+ import { useMessages, isWriterRole } from 'deepspace'
8
8
  import { useReactions } from 'deepspace'
9
9
  import { useUser } from 'deepspace'
10
10
  import type { Message } from 'deepspace'
@@ -17,7 +17,6 @@ import { ThreadPanel } from './ThreadPanel'
17
17
 
18
18
  interface MessageListProps {
19
19
  channelId: string
20
- onStartDM?: (userId: string) => void
21
20
  }
22
21
 
23
22
  interface ActionSheetState {
@@ -39,7 +38,9 @@ function isFirstInGroup(msgs: RecordData<Message>[], i: number): boolean {
39
38
  const prev = msgs[i - 1]
40
39
  const curr = msgs[i]
41
40
  if (getAuthorId(prev) !== getAuthorId(curr)) return true
42
- return new Date(curr.createdAt).getTime() - new Date(prev.createdAt).getTime() >= GROUP_THRESHOLD_MS
41
+ return (
42
+ new Date(curr.createdAt).getTime() - new Date(prev.createdAt).getTime() >= GROUP_THRESHOLD_MS
43
+ )
43
44
  }
44
45
 
45
46
  function formatTime(date: Date): string {
@@ -53,14 +54,19 @@ function getSeparatorLabel(date: Date): string {
53
54
 
54
55
  if (date.toDateString() === today.toDateString()) return `Today ${formatTime(date)}`
55
56
  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)}`
57
+ return (
58
+ date.toLocaleDateString(undefined, {
59
+ weekday: 'long',
60
+ month: 'long',
61
+ day: 'numeric',
62
+ }) + ` ${formatTime(date)}`
63
+ )
61
64
  }
62
65
 
63
- function shouldShowSeparator(current: RecordData<Message>, prev: RecordData<Message> | undefined): boolean {
66
+ function shouldShowSeparator(
67
+ current: RecordData<Message>,
68
+ prev: RecordData<Message> | undefined,
69
+ ): boolean {
64
70
  if (!prev) return true
65
71
  const curDate = new Date(current.createdAt)
66
72
  const prevDate = new Date(prev.createdAt)
@@ -68,10 +74,11 @@ function shouldShowSeparator(current: RecordData<Message>, prev: RecordData<Mess
68
74
  return curDate.getTime() - prevDate.getTime() >= TIME_SEPARATOR_GAP_MS
69
75
  }
70
76
 
71
- export function MessageList({ channelId, onStartDM }: MessageListProps) {
77
+ export function MessageList({ channelId }: MessageListProps) {
72
78
  const { messages, status, send, edit, softDelete } = useMessages(channelId)
73
79
  const { getReactionsForMessage, toggle: toggleReaction } = useReactions(channelId)
74
80
  const { user: currentUser } = useUser()
81
+ const canWrite = isWriterRole(currentUser?.role)
75
82
  const bottomRef = useRef<HTMLDivElement>(null)
76
83
  const feedRef = useRef<HTMLDivElement>(null)
77
84
  const isInitialMount = useRef(true)
@@ -85,17 +92,14 @@ export function MessageList({ channelId, onStartDM }: MessageListProps) {
85
92
  messages
86
93
  .filter((m: RecordData<Message>) => !m.data.parentMessageId)
87
94
  .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()),
88
- [messages]
95
+ [messages],
89
96
  )
90
97
 
91
98
  const threadCounts = useMemo(() => {
92
99
  const counts = new Map<string, number>()
93
100
  for (const m of messages) {
94
101
  if (m.data.parentMessageId) {
95
- counts.set(
96
- m.data.parentMessageId,
97
- (counts.get(m.data.parentMessageId) ?? 0) + 1
98
- )
102
+ counts.set(m.data.parentMessageId, (counts.get(m.data.parentMessageId) ?? 0) + 1)
99
103
  }
100
104
  }
101
105
  return counts
@@ -127,6 +131,7 @@ export function MessageList({ channelId, onStartDM }: MessageListProps) {
127
131
 
128
132
  const handleLongPress = useCallback(
129
133
  (msg: RecordData<Message>, rect: MessageRect) => {
134
+ if (!canWrite) return
130
135
  const authorId = msg.data.authorId || msg.createdBy
131
136
  const isOwn = currentUser?.id === authorId || currentUser?.id === msg.createdBy
132
137
  setActionSheet({
@@ -136,7 +141,7 @@ export function MessageList({ channelId, onStartDM }: MessageListProps) {
136
141
  rect,
137
142
  })
138
143
  },
139
- [currentUser]
144
+ [canWrite, currentUser],
140
145
  )
141
146
 
142
147
  if (status === 'loading') {
@@ -163,12 +168,18 @@ export function MessageList({ channelId, onStartDM }: MessageListProps) {
163
168
  return (
164
169
  <div className="flex flex-1 min-h-0">
165
170
  <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">
171
+ <div
172
+ ref={feedRef}
173
+ className="flex-1 overflow-y-auto overflow-x-hidden flex flex-col"
174
+ data-testid="messages-feed"
175
+ >
167
176
  {topLevelMessages.length === 0 ? (
168
177
  <div className="flex items-center justify-center flex-1">
169
178
  <div className="text-center text-muted-foreground">
170
179
  <p className="text-lg mb-1">No messages yet</p>
171
- <p className="text-sm">Be the first to send a message!</p>
180
+ <p className="text-sm">
181
+ {canWrite ? 'Be the first to send a message!' : 'There are no messages yet.'}
182
+ </p>
172
183
  </div>
173
184
  </div>
174
185
  ) : (
@@ -181,7 +192,10 @@ export function MessageList({ channelId, onStartDM }: MessageListProps) {
181
192
  return (
182
193
  <div key={msg.recordId}>
183
194
  {showSeparator && (
184
- <div className="relative flex items-center justify-center my-2 mx-4" data-testid="date-separator">
195
+ <div
196
+ className="relative flex items-center justify-center my-2 mx-4"
197
+ data-testid="date-separator"
198
+ >
185
199
  <div className="absolute inset-0 flex items-center">
186
200
  <div className="w-full border-t border-border" />
187
201
  </div>
@@ -203,7 +217,6 @@ export function MessageList({ channelId, onStartDM }: MessageListProps) {
203
217
  forceEdit={editingMessageId === msg.recordId}
204
218
  onEditDone={() => setEditingMessageId(null)}
205
219
  isHighlighted={actionSheet?.messageId === msg.recordId}
206
- onStartDM={onStartDM}
207
220
  />
208
221
  </div>
209
222
  )
@@ -213,7 +226,9 @@ export function MessageList({ channelId, onStartDM }: MessageListProps) {
213
226
  )}
214
227
  </div>
215
228
 
216
- <MessageInput onSend={(content) => send(content)} placeholder="Type a message..." />
229
+ {canWrite && (
230
+ <MessageInput onSend={(content) => send(content)} placeholder="Type a message..." />
231
+ )}
217
232
  </div>
218
233
 
219
234
  {threadMessageId && (
@@ -224,25 +239,27 @@ export function MessageList({ channelId, onStartDM }: MessageListProps) {
224
239
  />
225
240
  )}
226
241
 
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) softDelete(actionSheet.messageId)
239
- }}
240
- onReply={() => {
241
- if (actionSheet) setThreadMessageId(actionSheet.messageId)
242
- }}
243
- messageContent={actionSheet?.content ?? ''}
244
- messageRect={actionSheet?.rect ?? null}
245
- />
242
+ {canWrite && (
243
+ <MessageActionSheet
244
+ visible={!!actionSheet}
245
+ onClose={() => setActionSheet(null)}
246
+ isOwn={actionSheet?.isOwn ?? false}
247
+ onReaction={(emoji) => {
248
+ if (actionSheet) toggleReaction(actionSheet.messageId, emoji)
249
+ }}
250
+ onEdit={() => {
251
+ if (actionSheet) setEditingMessageId(actionSheet.messageId)
252
+ }}
253
+ onDelete={() => {
254
+ if (actionSheet) softDelete(actionSheet.messageId)
255
+ }}
256
+ onReply={() => {
257
+ if (actionSheet) setThreadMessageId(actionSheet.messageId)
258
+ }}
259
+ messageContent={actionSheet?.content ?? ''}
260
+ messageRect={actionSheet?.rect ?? null}
261
+ />
262
+ )}
246
263
  </div>
247
264
  )
248
265
  }
@@ -4,9 +4,10 @@
4
4
  */
5
5
 
6
6
  import { useEffect, useRef } from 'react'
7
- import { useMessages } from 'deepspace'
7
+ import { useMessages, isWriterRole } from 'deepspace'
8
8
  import { useReactions } from 'deepspace'
9
9
  import { useUserLookup } from 'deepspace'
10
+ import { useUser } from 'deepspace'
10
11
  import type { Message } from 'deepspace'
11
12
  import type { RecordData } from 'deepspace'
12
13
  import { MessageItem } from './MessageItem'
@@ -20,16 +21,21 @@ interface ThreadPanelProps {
20
21
 
21
22
  export function ThreadPanel({ channelId, parentMessageId, onClose }: ThreadPanelProps) {
22
23
  const { messages: allMessages } = useMessages(channelId)
23
- const { messages: replies, send, edit, remove } = useMessages(channelId, {
24
+ const {
25
+ messages: replies,
26
+ send,
27
+ edit,
28
+ remove,
29
+ } = useMessages(channelId, {
24
30
  parentMessageId,
25
31
  })
26
32
  const { getReactionsForMessage, toggle: toggleReaction } = useReactions(channelId)
27
33
  const { getUser } = useUserLookup()
34
+ const { user } = useUser()
35
+ const canWrite = isWriterRole(user?.role)
28
36
  const bottomRef = useRef<HTMLDivElement>(null)
29
37
 
30
- const parentMessage = allMessages.find(
31
- (m: RecordData<Message>) => m.recordId === parentMessageId
32
- )
38
+ const parentMessage = allMessages.find((m: RecordData<Message>) => m.recordId === parentMessageId)
33
39
 
34
40
  useEffect(() => {
35
41
  bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
@@ -58,7 +64,13 @@ export function ThreadPanel({ channelId, parentMessageId, onClose }: ThreadPanel
58
64
  onClick={onClose}
59
65
  className="p-1 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
60
66
  >
61
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
67
+ <svg
68
+ className="w-4 h-4"
69
+ fill="none"
70
+ viewBox="0 0 24 24"
71
+ stroke="currentColor"
72
+ strokeWidth={2}
73
+ >
62
74
  <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
63
75
  </svg>
64
76
  </button>
@@ -72,7 +84,10 @@ export function ThreadPanel({ channelId, parentMessageId, onClose }: ThreadPanel
72
84
  </span>
73
85
  <span className="text-xs text-muted-foreground">{parentTime}</span>
74
86
  </div>
75
- <p className="text-sm text-foreground/90 whitespace-pre-wrap break-words" data-testid="thread-parent-content">
87
+ <p
88
+ className="text-sm text-foreground/90 whitespace-pre-wrap break-words"
89
+ data-testid="thread-parent-content"
90
+ >
76
91
  {parentMessage.data.content}
77
92
  </p>
78
93
  <div className="mt-1.5 text-xs text-muted-foreground">
@@ -98,10 +113,9 @@ export function ThreadPanel({ channelId, parentMessageId, onClose }: ThreadPanel
98
113
  <div ref={bottomRef} />
99
114
  </div>
100
115
 
101
- <MessageInput
102
- onSend={(content) => send(content, parentMessageId)}
103
- placeholder="Reply..."
104
- />
116
+ {canWrite && (
117
+ <MessageInput onSend={(content) => send(content, parentMessageId)} placeholder="Reply..." />
118
+ )}
105
119
  </div>
106
120
  )
107
121
  }
@@ -5,13 +5,11 @@
5
5
 
6
6
  import { useState, useRef, useEffect, useCallback, type ReactNode } from 'react'
7
7
  import { useUserLookup } from 'deepspace'
8
- import { useUser } from 'deepspace'
9
8
  import { usePresence } from 'deepspace'
10
9
 
11
10
  interface UserProfilePopoverProps {
12
11
  userId: string
13
12
  children: ReactNode
14
- onStartDM?: (userId: string) => void
15
13
  }
16
14
 
17
15
  function formatLastSeen(lastSeenAt: string | undefined): string {
@@ -28,9 +26,8 @@ function formatLastSeen(lastSeenAt: string | undefined): string {
28
26
 
29
27
  const POPOVER_HEIGHT_ESTIMATE = 180
30
28
 
31
- export function UserProfilePopover({ userId, children, onStartDM }: UserProfilePopoverProps) {
29
+ export function UserProfilePopover({ userId, children }: UserProfilePopoverProps) {
32
30
  const { getUser } = useUserLookup()
33
- const { user: currentUser } = useUser()
34
31
  const { isOnline, users } = usePresence()
35
32
  const [open, setOpen] = useState(false)
36
33
  const [placement, setPlacement] = useState<'below' | 'above'>('below')
@@ -49,9 +46,7 @@ export function UserProfilePopover({ userId, children, onStartDM }: UserProfileP
49
46
  const rect = triggerRef.current.getBoundingClientRect()
50
47
  const spaceBelow = window.innerHeight - rect.bottom
51
48
  const inBottomHalf = rect.top > window.innerHeight / 2
52
- setPlacement(
53
- spaceBelow < POPOVER_HEIGHT_ESTIMATE || inBottomHalf ? 'above' : 'below'
54
- )
49
+ setPlacement(spaceBelow < POPOVER_HEIGHT_ESTIMATE || inBottomHalf ? 'above' : 'below')
55
50
  }
56
51
  return !prev
57
52
  })
@@ -61,8 +56,10 @@ export function UserProfilePopover({ userId, children, onStartDM }: UserProfileP
61
56
  if (!open) return
62
57
  function handleClickOutside(e: MouseEvent) {
63
58
  if (
64
- popoverRef.current && !popoverRef.current.contains(e.target as Node) &&
65
- triggerRef.current && !triggerRef.current.contains(e.target as Node)
59
+ popoverRef.current &&
60
+ !popoverRef.current.contains(e.target as Node) &&
61
+ triggerRef.current &&
62
+ !triggerRef.current.contains(e.target as Node)
66
63
  ) {
67
64
  close()
68
65
  }
@@ -81,9 +78,7 @@ export function UserProfilePopover({ userId, children, onStartDM }: UserProfileP
81
78
  }
82
79
  }, [open, close])
83
80
 
84
- const positionClass = placement === 'above'
85
- ? 'bottom-full mb-2 left-0'
86
- : 'top-full mt-2 left-0'
81
+ const positionClass = placement === 'above' ? 'bottom-full mb-2 left-0' : 'top-full mt-2 left-0'
87
82
 
88
83
  return (
89
84
  <span className="relative inline-flex">
@@ -118,7 +113,9 @@ export function UserProfilePopover({ userId, children, onStartDM }: UserProfileP
118
113
  />
119
114
  </div>
120
115
  <div className="flex-1 min-w-0">
121
- <p className="text-sm font-semibold text-foreground truncate">{user.name ?? 'Unknown'}</p>
116
+ <p className="text-sm font-semibold text-foreground truncate">
117
+ {user.name ?? 'Unknown'}
118
+ </p>
122
119
  {roomUser?.role && (
123
120
  <span className="inline-block mt-0.5 px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground">
124
121
  {roomUser.role}
@@ -135,22 +132,6 @@ export function UserProfilePopover({ userId, children, onStartDM }: UserProfileP
135
132
  {online ? 'Online' : `Last seen ${formatLastSeen(roomUser?.lastSeenAt)}`}
136
133
  </p>
137
134
  </div>
138
-
139
- {onStartDM && currentUser?.id !== userId && (
140
- <button
141
- data-testid={`dm-from-profile-${userId}`}
142
- onClick={() => {
143
- onStartDM(userId)
144
- close()
145
- }}
146
- className="mt-3 w-full flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
147
- >
148
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
149
- <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" />
150
- </svg>
151
- Message
152
- </button>
153
- )}
154
135
  </div>
155
136
  )}
156
137
  </span>