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.
- package/CHANGELOG.md +41 -0
- package/README.md +7 -6
- package/dist/{chunk-2GUGZLAX.js → chunk-YBXIUDZJ.js} +2 -2
- package/dist/{chunk-2GUGZLAX.js.map → chunk-YBXIUDZJ.js.map} +1 -1
- package/dist/cli.js +1036 -966
- package/dist/cli.js.map +1 -1
- package/dist/documentation-client-core.js +1 -1
- package/dist/documentation-default-renderer.cjs +1 -1
- package/dist/documentation-react.js +1 -1
- package/dist/documentation-runtime.js +1 -1
- package/dist/documentation-server-core.js +1 -1
- package/dist/documentation.js +1 -1
- package/dist/documentation.js.map +1 -1
- package/dist/index.d.ts +26 -434
- package/dist/index.js +99 -864
- package/dist/index.js.map +1 -1
- package/dist/schema.d.ts +4 -41
- package/dist/schema.js +2 -242
- package/dist/schema.js.map +1 -1
- package/dist/server.d.ts +6 -162
- package/dist/server.js +13 -281
- package/dist/server.js.map +1 -1
- package/dist/worker.d.ts +4 -160
- package/dist/worker.js +15 -281
- package/dist/worker.js.map +1 -1
- package/features/messaging/feature.json +3 -43
- package/features/messaging/src/ChatPage.tsx +12 -16
- package/features/messaging/src/components/chat/ChatHeader.tsx +5 -13
- package/features/messaging/src/components/chat/MessageItem.tsx +7 -9
- package/features/messaging/src/components/chat/MessageList.tsx +57 -40
- package/features/messaging/src/components/chat/ThreadPanel.tsx +25 -11
- package/features/messaging/src/components/chat/UserProfilePopover.tsx +10 -29
- package/features/messaging/src/hooks/useChatChannel.ts +18 -23
- package/features/messaging/src/messaging-schema.ts +0 -2
- package/features/search-bar/feature.json +9 -20
- package/features/search-bar/src/InlineSearch.tsx +101 -0
- package/features/search-bar/src/__tests__/search-model.test.tsx +38 -0
- package/features/search-bar/src/search-model.ts +2 -149
- package/package.json +1 -1
- package/features/messaging/src/ChatMultiPage.tsx +0 -298
- package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +0 -188
- package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +0 -163
- package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +0 -147
- package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +0 -344
- package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +0 -460
- package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +0 -190
- package/features/messaging/src/components/chat-multi/InvitationList.tsx +0 -149
- package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +0 -159
- package/features/messaging/src/hooks/useMultiChannel.ts +0 -142
- package/features/search-bar/src/SearchOverlay.tsx +0 -566
- package/features/search-bar/src/__tests__/search-model.test.ts +0 -40
- package/features/sidebar/feature.json +0 -19
- package/features/sidebar/src/AppSidebar.tsx +0 -558
|
@@ -1,298 +0,0 @@
|
|
|
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/schema'
|
|
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
|
-
className?: string
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export default function ChatMultiPage({
|
|
34
|
-
schemas = messagingSchemas,
|
|
35
|
-
className,
|
|
36
|
-
}: ChatMultiPageProps) {
|
|
37
|
-
const {
|
|
38
|
-
activeChannelId,
|
|
39
|
-
selectChannel,
|
|
40
|
-
channels,
|
|
41
|
-
groups,
|
|
42
|
-
dms,
|
|
43
|
-
pendingInvitations,
|
|
44
|
-
status,
|
|
45
|
-
hasUnread,
|
|
46
|
-
getDMDisplayName,
|
|
47
|
-
} = useMultiChannel()
|
|
48
|
-
|
|
49
|
-
const { user } = useUser()
|
|
50
|
-
const { channels: allChannels, create: createChannel } = useChannels()
|
|
51
|
-
const { create: createMembership } = useMutations<ChannelMember>('channel-members')
|
|
52
|
-
const { isMember, join, status: memberStatus } = useChannelMembers(activeChannelId ?? undefined)
|
|
53
|
-
const { markAsRead } = useReadReceipts()
|
|
54
|
-
|
|
55
|
-
const [showSettings, setShowSettings] = useState(false)
|
|
56
|
-
const [mobileView, setMobileView] = useState<'sidebar' | 'chat'>('sidebar')
|
|
57
|
-
const lastMarkedRef = useRef<string | null>(null)
|
|
58
|
-
|
|
59
|
-
useEffect(() => {
|
|
60
|
-
if (!activeChannelId || !isMember) return
|
|
61
|
-
if (lastMarkedRef.current === activeChannelId) return
|
|
62
|
-
lastMarkedRef.current = activeChannelId
|
|
63
|
-
markAsRead(activeChannelId)
|
|
64
|
-
}, [activeChannelId, isMember, markAsRead])
|
|
65
|
-
|
|
66
|
-
const handleSelectChannel = useCallback(
|
|
67
|
-
(channelId: string) => {
|
|
68
|
-
selectChannel(channelId)
|
|
69
|
-
setShowSettings(false)
|
|
70
|
-
setMobileView('chat')
|
|
71
|
-
},
|
|
72
|
-
[selectChannel]
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
const handleBack = useCallback(() => {
|
|
76
|
-
setMobileView('sidebar')
|
|
77
|
-
setShowSettings(false)
|
|
78
|
-
}, [])
|
|
79
|
-
|
|
80
|
-
const handleLeave = useCallback(() => {
|
|
81
|
-
setShowSettings(false)
|
|
82
|
-
setMobileView('sidebar')
|
|
83
|
-
}, [])
|
|
84
|
-
|
|
85
|
-
const handleStartDM = useCallback(
|
|
86
|
-
async (targetUserId: string) => {
|
|
87
|
-
if (!user) return
|
|
88
|
-
const ids = [user.id, targetUserId].sort()
|
|
89
|
-
const dmChannelName = `dm-${ids[0]}-${ids[1]}`
|
|
90
|
-
|
|
91
|
-
const existingDM = allChannels.find(
|
|
92
|
-
(c: RecordData<Channel>) => c.data.type === 'dm' && c.data.name === dmChannelName
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
if (existingDM) {
|
|
96
|
-
handleSelectChannel(existingDM.recordId)
|
|
97
|
-
return
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const channelId = await createChannel({
|
|
101
|
-
name: dmChannelName,
|
|
102
|
-
type: 'dm',
|
|
103
|
-
description: '',
|
|
104
|
-
})
|
|
105
|
-
if (channelId && user) {
|
|
106
|
-
await createMembership({
|
|
107
|
-
channelId,
|
|
108
|
-
userId: user.id,
|
|
109
|
-
joinedAt: new Date().toISOString(),
|
|
110
|
-
} as unknown as ChannelMember)
|
|
111
|
-
handleSelectChannel(channelId)
|
|
112
|
-
}
|
|
113
|
-
},
|
|
114
|
-
[user, allChannels, createChannel, createMembership, handleSelectChannel]
|
|
115
|
-
)
|
|
116
|
-
|
|
117
|
-
const activeChannelData = channels.find(
|
|
118
|
-
(c: RecordData<Channel>) => c.recordId === activeChannelId
|
|
119
|
-
)
|
|
120
|
-
const isDM = activeChannelData?.data.type === 'dm'
|
|
121
|
-
const isPrivate = activeChannelData?.data.type === 'private'
|
|
122
|
-
|
|
123
|
-
if (status !== 'ready') {
|
|
124
|
-
return (
|
|
125
|
-
<div className={`flex items-center justify-center h-full ${className ?? ''}`}>
|
|
126
|
-
<div className="text-muted-foreground text-sm">Loading...</div>
|
|
127
|
-
</div>
|
|
128
|
-
)
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return (
|
|
132
|
-
<div className={`flex h-full ${className ?? ''}`} data-testid="chat-multi-page">
|
|
133
|
-
{/* Sidebar -- visible on desktop always; on mobile only when mobileView='sidebar' */}
|
|
134
|
-
<div
|
|
135
|
-
className={`w-full md:w-72 lg:w-80 shrink-0 border-r border-border/40 ${
|
|
136
|
-
mobileView === 'sidebar' ? 'flex' : 'hidden md:flex'
|
|
137
|
-
}`}
|
|
138
|
-
>
|
|
139
|
-
<div className="w-full">
|
|
140
|
-
<ChannelSidebar
|
|
141
|
-
groups={groups}
|
|
142
|
-
dms={dms}
|
|
143
|
-
pendingInvitations={pendingInvitations}
|
|
144
|
-
activeChannelId={activeChannelId}
|
|
145
|
-
onSelectChannel={handleSelectChannel}
|
|
146
|
-
hasUnread={hasUnread}
|
|
147
|
-
getDMDisplayName={getDMDisplayName}
|
|
148
|
-
/>
|
|
149
|
-
</div>
|
|
150
|
-
</div>
|
|
151
|
-
|
|
152
|
-
{/* Chat area -- on mobile only when mobileView='chat' */}
|
|
153
|
-
<div
|
|
154
|
-
className={`flex-1 flex flex-col min-w-0 ${
|
|
155
|
-
mobileView === 'chat' ? 'flex' : 'hidden md:flex'
|
|
156
|
-
}`}
|
|
157
|
-
>
|
|
158
|
-
{activeChannelId ? (
|
|
159
|
-
<RecordScope key={activeChannelId} roomId={`chat:${activeChannelId}`} schemas={schemas}>
|
|
160
|
-
{/* Multi-channel header with back button on mobile */}
|
|
161
|
-
<div
|
|
162
|
-
data-testid="multi-chat-header"
|
|
163
|
-
className="shrink-0 px-4 py-3 border-b border-border bg-card/80 backdrop-blur-sm flex items-center gap-3"
|
|
164
|
-
>
|
|
165
|
-
{/* Mobile back button */}
|
|
166
|
-
<button
|
|
167
|
-
data-testid="back-to-sidebar"
|
|
168
|
-
onClick={handleBack}
|
|
169
|
-
className="md:hidden p-1 -ml-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
170
|
-
>
|
|
171
|
-
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
172
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
|
173
|
-
</svg>
|
|
174
|
-
</button>
|
|
175
|
-
|
|
176
|
-
<div className="flex-1 min-w-0">
|
|
177
|
-
<h1 className="text-base font-semibold text-foreground truncate">
|
|
178
|
-
{isDM && activeChannelData
|
|
179
|
-
? getDMDisplayName(activeChannelData)
|
|
180
|
-
: activeChannelData?.data.name ?? 'Chat'}
|
|
181
|
-
</h1>
|
|
182
|
-
{activeChannelData?.data.description && !isDM && (
|
|
183
|
-
<p className="text-xs text-muted-foreground truncate">
|
|
184
|
-
{activeChannelData.data.description}
|
|
185
|
-
</p>
|
|
186
|
-
)}
|
|
187
|
-
</div>
|
|
188
|
-
|
|
189
|
-
{/* Settings button for groups (not DMs) */}
|
|
190
|
-
{!isDM && isMember && (
|
|
191
|
-
<button
|
|
192
|
-
data-testid="toggle-settings-btn"
|
|
193
|
-
onClick={() => setShowSettings((prev) => !prev)}
|
|
194
|
-
className={`p-1.5 rounded-md transition-colors ${
|
|
195
|
-
showSettings
|
|
196
|
-
? 'text-primary bg-primary/10'
|
|
197
|
-
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
198
|
-
}`}
|
|
199
|
-
title="Group settings"
|
|
200
|
-
>
|
|
201
|
-
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
202
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
203
|
-
</svg>
|
|
204
|
-
</button>
|
|
205
|
-
)}
|
|
206
|
-
</div>
|
|
207
|
-
|
|
208
|
-
{/* Channel content */}
|
|
209
|
-
{memberStatus === 'ready' && !isMember ? (
|
|
210
|
-
isDM ? (
|
|
211
|
-
<DmAutoJoin join={join} channelId={activeChannelId}>
|
|
212
|
-
<div className="flex flex-1 min-h-0">
|
|
213
|
-
<div className={`flex flex-col flex-1 min-w-0 ${showSettings ? 'hidden md:flex' : 'flex'}`}>
|
|
214
|
-
<MessageList channelId={activeChannelId} onStartDM={handleStartDM} />
|
|
215
|
-
</div>
|
|
216
|
-
</div>
|
|
217
|
-
</DmAutoJoin>
|
|
218
|
-
) : (
|
|
219
|
-
<div className="flex-1 flex items-center justify-center">
|
|
220
|
-
<div className="text-center">
|
|
221
|
-
{isPrivate ? (
|
|
222
|
-
<>
|
|
223
|
-
<p className="text-foreground mb-1">This is a private group</p>
|
|
224
|
-
<p className="text-sm text-muted-foreground">
|
|
225
|
-
You need an invitation to join.
|
|
226
|
-
</p>
|
|
227
|
-
</>
|
|
228
|
-
) : (
|
|
229
|
-
<>
|
|
230
|
-
<p className="text-foreground mb-3">You're not a member of this group</p>
|
|
231
|
-
<button
|
|
232
|
-
data-testid="join-channel-btn"
|
|
233
|
-
onClick={join}
|
|
234
|
-
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
|
235
|
-
>
|
|
236
|
-
Join Group
|
|
237
|
-
</button>
|
|
238
|
-
</>
|
|
239
|
-
)}
|
|
240
|
-
</div>
|
|
241
|
-
</div>
|
|
242
|
-
)
|
|
243
|
-
) : (
|
|
244
|
-
<div className="flex flex-1 min-h-0">
|
|
245
|
-
<div className={`flex flex-col flex-1 min-w-0 ${showSettings ? 'hidden md:flex' : 'flex'}`}>
|
|
246
|
-
<MessageList channelId={activeChannelId} onStartDM={handleStartDM} />
|
|
247
|
-
</div>
|
|
248
|
-
{showSettings && activeChannelId && (
|
|
249
|
-
<ChannelSettingsPanel
|
|
250
|
-
channelId={activeChannelId}
|
|
251
|
-
onClose={() => setShowSettings(false)}
|
|
252
|
-
onLeave={handleLeave}
|
|
253
|
-
/>
|
|
254
|
-
)}
|
|
255
|
-
</div>
|
|
256
|
-
)}
|
|
257
|
-
</RecordScope>
|
|
258
|
-
) : (
|
|
259
|
-
<div className="flex-1 flex items-center justify-center">
|
|
260
|
-
<div className="text-center text-muted-foreground">
|
|
261
|
-
<div className="w-16 h-16 rounded-2xl bg-muted/60 flex items-center justify-center mx-auto mb-4">
|
|
262
|
-
<svg className="w-8 h-8 text-muted-foreground/50" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
263
|
-
<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" />
|
|
264
|
-
</svg>
|
|
265
|
-
</div>
|
|
266
|
-
<p className="text-base font-medium mb-1">Select a conversation</p>
|
|
267
|
-
<p className="text-sm">Choose a group or message from the sidebar</p>
|
|
268
|
-
</div>
|
|
269
|
-
</div>
|
|
270
|
-
)}
|
|
271
|
-
</div>
|
|
272
|
-
</div>
|
|
273
|
-
)
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Auto-joins the current user to a DM channel and renders children immediately.
|
|
278
|
-
* DMs don't require an explicit join gate -- if you can see the DM, you belong in it.
|
|
279
|
-
*/
|
|
280
|
-
function DmAutoJoin({
|
|
281
|
-
join,
|
|
282
|
-
channelId,
|
|
283
|
-
children,
|
|
284
|
-
}: {
|
|
285
|
-
join: () => void
|
|
286
|
-
channelId: string
|
|
287
|
-
children: ReactNode
|
|
288
|
-
}) {
|
|
289
|
-
const joined = useRef<string | null>(null)
|
|
290
|
-
|
|
291
|
-
useEffect(() => {
|
|
292
|
-
if (joined.current === channelId) return
|
|
293
|
-
joined.current = channelId
|
|
294
|
-
join()
|
|
295
|
-
}, [channelId, join])
|
|
296
|
-
|
|
297
|
-
return <>{children}</>
|
|
298
|
-
}
|
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AddMemberModal -- Invite users to a private group.
|
|
3
|
-
* Creates invitation records (status: 'pending') that the invitee
|
|
4
|
-
* must accept before becoming a member. Filters out current members
|
|
5
|
-
* and users who already have a pending invitation.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { useState, useCallback, useEffect, useMemo } from 'react'
|
|
9
|
-
import { useUsers } from 'deepspace'
|
|
10
|
-
import { useUser } from 'deepspace'
|
|
11
|
-
import { useChannelMembers } from 'deepspace'
|
|
12
|
-
import { useMutations } from 'deepspace'
|
|
13
|
-
import { useQuery } from 'deepspace'
|
|
14
|
-
import type { ChannelInvitation } from 'deepspace'
|
|
15
|
-
import type { RecordData, RoomUser } from 'deepspace'
|
|
16
|
-
|
|
17
|
-
interface AddMemberModalProps {
|
|
18
|
-
channelId: string
|
|
19
|
-
onClose: () => void
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function AddMemberModal({ channelId, onClose }: AddMemberModalProps) {
|
|
23
|
-
const { users } = useUsers()
|
|
24
|
-
const { user: currentUser } = useUser()
|
|
25
|
-
const { members } = useChannelMembers(channelId)
|
|
26
|
-
const { create: createInvitation, put: updateInvitation } = useMutations<ChannelInvitation>('channel-invitations')
|
|
27
|
-
const { records: allChannelInvitations } = useQuery<ChannelInvitation>('channel-invitations', {
|
|
28
|
-
where: { channelId },
|
|
29
|
-
})
|
|
30
|
-
const [search, setSearch] = useState('')
|
|
31
|
-
const [invitingUserId, setInvitingUserId] = useState<string | null>(null)
|
|
32
|
-
const [justInvitedIds, setJustInvitedIds] = useState<Set<string>>(new Set())
|
|
33
|
-
|
|
34
|
-
useEffect(() => {
|
|
35
|
-
function handleEscape(e: KeyboardEvent) {
|
|
36
|
-
if (e.key === 'Escape') onClose()
|
|
37
|
-
}
|
|
38
|
-
document.addEventListener('keydown', handleEscape)
|
|
39
|
-
return () => document.removeEventListener('keydown', handleEscape)
|
|
40
|
-
}, [onClose])
|
|
41
|
-
|
|
42
|
-
const memberIds = useMemo(
|
|
43
|
-
() => new Set(members.map((m) => m.data.userId)),
|
|
44
|
-
[members]
|
|
45
|
-
)
|
|
46
|
-
|
|
47
|
-
const pendingInviteUserIds = useMemo(
|
|
48
|
-
() => new Set(
|
|
49
|
-
allChannelInvitations
|
|
50
|
-
.filter((inv: RecordData<ChannelInvitation>) => inv.data.status === 'pending')
|
|
51
|
-
.map((inv: RecordData<ChannelInvitation>) => inv.data.invitedUserId)
|
|
52
|
-
),
|
|
53
|
-
[allChannelInvitations]
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
const availableUsers = useMemo(() => {
|
|
57
|
-
const lowerSearch = search.toLowerCase()
|
|
58
|
-
return users.filter(
|
|
59
|
-
(u: RoomUser) =>
|
|
60
|
-
u.id !== currentUser?.id &&
|
|
61
|
-
!memberIds.has(u.id) &&
|
|
62
|
-
!pendingInviteUserIds.has(u.id) &&
|
|
63
|
-
!justInvitedIds.has(u.id) &&
|
|
64
|
-
(lowerSearch === '' ||
|
|
65
|
-
u.name?.toLowerCase().includes(lowerSearch) ||
|
|
66
|
-
u.email?.toLowerCase().includes(lowerSearch))
|
|
67
|
-
)
|
|
68
|
-
}, [users, currentUser, memberIds, pendingInviteUserIds, justInvitedIds, search])
|
|
69
|
-
|
|
70
|
-
const handleInvite = useCallback(
|
|
71
|
-
async (userId: string) => {
|
|
72
|
-
if (invitingUserId) return
|
|
73
|
-
setInvitingUserId(userId)
|
|
74
|
-
try {
|
|
75
|
-
const existingDeclined = allChannelInvitations.find(
|
|
76
|
-
(inv: RecordData<ChannelInvitation>) =>
|
|
77
|
-
inv.data.invitedUserId === userId && inv.data.status === 'declined'
|
|
78
|
-
)
|
|
79
|
-
if (existingDeclined) {
|
|
80
|
-
await updateInvitation(existingDeclined.recordId, {
|
|
81
|
-
...existingDeclined.data,
|
|
82
|
-
status: 'pending',
|
|
83
|
-
} as unknown as ChannelInvitation)
|
|
84
|
-
} else {
|
|
85
|
-
await createInvitation({
|
|
86
|
-
channelId,
|
|
87
|
-
invitedUserId: userId,
|
|
88
|
-
status: 'pending',
|
|
89
|
-
} as unknown as ChannelInvitation)
|
|
90
|
-
}
|
|
91
|
-
setJustInvitedIds((prev) => new Set(prev).add(userId))
|
|
92
|
-
} finally {
|
|
93
|
-
setInvitingUserId(null)
|
|
94
|
-
}
|
|
95
|
-
},
|
|
96
|
-
[invitingUserId, createInvitation, updateInvitation, channelId, allChannelInvitations]
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
return (
|
|
100
|
-
<>
|
|
101
|
-
<div className="fixed inset-0 bg-black/40 z-50 animate-in fade-in duration-150" onClick={onClose} />
|
|
102
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
|
103
|
-
<div
|
|
104
|
-
data-testid="add-member-modal"
|
|
105
|
-
className="bg-card border border-border rounded-2xl shadow-2xl w-full max-w-md animate-in fade-in zoom-in-95 duration-200"
|
|
106
|
-
onClick={(e) => e.stopPropagation()}
|
|
107
|
-
>
|
|
108
|
-
<div className="px-6 pt-6 pb-0">
|
|
109
|
-
<h2 className="text-lg font-semibold text-foreground">Invite Members</h2>
|
|
110
|
-
<p className="text-sm text-muted-foreground mt-1">
|
|
111
|
-
Send an invitation to join this group. They'll need to accept before joining.
|
|
112
|
-
</p>
|
|
113
|
-
</div>
|
|
114
|
-
|
|
115
|
-
<div className="px-6 pt-4 pb-2">
|
|
116
|
-
<div className="relative">
|
|
117
|
-
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
118
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
119
|
-
</svg>
|
|
120
|
-
<input
|
|
121
|
-
data-testid="add-member-search"
|
|
122
|
-
type="text"
|
|
123
|
-
value={search}
|
|
124
|
-
onChange={(e) => setSearch(e.target.value)}
|
|
125
|
-
placeholder="Search by name or email..."
|
|
126
|
-
className="w-full bg-background border border-border rounded-lg pl-9 pr-3.5 py-2.5 text-sm text-foreground placeholder:text-muted-foreground outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20 transition-shadow"
|
|
127
|
-
autoFocus
|
|
128
|
-
/>
|
|
129
|
-
</div>
|
|
130
|
-
</div>
|
|
131
|
-
|
|
132
|
-
<div className="px-3 pb-3 max-h-64 overflow-y-auto">
|
|
133
|
-
<p className="text-xs text-muted-foreground px-3 pb-2">
|
|
134
|
-
Only users who have visited the app are shown.
|
|
135
|
-
</p>
|
|
136
|
-
{availableUsers.length === 0 ? (
|
|
137
|
-
<p className="text-sm text-muted-foreground text-center py-8">
|
|
138
|
-
{search
|
|
139
|
-
? 'No matching users found'
|
|
140
|
-
: 'No users available to invite'}
|
|
141
|
-
</p>
|
|
142
|
-
) : (
|
|
143
|
-
<div className="py-1 space-y-0.5">
|
|
144
|
-
{availableUsers.map((u: RoomUser) => (
|
|
145
|
-
<div
|
|
146
|
-
key={u.id}
|
|
147
|
-
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-muted/40 transition-colors"
|
|
148
|
-
>
|
|
149
|
-
{u.imageUrl ? (
|
|
150
|
-
<img src={u.imageUrl} alt="" className="w-9 h-9 rounded-full object-cover" />
|
|
151
|
-
) : (
|
|
152
|
-
<div className="w-9 h-9 rounded-full bg-primary/15 flex items-center justify-center text-sm font-semibold text-primary">
|
|
153
|
-
{u.name?.[0]?.toUpperCase() ?? '?'}
|
|
154
|
-
</div>
|
|
155
|
-
)}
|
|
156
|
-
<div className="flex-1 min-w-0">
|
|
157
|
-
<div className="text-sm font-medium text-foreground truncate">{u.name}</div>
|
|
158
|
-
{u.email && (
|
|
159
|
-
<div className="text-xs text-muted-foreground truncate">{u.email}</div>
|
|
160
|
-
)}
|
|
161
|
-
</div>
|
|
162
|
-
<button
|
|
163
|
-
data-testid={`invite-member-${u.id}`}
|
|
164
|
-
onClick={() => handleInvite(u.id)}
|
|
165
|
-
disabled={invitingUserId === u.id}
|
|
166
|
-
className="shrink-0 px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-40 transition-colors"
|
|
167
|
-
>
|
|
168
|
-
{invitingUserId === u.id ? 'Inviting...' : 'Invite'}
|
|
169
|
-
</button>
|
|
170
|
-
</div>
|
|
171
|
-
))}
|
|
172
|
-
</div>
|
|
173
|
-
)}
|
|
174
|
-
</div>
|
|
175
|
-
|
|
176
|
-
<div className="px-6 pb-6 pt-2 border-t border-border">
|
|
177
|
-
<button
|
|
178
|
-
onClick={onClose}
|
|
179
|
-
className="w-full px-4 py-2 text-sm text-muted-foreground hover:text-foreground rounded-lg border border-border hover:bg-muted transition-colors"
|
|
180
|
-
>
|
|
181
|
-
Done
|
|
182
|
-
</button>
|
|
183
|
-
</div>
|
|
184
|
-
</div>
|
|
185
|
-
</div>
|
|
186
|
-
</>
|
|
187
|
-
)
|
|
188
|
-
}
|
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* BrowseGroupsModal -- Discover and join public groups.
|
|
3
|
-
* Shows a searchable list of public channels with join/open actions.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { useState, useEffect } from 'react'
|
|
7
|
-
import { useChannels } from 'deepspace'
|
|
8
|
-
import { useChannelMembers } from 'deepspace'
|
|
9
|
-
import type { Channel } from 'deepspace'
|
|
10
|
-
import type { RecordData } from 'deepspace'
|
|
11
|
-
|
|
12
|
-
interface BrowseGroupsModalProps {
|
|
13
|
-
onClose: () => void
|
|
14
|
-
onSelected: (channelId: string) => void
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function BrowseGroupsModal({ onClose, onSelected }: BrowseGroupsModalProps) {
|
|
18
|
-
const { channels } = useChannels()
|
|
19
|
-
const [search, setSearch] = useState('')
|
|
20
|
-
|
|
21
|
-
useEffect(() => {
|
|
22
|
-
function handleEscape(e: KeyboardEvent) {
|
|
23
|
-
if (e.key === 'Escape') onClose()
|
|
24
|
-
}
|
|
25
|
-
document.addEventListener('keydown', handleEscape)
|
|
26
|
-
return () => document.removeEventListener('keydown', handleEscape)
|
|
27
|
-
}, [onClose])
|
|
28
|
-
|
|
29
|
-
const publicGroups = channels.filter(
|
|
30
|
-
(c: RecordData<Channel>) =>
|
|
31
|
-
c.data.type === 'public' &&
|
|
32
|
-
!c.data.archived &&
|
|
33
|
-
c.data.name.toLowerCase().includes(search.toLowerCase())
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
return (
|
|
37
|
-
<>
|
|
38
|
-
<div className="fixed inset-0 bg-black/40 z-50 animate-in fade-in duration-150" onClick={onClose} />
|
|
39
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
|
40
|
-
<div
|
|
41
|
-
data-testid="browse-groups-modal"
|
|
42
|
-
className="bg-card border border-border rounded-2xl shadow-2xl w-full max-w-lg animate-in fade-in zoom-in-95 duration-200"
|
|
43
|
-
onClick={(e) => e.stopPropagation()}
|
|
44
|
-
>
|
|
45
|
-
<div className="px-6 pt-6 pb-0">
|
|
46
|
-
<h2 className="text-lg font-semibold text-foreground">Browse Groups</h2>
|
|
47
|
-
<p className="text-sm text-muted-foreground mt-1">
|
|
48
|
-
Discover public groups and join the conversation.
|
|
49
|
-
</p>
|
|
50
|
-
</div>
|
|
51
|
-
|
|
52
|
-
<div className="px-6 pt-4 pb-2">
|
|
53
|
-
<div className="relative">
|
|
54
|
-
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
55
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
56
|
-
</svg>
|
|
57
|
-
<input
|
|
58
|
-
data-testid="browse-search-input"
|
|
59
|
-
type="text"
|
|
60
|
-
value={search}
|
|
61
|
-
onChange={(e) => setSearch(e.target.value)}
|
|
62
|
-
placeholder="Search groups..."
|
|
63
|
-
className="w-full bg-background border border-border rounded-lg pl-9 pr-3.5 py-2.5 text-sm text-foreground placeholder:text-muted-foreground outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20 transition-shadow"
|
|
64
|
-
autoFocus
|
|
65
|
-
/>
|
|
66
|
-
</div>
|
|
67
|
-
</div>
|
|
68
|
-
|
|
69
|
-
<div className="px-3 pb-3 max-h-80 overflow-y-auto">
|
|
70
|
-
{publicGroups.length === 0 ? (
|
|
71
|
-
<p className="text-sm text-muted-foreground text-center py-8">
|
|
72
|
-
{search ? 'No groups found' : 'No public groups yet'}
|
|
73
|
-
</p>
|
|
74
|
-
) : (
|
|
75
|
-
<div className="py-1 space-y-1">
|
|
76
|
-
{publicGroups.map((ch: RecordData<Channel>) => (
|
|
77
|
-
<BrowseGroupRow
|
|
78
|
-
key={ch.recordId}
|
|
79
|
-
channel={ch}
|
|
80
|
-
onSelect={() => onSelected(ch.recordId)}
|
|
81
|
-
/>
|
|
82
|
-
))}
|
|
83
|
-
</div>
|
|
84
|
-
)}
|
|
85
|
-
</div>
|
|
86
|
-
|
|
87
|
-
<div className="px-6 pb-6 pt-2 border-t border-border">
|
|
88
|
-
<button
|
|
89
|
-
onClick={onClose}
|
|
90
|
-
className="w-full px-4 py-2 text-sm text-muted-foreground hover:text-foreground rounded-lg border border-border hover:bg-muted transition-colors"
|
|
91
|
-
>
|
|
92
|
-
Close
|
|
93
|
-
</button>
|
|
94
|
-
</div>
|
|
95
|
-
</div>
|
|
96
|
-
</div>
|
|
97
|
-
</>
|
|
98
|
-
)
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function BrowseGroupRow({
|
|
102
|
-
channel,
|
|
103
|
-
onSelect,
|
|
104
|
-
}: {
|
|
105
|
-
channel: RecordData<Channel>
|
|
106
|
-
onSelect: () => void
|
|
107
|
-
}) {
|
|
108
|
-
const { members, isMember, join, status } = useChannelMembers(channel.recordId)
|
|
109
|
-
const [joining, setJoining] = useState(false)
|
|
110
|
-
|
|
111
|
-
const handleJoin = async () => {
|
|
112
|
-
setJoining(true)
|
|
113
|
-
try {
|
|
114
|
-
await join()
|
|
115
|
-
onSelect()
|
|
116
|
-
} finally {
|
|
117
|
-
setJoining(false)
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const loading = status !== 'ready'
|
|
122
|
-
|
|
123
|
-
return (
|
|
124
|
-
<div
|
|
125
|
-
data-testid={`browse-group-${channel.data.name}`}
|
|
126
|
-
className="flex items-center gap-3 px-3 py-3 rounded-lg hover:bg-muted/40 transition-colors"
|
|
127
|
-
>
|
|
128
|
-
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
|
129
|
-
<svg className="w-4 h-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
130
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
131
|
-
</svg>
|
|
132
|
-
</div>
|
|
133
|
-
<div className="flex-1 min-w-0">
|
|
134
|
-
<div className="text-sm font-medium text-foreground truncate">{channel.data.name}</div>
|
|
135
|
-
{channel.data.description && (
|
|
136
|
-
<div className="text-xs text-muted-foreground truncate mt-0.5">{channel.data.description}</div>
|
|
137
|
-
)}
|
|
138
|
-
<div className="text-xs text-muted-foreground mt-0.5">
|
|
139
|
-
{members.length} {members.length === 1 ? 'member' : 'members'}
|
|
140
|
-
</div>
|
|
141
|
-
</div>
|
|
142
|
-
{loading ? (
|
|
143
|
-
<span className="shrink-0 px-3 py-1.5 text-xs text-muted-foreground">...</span>
|
|
144
|
-
) : isMember ? (
|
|
145
|
-
<button
|
|
146
|
-
onClick={onSelect}
|
|
147
|
-
className="shrink-0 px-3 py-1.5 text-xs font-medium bg-muted text-foreground rounded-lg border border-border hover:bg-muted/80 transition-colors"
|
|
148
|
-
>
|
|
149
|
-
Open
|
|
150
|
-
</button>
|
|
151
|
-
) : (
|
|
152
|
-
<button
|
|
153
|
-
data-testid={`join-group-${channel.data.name}`}
|
|
154
|
-
onClick={handleJoin}
|
|
155
|
-
disabled={joining}
|
|
156
|
-
className="shrink-0 px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-40 transition-colors"
|
|
157
|
-
>
|
|
158
|
-
{joining ? 'Joining...' : 'Join'}
|
|
159
|
-
</button>
|
|
160
|
-
)}
|
|
161
|
-
</div>
|
|
162
|
-
)
|
|
163
|
-
}
|