deepspace 0.3.9 → 0.3.11
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/dist/cli.js +96 -18
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +160 -1
- package/dist/index.js +283 -126
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +172 -2
- package/dist/server.js +581 -1
- package/dist/server.js.map +1 -1
- package/dist/worker.d.ts +252 -2
- package/dist/worker.js +588 -1
- package/dist/worker.js.map +1 -1
- package/features/admin-page/feature.json +29 -0
- package/features/admin-page/src/AdminPage.tsx +292 -0
- package/features/admin-page/src/admin-schema.ts +20 -0
- package/features/ai-chat/feature.json +41 -0
- package/features/ai-chat/src/AiChatPage.tsx +692 -0
- package/features/ai-chat/src/ChatPanel.tsx +1098 -0
- package/features/ai-chat/src/ai-chat-schema.ts +11 -0
- package/features/canvas/feature.json +45 -0
- package/features/canvas/src/CanvasEditorPage.tsx +87 -0
- package/features/canvas/src/CanvasListPage.tsx +202 -0
- package/features/canvas/src/canvas-schema.ts +33 -0
- package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
- package/features/canvas/src/components/CanvasView.tsx +369 -0
- package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
- package/features/cron/feature.json +33 -0
- package/features/cron/src/CronLogPage.tsx +100 -0
- package/features/cron/src/cron.ts +25 -0
- package/features/docs/feature.json +96 -0
- package/features/docs/src/DocsEditorPage.tsx +1012 -0
- package/features/docs/src/DocsListPage.tsx +951 -0
- package/features/docs/src/DocsOutlinePanel.tsx +68 -0
- package/features/docs/src/DocsPresence.tsx +138 -0
- package/features/docs/src/InviteDialog.tsx +336 -0
- package/features/docs/src/LibrarySidebar.tsx +464 -0
- package/features/docs/src/docs-favorites.ts +18 -0
- package/features/docs/src/docs-library-types.ts +15 -0
- package/features/docs/src/docs-schema.ts +80 -0
- package/features/docs/src/docs-ui.css +309 -0
- package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
- package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
- package/features/docs/src/editor/useDocEditor.ts +206 -0
- package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
- package/features/file-attachments/feature.json +42 -0
- package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
- package/features/file-attachments/src/attachments-constants.ts +9 -0
- package/features/file-attachments/src/attachments-schema.ts +45 -0
- package/features/file-attachments/src/useFilePreview.ts +471 -0
- package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
- package/features/file-manager/feature.json +24 -0
- package/features/file-manager/src/FileManagerPage.tsx +330 -0
- package/features/integration-test/feature.json +18 -0
- package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
- package/features/integration-test/src/integration.spec.ts +42 -0
- package/features/items/feature.json +33 -0
- package/features/items/src/ItemsPage.tsx +325 -0
- package/features/items/src/items-constants.ts +10 -0
- package/features/items/src/items-schema.ts +27 -0
- package/features/kanban/feature.json +18 -0
- package/features/kanban/src/Kanban.tsx +283 -0
- package/features/landing/feature.json +62 -0
- package/features/landing/src/LandingPage.tsx +627 -0
- package/features/landing/src/primitives.tsx +389 -0
- package/features/landing/src/sections/FAQSection.tsx +110 -0
- package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
- package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
- package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
- package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
- package/features/landing/src/sections/StatsSection.tsx +31 -0
- package/features/landing/src/sections/TeamSection.tsx +86 -0
- package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
- package/features/landing/src/sections/VideoSection.tsx +71 -0
- package/features/leaderboard/feature.json +33 -0
- package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
- package/features/leaderboard/src/leaderboard-constants.ts +21 -0
- package/features/leaderboard/src/leaderboard-schema.ts +36 -0
- package/features/messaging/feature.json +111 -0
- package/features/messaging/src/ChatMultiPage.tsx +300 -0
- package/features/messaging/src/ChatPage.tsx +75 -0
- package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
- package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
- package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
- package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
- package/features/messaging/src/components/chat/MessageList.tsx +248 -0
- package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
- package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
- package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
- package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
- package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
- package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
- package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
- package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
- package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
- package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
- package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
- package/features/messaging/src/hooks/useChatChannel.ts +64 -0
- package/features/messaging/src/hooks/useLongPress.ts +74 -0
- package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
- package/features/messaging/src/messaging-schema.ts +25 -0
- package/features/presence-test/feature.json +21 -0
- package/features/presence-test/src/PresenceTestPage.tsx +120 -0
- package/features/sidebar/feature.json +19 -0
- package/features/sidebar/src/AppSidebar.tsx +558 -0
- package/features/tasks/feature.json +33 -0
- package/features/tasks/src/TasksPage.tsx +598 -0
- package/features/tasks/src/tasks-constants.ts +43 -0
- package/features/tasks/src/tasks-schema.ts +56 -0
- package/features/testing/feature.json +49 -0
- package/features/testing/src/TestPage.tsx +145 -0
- package/features/testing/src/cpu-burn-action.ts +51 -0
- package/features/testing/src/subrequest-burn-action.ts +53 -0
- package/features/testing/src/test-schema.ts +23 -0
- package/features/topbar/feature.json +18 -0
- package/features/topbar/src/Topbar.tsx +353 -0
- package/features/tree/feature.json +26 -0
- package/features/tree/src/TreeSidebar.tsx +607 -0
- package/features/tree/src/tree-constants.ts +68 -0
- package/features/tree/src/tree-hooks.ts +258 -0
- package/package.json +4 -2
- 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
|
+
}
|