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,107 @@
1
+ /**
2
+ * ThreadPanel -- Side panel for threaded replies on a parent message.
3
+ * Renders the parent message preview, a scrollable reply list, and a composer.
4
+ */
5
+
6
+ import { useEffect, useRef } from 'react'
7
+ import { useMessages } from 'deepspace'
8
+ import { useReactions } from 'deepspace'
9
+ import { useUserLookup } from 'deepspace'
10
+ import type { Message } from 'deepspace'
11
+ import type { RecordData } from 'deepspace'
12
+ import { MessageItem } from './MessageItem'
13
+ import { MessageInput } from './MessageInput'
14
+
15
+ interface ThreadPanelProps {
16
+ channelId: string
17
+ parentMessageId: string
18
+ onClose: () => void
19
+ }
20
+
21
+ export function ThreadPanel({ channelId, parentMessageId, onClose }: ThreadPanelProps) {
22
+ const { messages: allMessages } = useMessages(channelId)
23
+ const { messages: replies, send, edit, remove } = useMessages(channelId, {
24
+ parentMessageId,
25
+ })
26
+ const { getReactionsForMessage, toggle: toggleReaction } = useReactions(channelId)
27
+ const { getUser } = useUserLookup()
28
+ const bottomRef = useRef<HTMLDivElement>(null)
29
+
30
+ const parentMessage = allMessages.find(
31
+ (m: RecordData<Message>) => m.recordId === parentMessageId
32
+ )
33
+
34
+ useEffect(() => {
35
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
36
+ }, [replies.length])
37
+
38
+ const parentAuthor = parentMessage
39
+ ? getUser(parentMessage.data.authorId || parentMessage.createdBy)
40
+ : null
41
+
42
+ const parentTime = parentMessage
43
+ ? new Date(parentMessage.createdAt).toLocaleTimeString([], {
44
+ hour: 'numeric',
45
+ minute: '2-digit',
46
+ })
47
+ : ''
48
+
49
+ return (
50
+ <div
51
+ data-testid="thread-panel"
52
+ className="w-full md:w-80 md:border-l border-border flex flex-col h-full bg-card md:shrink-0"
53
+ >
54
+ <div className="shrink-0 h-12 border-b border-border px-4 flex items-center justify-between">
55
+ <h3 className="font-semibold text-sm text-foreground">Thread</h3>
56
+ <button
57
+ data-testid="close-thread-btn"
58
+ onClick={onClose}
59
+ className="p-1 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
60
+ >
61
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
62
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
63
+ </svg>
64
+ </button>
65
+ </div>
66
+
67
+ {parentMessage && (
68
+ <div className="shrink-0 px-4 py-3 border-b border-border bg-muted/30">
69
+ <div className="flex items-baseline gap-2 mb-1">
70
+ <span className="font-semibold text-sm text-foreground">
71
+ {parentAuthor?.name ?? 'Unknown'}
72
+ </span>
73
+ <span className="text-xs text-muted-foreground">{parentTime}</span>
74
+ </div>
75
+ <p className="text-sm text-foreground/90 whitespace-pre-wrap break-words" data-testid="thread-parent-content">
76
+ {parentMessage.data.content}
77
+ </p>
78
+ <div className="mt-1.5 text-xs text-muted-foreground">
79
+ {replies.length} {replies.length === 1 ? 'reply' : 'replies'}
80
+ </div>
81
+ </div>
82
+ )}
83
+
84
+ <div className="flex-1 overflow-y-auto" data-testid="thread-replies">
85
+ {replies.map((msg: RecordData<Message>) => (
86
+ <MessageItem
87
+ key={msg.recordId}
88
+ message={msg}
89
+ reactions={getReactionsForMessage(msg.recordId)}
90
+ threadCount={0}
91
+ onToggleReaction={(emoji) => toggleReaction(msg.recordId, emoji)}
92
+ onOpenThread={() => {}}
93
+ onEdit={(newContent) => edit(msg.recordId, newContent)}
94
+ onDelete={() => remove(msg.recordId)}
95
+ isFirstInGroup
96
+ />
97
+ ))}
98
+ <div ref={bottomRef} />
99
+ </div>
100
+
101
+ <MessageInput
102
+ onSend={(content) => send(content, parentMessageId)}
103
+ placeholder="Reply..."
104
+ />
105
+ </div>
106
+ )
107
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * UserProfilePopover -- Click avatar/name to view a lightweight profile card.
3
+ * Positioned below the trigger by default, flips above if insufficient space.
4
+ */
5
+
6
+ import { useState, useRef, useEffect, useCallback, type ReactNode } from 'react'
7
+ import { useUserLookup } from 'deepspace'
8
+ import { useUser } from 'deepspace'
9
+ import { usePresence } from 'deepspace'
10
+
11
+ interface UserProfilePopoverProps {
12
+ userId: string
13
+ children: ReactNode
14
+ onStartDM?: (userId: string) => void
15
+ }
16
+
17
+ function formatLastSeen(lastSeenAt: string | undefined): string {
18
+ if (!lastSeenAt) return 'Never seen'
19
+ const diff = Date.now() - new Date(lastSeenAt).getTime()
20
+ const minutes = Math.floor(diff / 60000)
21
+ if (minutes < 1) return 'Just now'
22
+ if (minutes < 60) return `${minutes}m ago`
23
+ const hours = Math.floor(minutes / 60)
24
+ if (hours < 24) return `${hours}h ago`
25
+ const days = Math.floor(hours / 24)
26
+ return `${days}d ago`
27
+ }
28
+
29
+ const POPOVER_HEIGHT_ESTIMATE = 180
30
+
31
+ export function UserProfilePopover({ userId, children, onStartDM }: UserProfilePopoverProps) {
32
+ const { getUser } = useUserLookup()
33
+ const { user: currentUser } = useUser()
34
+ const { isOnline, users } = usePresence()
35
+ const [open, setOpen] = useState(false)
36
+ const [placement, setPlacement] = useState<'below' | 'above'>('below')
37
+ const triggerRef = useRef<HTMLButtonElement>(null)
38
+ const popoverRef = useRef<HTMLDivElement>(null)
39
+
40
+ const user = getUser(userId)
41
+ const roomUser = users.find((u) => u.id === userId)
42
+ const online = isOnline(userId)
43
+
44
+ const close = useCallback(() => setOpen(false), [])
45
+
46
+ const handleToggle = useCallback(() => {
47
+ setOpen((prev) => {
48
+ if (!prev && triggerRef.current) {
49
+ const rect = triggerRef.current.getBoundingClientRect()
50
+ const spaceBelow = window.innerHeight - rect.bottom
51
+ const inBottomHalf = rect.top > window.innerHeight / 2
52
+ setPlacement(
53
+ spaceBelow < POPOVER_HEIGHT_ESTIMATE || inBottomHalf ? 'above' : 'below'
54
+ )
55
+ }
56
+ return !prev
57
+ })
58
+ }, [])
59
+
60
+ useEffect(() => {
61
+ if (!open) return
62
+ function handleClickOutside(e: MouseEvent) {
63
+ if (
64
+ popoverRef.current && !popoverRef.current.contains(e.target as Node) &&
65
+ triggerRef.current && !triggerRef.current.contains(e.target as Node)
66
+ ) {
67
+ close()
68
+ }
69
+ }
70
+ function handleEscape(e: KeyboardEvent) {
71
+ if (e.key === 'Escape') close()
72
+ }
73
+ const timer = setTimeout(() => {
74
+ document.addEventListener('mousedown', handleClickOutside)
75
+ document.addEventListener('keydown', handleEscape)
76
+ }, 0)
77
+ return () => {
78
+ clearTimeout(timer)
79
+ document.removeEventListener('mousedown', handleClickOutside)
80
+ document.removeEventListener('keydown', handleEscape)
81
+ }
82
+ }, [open, close])
83
+
84
+ const positionClass = placement === 'above'
85
+ ? 'bottom-full mb-2 left-0'
86
+ : 'top-full mt-2 left-0'
87
+
88
+ return (
89
+ <span className="relative inline-flex">
90
+ <button
91
+ ref={triggerRef}
92
+ type="button"
93
+ onClick={handleToggle}
94
+ className="cursor-pointer text-left"
95
+ >
96
+ {children}
97
+ </button>
98
+
99
+ {open && user && (
100
+ <div
101
+ ref={popoverRef}
102
+ data-testid="user-profile-popover"
103
+ className={`absolute ${positionClass} z-50 w-60 bg-card border border-border rounded-lg shadow-lg p-4 animate-in fade-in zoom-in-95 duration-150`}
104
+ >
105
+ <div className="flex items-start gap-3">
106
+ <div className="relative shrink-0">
107
+ {user.imageUrl ? (
108
+ <img src={user.imageUrl} alt="" className="w-12 h-12 rounded-full" />
109
+ ) : (
110
+ <div className="w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center text-base font-semibold text-primary">
111
+ {(user.name ?? '?')[0]?.toUpperCase()}
112
+ </div>
113
+ )}
114
+ <span
115
+ className={`absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-card ${
116
+ online ? 'bg-success' : 'bg-muted-foreground/30'
117
+ }`}
118
+ />
119
+ </div>
120
+ <div className="flex-1 min-w-0">
121
+ <p className="text-sm font-semibold text-foreground truncate">{user.name ?? 'Unknown'}</p>
122
+ {roomUser?.role && (
123
+ <span className="inline-block mt-0.5 px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground">
124
+ {roomUser.role}
125
+ </span>
126
+ )}
127
+ </div>
128
+ </div>
129
+
130
+ <div className="mt-3 pt-3 border-t border-border space-y-1.5">
131
+ {roomUser?.email && (
132
+ <p className="text-xs text-muted-foreground truncate">{roomUser.email}</p>
133
+ )}
134
+ <p className="text-xs text-muted-foreground">
135
+ {online ? 'Online' : `Last seen ${formatLastSeen(roomUser?.lastSeenAt)}`}
136
+ </p>
137
+ </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
+ </div>
155
+ )}
156
+ </span>
157
+ )
158
+ }
@@ -0,0 +1,188 @@
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
+ }
@@ -0,0 +1,163 @@
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
+ }