deepspace 0.26.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 (52) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/{chunk-LPXWXF6R.js → chunk-YBXIUDZJ.js} +2 -2
  3. package/dist/{chunk-LPXWXF6R.js.map → chunk-YBXIUDZJ.js.map} +1 -1
  4. package/dist/cli.js +80 -45
  5. package/dist/cli.js.map +1 -1
  6. package/dist/documentation-client-core.js +1 -1
  7. package/dist/documentation-default-renderer.cjs +1 -1
  8. package/dist/documentation-react.js +1 -1
  9. package/dist/documentation-runtime.js +1 -1
  10. package/dist/documentation-server-core.js +1 -1
  11. package/dist/documentation.js +1 -1
  12. package/dist/documentation.js.map +1 -1
  13. package/dist/index.d.ts +26 -434
  14. package/dist/index.js +99 -864
  15. package/dist/index.js.map +1 -1
  16. package/dist/schema.d.ts +4 -41
  17. package/dist/schema.js +2 -242
  18. package/dist/schema.js.map +1 -1
  19. package/dist/server.d.ts +6 -162
  20. package/dist/server.js +13 -281
  21. package/dist/server.js.map +1 -1
  22. package/dist/worker.d.ts +4 -160
  23. package/dist/worker.js +15 -281
  24. package/dist/worker.js.map +1 -1
  25. package/features/messaging/feature.json +3 -43
  26. package/features/messaging/src/ChatPage.tsx +12 -16
  27. package/features/messaging/src/components/chat/ChatHeader.tsx +5 -13
  28. package/features/messaging/src/components/chat/MessageItem.tsx +7 -9
  29. package/features/messaging/src/components/chat/MessageList.tsx +57 -40
  30. package/features/messaging/src/components/chat/ThreadPanel.tsx +25 -11
  31. package/features/messaging/src/components/chat/UserProfilePopover.tsx +10 -29
  32. package/features/messaging/src/hooks/useChatChannel.ts +18 -23
  33. package/features/messaging/src/messaging-schema.ts +0 -2
  34. package/features/search-bar/feature.json +9 -20
  35. package/features/search-bar/src/InlineSearch.tsx +101 -0
  36. package/features/search-bar/src/__tests__/search-model.test.tsx +38 -0
  37. package/features/search-bar/src/search-model.ts +2 -149
  38. package/package.json +1 -1
  39. package/features/messaging/src/ChatMultiPage.tsx +0 -298
  40. package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +0 -188
  41. package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +0 -163
  42. package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +0 -147
  43. package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +0 -344
  44. package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +0 -460
  45. package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +0 -190
  46. package/features/messaging/src/components/chat-multi/InvitationList.tsx +0 -149
  47. package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +0 -159
  48. package/features/messaging/src/hooks/useMultiChannel.ts +0 -142
  49. package/features/search-bar/src/SearchOverlay.tsx +0 -566
  50. package/features/search-bar/src/__tests__/search-model.test.ts +0 -40
  51. package/features/sidebar/feature.json +0 -19
  52. 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
- }