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,149 @@
1
+ /**
2
+ * InvitationList -- Inline list of pending group invitations.
3
+ * Shown in the sidebar when the user has pending invitations.
4
+ * Accept joins the channel + updates invitation status.
5
+ * Decline updates the invitation status to 'declined'.
6
+ */
7
+
8
+ import { useState, useCallback } from 'react'
9
+ import { useChannelMembers } from 'deepspace'
10
+ import { useMutations } from 'deepspace'
11
+ import { useUserLookup } from 'deepspace'
12
+ import type { Channel, ChannelInvitation } from 'deepspace'
13
+ import type { RecordData } from 'deepspace'
14
+
15
+ interface PendingInvitation {
16
+ invitation: RecordData<ChannelInvitation>
17
+ channel: RecordData<Channel> | undefined
18
+ }
19
+
20
+ interface InvitationListProps {
21
+ invitations: PendingInvitation[]
22
+ onAccepted: (channelId: string) => void
23
+ }
24
+
25
+ export function InvitationList({ invitations, onAccepted }: InvitationListProps) {
26
+ if (invitations.length === 0) return null
27
+
28
+ return (
29
+ <div className="mb-2">
30
+ <div className="flex items-center gap-1.5 px-2 py-2">
31
+ <svg
32
+ className="w-3.5 h-3.5 text-warning"
33
+ fill="none"
34
+ viewBox="0 0 24 24"
35
+ stroke="currentColor"
36
+ strokeWidth={2}
37
+ >
38
+ <path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
39
+ </svg>
40
+ <span className="text-[11px] font-bold text-muted-foreground/70 uppercase tracking-widest">
41
+ Invitations
42
+ </span>
43
+ <span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-warning/15 text-warning text-[10px] font-bold tabular-nums">
44
+ {invitations.length}
45
+ </span>
46
+ </div>
47
+ <div className="space-y-1.5 px-0.5">
48
+ {invitations.map(({ invitation, channel }) => (
49
+ <InvitationRow
50
+ key={invitation.recordId}
51
+ invitation={invitation}
52
+ channel={channel}
53
+ onAccepted={onAccepted}
54
+ />
55
+ ))}
56
+ </div>
57
+ </div>
58
+ )
59
+ }
60
+
61
+ function InvitationRow({
62
+ invitation,
63
+ channel,
64
+ onAccepted,
65
+ }: {
66
+ invitation: RecordData<ChannelInvitation>
67
+ channel: RecordData<Channel> | undefined
68
+ onAccepted: (channelId: string) => void
69
+ }) {
70
+ const { join } = useChannelMembers(invitation.data.channelId)
71
+ const { put } = useMutations<ChannelInvitation>('channel-invitations')
72
+ const { getUser } = useUserLookup()
73
+ const [acting, setActing] = useState<'accept' | 'decline' | null>(null)
74
+
75
+ const inviter = getUser(invitation.data.invitedBy)
76
+ const channelName = channel?.data.name ?? 'Unknown group'
77
+ const isPrivate = channel?.data.type === 'private'
78
+
79
+ const handleAccept = useCallback(async () => {
80
+ setActing('accept')
81
+ try {
82
+ await put(invitation.recordId, {
83
+ ...invitation.data,
84
+ status: 'accepted',
85
+ } as unknown as ChannelInvitation)
86
+ await join()
87
+ onAccepted(invitation.data.channelId)
88
+ } finally {
89
+ setActing(null)
90
+ }
91
+ }, [invitation, put, join, onAccepted])
92
+
93
+ const handleDecline = useCallback(async () => {
94
+ setActing('decline')
95
+ try {
96
+ await put(invitation.recordId, {
97
+ ...invitation.data,
98
+ status: 'declined',
99
+ } as unknown as ChannelInvitation)
100
+ } finally {
101
+ setActing(null)
102
+ }
103
+ }, [invitation, put])
104
+
105
+ return (
106
+ <div
107
+ data-testid={`invitation-${invitation.recordId}`}
108
+ className="px-3 py-3 rounded-xl bg-warning/5 border border-warning/10"
109
+ >
110
+ <div className="flex items-center gap-2.5 mb-2">
111
+ <span className="shrink-0 w-8 h-8 rounded-lg bg-warning/10 flex items-center justify-center">
112
+ {isPrivate ? (
113
+ <svg className="w-3.5 h-3.5 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
114
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
115
+ </svg>
116
+ ) : (
117
+ <svg className="w-3.5 h-3.5 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
118
+ <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" />
119
+ </svg>
120
+ )}
121
+ </span>
122
+ <div className="flex-1 min-w-0">
123
+ <p className="text-sm font-semibold text-foreground truncate">{channelName}</p>
124
+ <p className="text-[11px] text-muted-foreground/70">
125
+ from {inviter?.name ?? 'someone'}
126
+ </p>
127
+ </div>
128
+ </div>
129
+ <div className="flex gap-2">
130
+ <button
131
+ data-testid={`accept-invitation-${invitation.recordId}`}
132
+ onClick={handleAccept}
133
+ disabled={acting !== null}
134
+ className="flex-1 px-3 py-1.5 text-xs font-semibold bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 active:bg-primary/80 disabled:opacity-40 transition-colors duration-200 cursor-pointer"
135
+ >
136
+ {acting === 'accept' ? 'Joining...' : 'Accept'}
137
+ </button>
138
+ <button
139
+ data-testid={`decline-invitation-${invitation.recordId}`}
140
+ onClick={handleDecline}
141
+ disabled={acting !== null}
142
+ className="flex-1 px-3 py-1.5 text-xs font-semibold text-muted-foreground border border-border/60 rounded-lg hover:bg-muted/50 active:bg-muted disabled:opacity-40 transition-colors duration-200 cursor-pointer"
143
+ >
144
+ {acting === 'decline' ? 'Declining...' : 'Decline'}
145
+ </button>
146
+ </div>
147
+ </div>
148
+ )
149
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * NewMessageModal -- Start a direct message with another user.
3
+ * Finds existing DM channels to avoid duplicates.
4
+ */
5
+
6
+ import { useState, useCallback, useEffect } from 'react'
7
+ import { useUser } from 'deepspace'
8
+ import { useUsers } from 'deepspace'
9
+ import { useChannels } from 'deepspace'
10
+ import { useMutations } from 'deepspace'
11
+ import type { Channel, ChannelMember } from 'deepspace'
12
+ import type { RecordData } from 'deepspace'
13
+
14
+ interface NewMessageModalProps {
15
+ onClose: () => void
16
+ onSelected: (channelId: string) => void
17
+ }
18
+
19
+ export function NewMessageModal({ onClose, onSelected }: NewMessageModalProps) {
20
+ const { user } = useUser()
21
+ const { users } = useUsers()
22
+ const { channels, create } = useChannels()
23
+ const { create: createMembership } = useMutations<ChannelMember>('channel-members')
24
+ const [search, setSearch] = useState('')
25
+ const [isCreating, setIsCreating] = useState(false)
26
+
27
+ useEffect(() => {
28
+ function handleEscape(e: KeyboardEvent) {
29
+ if (e.key === 'Escape') onClose()
30
+ }
31
+ document.addEventListener('keydown', handleEscape)
32
+ return () => document.removeEventListener('keydown', handleEscape)
33
+ }, [onClose])
34
+
35
+ const filteredUsers = users.filter(
36
+ (u) =>
37
+ u.id !== user?.id &&
38
+ (u.name?.toLowerCase().includes(search.toLowerCase()) ||
39
+ u.email?.toLowerCase().includes(search.toLowerCase()))
40
+ )
41
+
42
+ const handleSelectUser = useCallback(
43
+ async (targetUserId: string, targetName: string) => {
44
+ if (!user || isCreating) return
45
+
46
+ const ids = [user.id, targetUserId].sort()
47
+ const dmChannelName = `dm-${ids[0]}-${ids[1]}`
48
+
49
+ const existingDM = channels.find(
50
+ (c: RecordData<Channel>) => c.data.type === 'dm' && c.data.name === dmChannelName
51
+ )
52
+
53
+ if (existingDM) {
54
+ onSelected(existingDM.recordId)
55
+ onClose()
56
+ return
57
+ }
58
+
59
+ setIsCreating(true)
60
+ try {
61
+ const channelId = await create({
62
+ name: dmChannelName,
63
+ type: 'dm',
64
+ description: targetName,
65
+ })
66
+ if (channelId && user) {
67
+ await createMembership({
68
+ channelId,
69
+ userId: user.id,
70
+ joinedAt: new Date().toISOString(),
71
+ } as unknown as ChannelMember)
72
+ onSelected(channelId)
73
+ }
74
+ onClose()
75
+ } finally {
76
+ setIsCreating(false)
77
+ }
78
+ },
79
+ [user, isCreating, channels, create, createMembership, onSelected, onClose]
80
+ )
81
+
82
+ return (
83
+ <>
84
+ <div className="fixed inset-0 bg-black/40 z-50 animate-in fade-in duration-150" onClick={onClose} />
85
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
86
+ <div
87
+ data-testid="new-message-modal"
88
+ className="bg-card border border-border rounded-2xl shadow-2xl w-full max-w-md animate-in fade-in zoom-in-95 duration-200"
89
+ onClick={(e) => e.stopPropagation()}
90
+ >
91
+ <div className="px-6 pt-6 pb-0">
92
+ <h2 className="text-lg font-semibold text-foreground">New Message</h2>
93
+ <p className="text-sm text-muted-foreground mt-1">Send a direct message to someone.</p>
94
+ </div>
95
+
96
+ <div className="px-6 pt-4 pb-2">
97
+ <div className="relative">
98
+ <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}>
99
+ <path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
100
+ </svg>
101
+ <input
102
+ data-testid="dm-search-input"
103
+ type="text"
104
+ value={search}
105
+ onChange={(e) => setSearch(e.target.value)}
106
+ placeholder="Search by name or email..."
107
+ 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"
108
+ autoFocus
109
+ />
110
+ </div>
111
+ </div>
112
+
113
+ <div className="px-3 pb-3 max-h-64 overflow-y-auto">
114
+ {filteredUsers.length === 0 ? (
115
+ <p className="text-sm text-muted-foreground text-center py-8">
116
+ {search ? 'No users found' : 'No other users available'}
117
+ </p>
118
+ ) : (
119
+ <div className="py-1 space-y-0.5">
120
+ {filteredUsers.map((u) => (
121
+ <button
122
+ key={u.id}
123
+ data-testid={`dm-user-${u.id}`}
124
+ onClick={() => handleSelectUser(u.id, u.name)}
125
+ disabled={isCreating}
126
+ className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-muted/60 text-left transition-colors disabled:opacity-50"
127
+ >
128
+ {u.imageUrl ? (
129
+ <img src={u.imageUrl} alt="" className="w-9 h-9 rounded-full object-cover" />
130
+ ) : (
131
+ <div className="w-9 h-9 rounded-full bg-primary/15 flex items-center justify-center text-sm font-semibold text-primary">
132
+ {u.name?.[0]?.toUpperCase() ?? '?'}
133
+ </div>
134
+ )}
135
+ <div className="min-w-0 flex-1">
136
+ <div className="text-sm font-medium text-foreground truncate">{u.name}</div>
137
+ {u.email && (
138
+ <div className="text-xs text-muted-foreground truncate">{u.email}</div>
139
+ )}
140
+ </div>
141
+ </button>
142
+ ))}
143
+ </div>
144
+ )}
145
+ </div>
146
+
147
+ <div className="px-6 pb-6 pt-2 border-t border-border">
148
+ <button
149
+ onClick={onClose}
150
+ 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"
151
+ >
152
+ Cancel
153
+ </button>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ </>
158
+ )
159
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * useChatChannel — Find or create a default chat channel, and auto-join.
3
+ *
4
+ * Encapsulates all channel logic so consuming components never need to
5
+ * know about the channels collection. On first mount (when no channel
6
+ * exists yet) it creates a "general" public channel automatically and
7
+ * joins the current user.
8
+ *
9
+ * Usage:
10
+ * const { channelId, status } = useChatChannel()
11
+ * if (!channelId) return <Loading />
12
+ * return <MessageList channelId={channelId} />
13
+ */
14
+
15
+ import { useEffect, useRef } from 'react'
16
+ import { useUser } from 'deepspace'
17
+ import { useChannels } from 'deepspace'
18
+ import { useChannelMembers } from 'deepspace'
19
+ import type { Channel } from 'deepspace'
20
+ import type { RecordData } from 'deepspace'
21
+
22
+ const DEFAULT_CHANNEL_NAME = 'general'
23
+
24
+ export function useChatChannel(channelName: string = DEFAULT_CHANNEL_NAME) {
25
+ const { user } = useUser()
26
+ const { channels, status, create } = useChannels()
27
+ const hasInitialized = useRef(false)
28
+
29
+ const defaultChannel = channels.find(
30
+ (c: RecordData<Channel>) =>
31
+ c.data.name === channelName &&
32
+ c.data.type === 'public' &&
33
+ !c.data.archived
34
+ )
35
+
36
+ const { isMember, join, status: membersStatus } = useChannelMembers(defaultChannel?.recordId)
37
+
38
+ // One-shot initialization: create the default channel if it doesn't exist.
39
+ useEffect(() => {
40
+ if (!user || status !== 'ready' || hasInitialized.current) return
41
+ hasInitialized.current = true
42
+
43
+ if (!defaultChannel) {
44
+ create({
45
+ name: channelName,
46
+ type: 'public',
47
+ description: 'Default chat channel',
48
+ })
49
+ }
50
+ }, [user, status]) // eslint-disable-line react-hooks/exhaustive-deps
51
+
52
+ // Wait for the members query to be ready before auto-joining.
53
+ useEffect(() => {
54
+ if (!user || !defaultChannel || isMember || membersStatus !== 'ready') return
55
+ join()
56
+ }, [user, defaultChannel, isMember, membersStatus, join])
57
+
58
+ return {
59
+ channelId: defaultChannel?.recordId,
60
+ status,
61
+ isMember,
62
+ join,
63
+ }
64
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * useLongPress — Touch-based long-press detection for web.
3
+ *
4
+ * Returns event handlers (onTouchStart, onTouchEnd, onTouchMove) to
5
+ * attach to any element. Fires the callback after `delay` ms of
6
+ * sustained touch. Cancels if the finger moves beyond `threshold` px
7
+ * or lifts before the timer fires.
8
+ *
9
+ * Only active on touch devices — mouse events are not intercepted,
10
+ * so desktop hover behavior is unaffected.
11
+ */
12
+
13
+ import { useRef, useCallback } from 'react'
14
+
15
+ interface UseLongPressOptions {
16
+ delay?: number
17
+ threshold?: number
18
+ }
19
+
20
+ export function useLongPress(
21
+ callback: () => void,
22
+ { delay = 500, threshold = 10 }: UseLongPressOptions = {}
23
+ ) {
24
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
25
+ const startPos = useRef<{ x: number; y: number } | null>(null)
26
+ const firedRef = useRef(false)
27
+
28
+ const clear = useCallback(() => {
29
+ if (timerRef.current !== null) {
30
+ clearTimeout(timerRef.current)
31
+ timerRef.current = null
32
+ }
33
+ startPos.current = null
34
+ }, [])
35
+
36
+ const onTouchStart = useCallback(
37
+ (e: React.TouchEvent) => {
38
+ firedRef.current = false
39
+ const touch = e.touches[0]
40
+ startPos.current = { x: touch.clientX, y: touch.clientY }
41
+ timerRef.current = setTimeout(() => {
42
+ firedRef.current = true
43
+ callback()
44
+ timerRef.current = null
45
+ }, delay)
46
+ },
47
+ [callback, delay]
48
+ )
49
+
50
+ const onTouchMove = useCallback(
51
+ (e: React.TouchEvent) => {
52
+ if (!startPos.current || timerRef.current === null) return
53
+ const touch = e.touches[0]
54
+ const dx = touch.clientX - startPos.current.x
55
+ const dy = touch.clientY - startPos.current.y
56
+ if (Math.sqrt(dx * dx + dy * dy) > threshold) {
57
+ clear()
58
+ }
59
+ },
60
+ [threshold, clear]
61
+ )
62
+
63
+ const onTouchEnd = useCallback(
64
+ (e: React.TouchEvent) => {
65
+ if (firedRef.current) {
66
+ e.preventDefault()
67
+ }
68
+ clear()
69
+ },
70
+ [clear]
71
+ )
72
+
73
+ return { onTouchStart, onTouchMove, onTouchEnd }
74
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * useMultiChannel — Multi-channel state management.
3
+ *
4
+ * Manages the active channel selection. Provides channel lists filtered
5
+ * by type and helpers for DM display names and unread.
6
+ *
7
+ * DM visibility: DM channels use a `dm-{id1}-{id2}` naming convention.
8
+ * A DM appears in a user's sidebar if their ID is embedded in the channel
9
+ * name, regardless of whether a membership record exists yet.
10
+ */
11
+
12
+ import { useState, useCallback, useMemo } from 'react'
13
+ import { useUser } from 'deepspace'
14
+ import { useUsers } from 'deepspace'
15
+ import { useChannels } from 'deepspace'
16
+ import { useReadReceipts } from 'deepspace'
17
+ import { useQuery } from 'deepspace'
18
+ import type { Channel, ChannelMember, ChannelInvitation } from 'deepspace'
19
+ import type { RecordData } from 'deepspace'
20
+
21
+ const CHANNEL_TYPES = {
22
+ PUBLIC: 'public',
23
+ PRIVATE: 'private',
24
+ DM: 'dm',
25
+ } as const
26
+
27
+ export type ChannelType = (typeof CHANNEL_TYPES)[keyof typeof CHANNEL_TYPES]
28
+
29
+ export { CHANNEL_TYPES }
30
+
31
+ export function useMultiChannel() {
32
+ const { user } = useUser()
33
+ const { users } = useUsers()
34
+ const { channels, status } = useChannels()
35
+ const { receipts, markAsRead } = useReadReceipts()
36
+ const [activeChannelId, setActiveChannelId] = useState<string | null>(null)
37
+
38
+ const { records: myMemberships } = useQuery<ChannelMember>('channel-members', {
39
+ where: user ? { userId: user.id } : { userId: '__none__' },
40
+ })
41
+
42
+ const myChannelIds = useMemo(
43
+ () => new Set(
44
+ myMemberships.map((m: RecordData<ChannelMember>) => m.data.channelId)
45
+ ),
46
+ [myMemberships]
47
+ )
48
+
49
+ const liveChannels = useMemo(
50
+ () => channels.filter((c: RecordData<Channel>) => !c.data.archived),
51
+ [channels]
52
+ )
53
+
54
+ const groups = useMemo(
55
+ () => {
56
+ if (!user) return []
57
+ return liveChannels.filter(
58
+ (c: RecordData<Channel>) =>
59
+ (c.data.type === CHANNEL_TYPES.PUBLIC || c.data.type === CHANNEL_TYPES.PRIVATE) &&
60
+ myChannelIds.has(c.recordId)
61
+ )
62
+ },
63
+ [liveChannels, myChannelIds, user]
64
+ )
65
+
66
+ /** DMs visible to the current user -- matched by user ID in channel name, not membership */
67
+ const dms = useMemo(
68
+ () => {
69
+ if (!user) return []
70
+ return liveChannels.filter(
71
+ (c: RecordData<Channel>) =>
72
+ c.data.type === CHANNEL_TYPES.DM &&
73
+ c.data.name.includes(user.id)
74
+ )
75
+ },
76
+ [liveChannels, user]
77
+ )
78
+
79
+ // Use `where` for the immutable `invitedUserId` field so the SDK filters
80
+ // at query-time and gates real-time creates. `status` is mutable (pending ->
81
+ // accepted/declined), so we filter it client-side in the `useMemo` below.
82
+ const { records: myInvitations } = useQuery<ChannelInvitation>('channel-invitations', {
83
+ where: user ? { invitedUserId: user.id } : { invitedUserId: '__none__' },
84
+ })
85
+
86
+ const pendingInvitations = useMemo(
87
+ () => {
88
+ return myInvitations
89
+ .filter((inv: RecordData<ChannelInvitation>) => inv.data.status === 'pending')
90
+ .filter((inv: RecordData<ChannelInvitation>) => {
91
+ const ch = liveChannels.find((c: RecordData<Channel>) => c.recordId === inv.data.channelId)
92
+ return ch && !ch.data.archived
93
+ })
94
+ .map((inv: RecordData<ChannelInvitation>) => {
95
+ const ch = liveChannels.find((c: RecordData<Channel>) => c.recordId === inv.data.channelId)
96
+ return { invitation: inv, channel: ch }
97
+ })
98
+ },
99
+ [myInvitations, liveChannels]
100
+ )
101
+
102
+ const hasUnread = useCallback(
103
+ (channelId: string): boolean => {
104
+ if (channelId === activeChannelId) return false
105
+ const receipt = receipts.find((r) => r.data.channelId === channelId)
106
+ return !receipt
107
+ },
108
+ [receipts, activeChannelId]
109
+ )
110
+
111
+ const getDMDisplayName = useCallback(
112
+ (channel: RecordData<Channel>): string => {
113
+ if (!user) return channel.data.name
114
+ const dmName = channel.data.name
115
+ const parts = dmName.replace('dm-', '').split('-')
116
+ const otherId = parts.find((id) => id !== user.id) ?? parts[0]
117
+ const otherUser = users.find((u) => u.id === otherId)
118
+ return otherUser?.name ?? channel.data.description ?? 'Direct Message'
119
+ },
120
+ [user, users]
121
+ )
122
+
123
+ const selectChannel = useCallback(
124
+ (id: string) => {
125
+ setActiveChannelId(id)
126
+ markAsRead(id)
127
+ },
128
+ [markAsRead]
129
+ )
130
+
131
+ return {
132
+ activeChannelId,
133
+ selectChannel,
134
+ channels: liveChannels,
135
+ groups,
136
+ dms,
137
+ pendingInvitations,
138
+ status,
139
+ hasUnread,
140
+ getDMDisplayName,
141
+ }
142
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Messaging Feature — Schema
3
+ *
4
+ * Re-exports the SDK's pre-built messaging collection schemas.
5
+ * Spread into the app's schemas array: ...messagingSchemas
6
+ */
7
+
8
+ import type { CollectionSchema } from 'deepspace/worker'
9
+ import {
10
+ CHANNELS_SCHEMA,
11
+ MESSAGES_SCHEMA,
12
+ REACTIONS_SCHEMA,
13
+ CHANNEL_MEMBERS_SCHEMA,
14
+ CHANNEL_INVITATIONS_SCHEMA,
15
+ READ_RECEIPTS_SCHEMA,
16
+ } from 'deepspace/worker'
17
+
18
+ export const messagingSchemas: CollectionSchema[] = [
19
+ CHANNELS_SCHEMA,
20
+ MESSAGES_SCHEMA,
21
+ REACTIONS_SCHEMA,
22
+ CHANNEL_MEMBERS_SCHEMA,
23
+ CHANNEL_INVITATIONS_SCHEMA,
24
+ READ_RECEIPTS_SCHEMA,
25
+ ]
@@ -0,0 +1,21 @@
1
+ {
2
+ "id": "presence-test",
3
+ "name": "Presence Test",
4
+ "category": "data",
5
+ "description": "E2E test page for real-time presence tracking via PresenceRoom DO",
6
+ "details": "Test page used by Playwright e2e tests. Connects to a PresenceRoom scope and displays peers, state updates, and connection status with data-testid attributes.",
7
+ "files": [
8
+ {
9
+ "src": "src/PresenceTestPage.tsx",
10
+ "dest": "src/pages/presence-test.tsx"
11
+ }
12
+ ],
13
+ "schema": null,
14
+ "route": {
15
+ "path": "/presence-test",
16
+ "protected": false
17
+ },
18
+ "requires": [],
19
+ "instructions": [],
20
+ "patterns": []
21
+ }