deepspace 0.26.0 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/dist/{chunk-LPXWXF6R.js → chunk-YBXIUDZJ.js} +2 -2
- package/dist/{chunk-LPXWXF6R.js.map → chunk-YBXIUDZJ.js.map} +1 -1
- package/dist/cli.js +80 -45
- package/dist/cli.js.map +1 -1
- package/dist/documentation-client-core.js +1 -1
- package/dist/documentation-default-renderer.cjs +1 -1
- package/dist/documentation-react.js +1 -1
- package/dist/documentation-runtime.js +1 -1
- package/dist/documentation-server-core.js +1 -1
- package/dist/documentation.js +1 -1
- package/dist/documentation.js.map +1 -1
- package/dist/index.d.ts +26 -434
- package/dist/index.js +99 -864
- package/dist/index.js.map +1 -1
- package/dist/schema.d.ts +4 -41
- package/dist/schema.js +2 -242
- package/dist/schema.js.map +1 -1
- package/dist/server.d.ts +6 -162
- package/dist/server.js +13 -281
- package/dist/server.js.map +1 -1
- package/dist/worker.d.ts +4 -160
- package/dist/worker.js +15 -281
- package/dist/worker.js.map +1 -1
- package/features/messaging/feature.json +3 -43
- package/features/messaging/src/ChatPage.tsx +12 -16
- package/features/messaging/src/components/chat/ChatHeader.tsx +5 -13
- package/features/messaging/src/components/chat/MessageItem.tsx +7 -9
- package/features/messaging/src/components/chat/MessageList.tsx +57 -40
- package/features/messaging/src/components/chat/ThreadPanel.tsx +25 -11
- package/features/messaging/src/components/chat/UserProfilePopover.tsx +10 -29
- package/features/messaging/src/hooks/useChatChannel.ts +18 -23
- package/features/messaging/src/messaging-schema.ts +0 -2
- package/features/search-bar/feature.json +9 -20
- package/features/search-bar/src/InlineSearch.tsx +101 -0
- package/features/search-bar/src/__tests__/search-model.test.tsx +38 -0
- package/features/search-bar/src/search-model.ts +2 -149
- package/package.json +1 -1
- package/features/messaging/src/ChatMultiPage.tsx +0 -298
- package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +0 -188
- package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +0 -163
- package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +0 -147
- package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +0 -344
- package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +0 -460
- package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +0 -190
- package/features/messaging/src/components/chat-multi/InvitationList.tsx +0 -149
- package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +0 -159
- package/features/messaging/src/hooks/useMultiChannel.ts +0 -142
- package/features/search-bar/src/SearchOverlay.tsx +0 -566
- package/features/search-bar/src/__tests__/search-model.test.ts +0 -40
- package/features/sidebar/feature.json +0 -19
- package/features/sidebar/src/AppSidebar.tsx +0 -558
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
"id": "messaging",
|
|
3
3
|
"name": "Messaging",
|
|
4
4
|
"category": "data",
|
|
5
|
-
"description": "
|
|
6
|
-
"details": "
|
|
5
|
+
"description": "Public real-time chat with threads and reactions",
|
|
6
|
+
"details": "A focused public-channel chat page with threads, reactions, presence, read receipts, and optional confirmed channel membership hooks. Viewers can read public chat without joining; private groups and DMs are deliberately excluded because client-side membership checks do not provide server-side record isolation.",
|
|
7
7
|
"files": [
|
|
8
8
|
{
|
|
9
9
|
"src": "src/messaging-schema.ts",
|
|
@@ -13,10 +13,6 @@
|
|
|
13
13
|
"src": "src/ChatPage.tsx",
|
|
14
14
|
"dest": "src/pages/chat.tsx"
|
|
15
15
|
},
|
|
16
|
-
{
|
|
17
|
-
"src": "src/ChatMultiPage.tsx",
|
|
18
|
-
"dest": "src/pages/chat-multi.tsx"
|
|
19
|
-
},
|
|
20
16
|
{
|
|
21
17
|
"src": "src/hooks/useChatChannel.ts",
|
|
22
18
|
"dest": "src/components/messaging/hooks/useChatChannel.ts"
|
|
@@ -25,10 +21,6 @@
|
|
|
25
21
|
"src": "src/hooks/useLongPress.ts",
|
|
26
22
|
"dest": "src/components/messaging/hooks/useLongPress.ts"
|
|
27
23
|
},
|
|
28
|
-
{
|
|
29
|
-
"src": "src/hooks/useMultiChannel.ts",
|
|
30
|
-
"dest": "src/components/messaging/hooks/useMultiChannel.ts"
|
|
31
|
-
},
|
|
32
24
|
{
|
|
33
25
|
"src": "src/components/chat/ChatHeader.tsx",
|
|
34
26
|
"dest": "src/components/messaging/chat/ChatHeader.tsx"
|
|
@@ -56,38 +48,6 @@
|
|
|
56
48
|
{
|
|
57
49
|
"src": "src/components/chat/MessageActionSheet.tsx",
|
|
58
50
|
"dest": "src/components/messaging/chat/MessageActionSheet.tsx"
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
"src": "src/components/chat-multi/ChannelSidebar.tsx",
|
|
62
|
-
"dest": "src/components/messaging/chat-multi/ChannelSidebar.tsx"
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
"src": "src/components/chat-multi/ChannelListItem.tsx",
|
|
66
|
-
"dest": "src/components/messaging/chat-multi/ChannelListItem.tsx"
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
"src": "src/components/chat-multi/CreateGroupModal.tsx",
|
|
70
|
-
"dest": "src/components/messaging/chat-multi/CreateGroupModal.tsx"
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
"src": "src/components/chat-multi/NewMessageModal.tsx",
|
|
74
|
-
"dest": "src/components/messaging/chat-multi/NewMessageModal.tsx"
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
"src": "src/components/chat-multi/BrowseGroupsModal.tsx",
|
|
78
|
-
"dest": "src/components/messaging/chat-multi/BrowseGroupsModal.tsx"
|
|
79
|
-
},
|
|
80
|
-
{
|
|
81
|
-
"src": "src/components/chat-multi/ChannelSettingsPanel.tsx",
|
|
82
|
-
"dest": "src/components/messaging/chat-multi/ChannelSettingsPanel.tsx"
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
"src": "src/components/chat-multi/AddMemberModal.tsx",
|
|
86
|
-
"dest": "src/components/messaging/chat-multi/AddMemberModal.tsx"
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
"src": "src/components/chat-multi/InvitationList.tsx",
|
|
90
|
-
"dest": "src/components/messaging/chat-multi/InvitationList.tsx"
|
|
91
51
|
}
|
|
92
52
|
],
|
|
93
53
|
"schema": {
|
|
@@ -101,7 +61,7 @@
|
|
|
101
61
|
},
|
|
102
62
|
"requires": [],
|
|
103
63
|
"instructions": [
|
|
104
|
-
"
|
|
64
|
+
"The included schemas and UI are for public channels. Add server-enforced tenant or participant scoping before implementing private groups or direct messages."
|
|
105
65
|
],
|
|
106
66
|
"patterns": []
|
|
107
67
|
}
|
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { useEffect, useRef } from 'react'
|
|
7
7
|
import { useChatChannel } from '../components/messaging/hooks/useChatChannel'
|
|
8
|
-
import { useReadReceipts } from 'deepspace'
|
|
9
|
-
import { RecordScope } from 'deepspace'
|
|
8
|
+
import { RecordScope, useReadReceipts, useUser, isWriterRole } from 'deepspace'
|
|
10
9
|
import type { CollectionSchema } from 'deepspace/schema'
|
|
11
10
|
import { messagingSchemas } from '../schemas/messaging-schema'
|
|
12
11
|
import { ChatHeader } from '../components/messaging/chat/ChatHeader'
|
|
@@ -23,18 +22,20 @@ export default function ChatPage({
|
|
|
23
22
|
channelName = 'general',
|
|
24
23
|
className,
|
|
25
24
|
}: ChatPageProps) {
|
|
26
|
-
const { channelId, status
|
|
25
|
+
const { channelId, status } = useChatChannel(channelName)
|
|
27
26
|
const { markAsRead } = useReadReceipts()
|
|
27
|
+
const { user } = useUser()
|
|
28
28
|
const lastMarkedRef = useRef<string | null>(null)
|
|
29
|
+
const canTrackReadState = isWriterRole(user?.role)
|
|
29
30
|
|
|
30
31
|
useEffect(() => {
|
|
31
|
-
if (!channelId || !
|
|
32
|
+
if (!channelId || !canTrackReadState) return
|
|
32
33
|
if (lastMarkedRef.current === channelId) return
|
|
33
34
|
lastMarkedRef.current = channelId
|
|
34
35
|
markAsRead(channelId)
|
|
35
|
-
}, [
|
|
36
|
+
}, [canTrackReadState, channelId, markAsRead])
|
|
36
37
|
|
|
37
|
-
if (status !== 'ready'
|
|
38
|
+
if (status !== 'ready') {
|
|
38
39
|
// data-testid lets tests (and humans reading a trace) distinguish "stuck
|
|
39
40
|
// waiting for the default channel" from "route never mounted".
|
|
40
41
|
return (
|
|
@@ -47,20 +48,15 @@ export default function ChatPage({
|
|
|
47
48
|
)
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
if (!
|
|
51
|
+
if (!channelId) {
|
|
51
52
|
return (
|
|
52
53
|
<div className={`flex flex-col h-full ${className ?? ''}`} data-testid="chat-page">
|
|
53
|
-
<ChatHeader channelId={channelId} />
|
|
54
54
|
<div className="flex-1 flex items-center justify-center">
|
|
55
55
|
<div className="text-center">
|
|
56
|
-
<p className="text-foreground
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
|
|
61
|
-
>
|
|
62
|
-
Join Channel
|
|
63
|
-
</button>
|
|
56
|
+
<p className="text-foreground">No public channel exists yet.</p>
|
|
57
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
58
|
+
A member or admin can create the first channel.
|
|
59
|
+
</p>
|
|
64
60
|
</div>
|
|
65
61
|
</div>
|
|
66
62
|
</div>
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ChatHeader -- Channel header showing name
|
|
2
|
+
* ChatHeader -- Channel header showing the public channel name and description.
|
|
3
3
|
* Generic styling (no Slack-specific prefixes like #).
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { useChannels } from 'deepspace'
|
|
7
|
-
import { useChannelMembers } from 'deepspace'
|
|
8
7
|
import type { Channel } from 'deepspace'
|
|
9
8
|
import type { RecordData } from 'deepspace'
|
|
10
9
|
|
|
@@ -16,7 +15,6 @@ interface ChatHeaderProps {
|
|
|
16
15
|
|
|
17
16
|
export function ChatHeader({ channelId, onToggleThread, threadOpen }: ChatHeaderProps) {
|
|
18
17
|
const { channels } = useChannels()
|
|
19
|
-
const { members } = useChannelMembers(channelId)
|
|
20
18
|
|
|
21
19
|
const channel = channels.find((c: RecordData<Channel>) => c.recordId === channelId)
|
|
22
20
|
|
|
@@ -42,14 +40,8 @@ export function ChatHeader({ channelId, onToggleThread, threadOpen }: ChatHeader
|
|
|
42
40
|
)}
|
|
43
41
|
</div>
|
|
44
42
|
|
|
45
|
-
|
|
46
|
-
<
|
|
47
|
-
className="text-xs text-muted-foreground"
|
|
48
|
-
data-testid="chat-header-member-count"
|
|
49
|
-
>
|
|
50
|
-
{members.length} {members.length === 1 ? 'member' : 'members'}
|
|
51
|
-
</span>
|
|
52
|
-
{onToggleThread && (
|
|
43
|
+
{onToggleThread && (
|
|
44
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
53
45
|
<button
|
|
54
46
|
data-testid="toggle-thread-btn"
|
|
55
47
|
onClick={onToggleThread}
|
|
@@ -69,8 +61,8 @@ export function ChatHeader({ channelId, onToggleThread, threadOpen }: ChatHeader
|
|
|
69
61
|
/>
|
|
70
62
|
</svg>
|
|
71
63
|
</button>
|
|
72
|
-
|
|
73
|
-
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
74
66
|
</div>
|
|
75
67
|
)
|
|
76
68
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
8
|
-
import { useUserLookup } from 'deepspace'
|
|
8
|
+
import { useUserLookup, isWriterRole } from 'deepspace'
|
|
9
9
|
import { useUser } from 'deepspace'
|
|
10
10
|
import { usePresence } from 'deepspace'
|
|
11
11
|
import type { Message } from 'deepspace'
|
|
@@ -43,7 +43,6 @@ interface MessageItemProps {
|
|
|
43
43
|
forceEdit?: boolean
|
|
44
44
|
onEditDone?: () => void
|
|
45
45
|
isHighlighted?: boolean
|
|
46
|
-
onStartDM?: (userId: string) => void
|
|
47
46
|
}
|
|
48
47
|
|
|
49
48
|
export function MessageItem({
|
|
@@ -59,7 +58,6 @@ export function MessageItem({
|
|
|
59
58
|
forceEdit = false,
|
|
60
59
|
onEditDone,
|
|
61
60
|
isHighlighted = false,
|
|
62
|
-
onStartDM,
|
|
63
61
|
}: MessageItemProps) {
|
|
64
62
|
const { getUser } = useUserLookup()
|
|
65
63
|
const { user: currentUser } = useUser()
|
|
@@ -74,11 +72,12 @@ export function MessageItem({
|
|
|
74
72
|
const authorId = message.data.authorId || message.createdBy
|
|
75
73
|
const author = getUser(authorId)
|
|
76
74
|
const isOwn = currentUser?.id === authorId || currentUser?.id === message.createdBy
|
|
75
|
+
const canWrite = isWriterRole(currentUser?.role)
|
|
77
76
|
const online = isOnline(authorId)
|
|
78
77
|
|
|
79
78
|
const longPressHandlers = useLongPress(
|
|
80
79
|
() => {
|
|
81
|
-
if (!contentRef.current) return
|
|
80
|
+
if (!canWrite || !contentRef.current) return
|
|
82
81
|
const rect = contentRef.current.getBoundingClientRect()
|
|
83
82
|
onLongPress?.({
|
|
84
83
|
top: rect.top,
|
|
@@ -198,9 +197,7 @@ export function MessageItem({
|
|
|
198
197
|
{/* Avatar column */}
|
|
199
198
|
<div className="shrink-0 w-10">
|
|
200
199
|
{isFirstInGroup ? (
|
|
201
|
-
<UserProfilePopover userId={authorId}
|
|
202
|
-
{avatar}
|
|
203
|
-
</UserProfilePopover>
|
|
200
|
+
<UserProfilePopover userId={authorId}>{avatar}</UserProfilePopover>
|
|
204
201
|
) : (
|
|
205
202
|
<span className="w-full pt-1 flex justify-center text-[10px] text-muted-foreground/0 group-hover:text-muted-foreground/70 transition-colors select-none">
|
|
206
203
|
{timeStr}
|
|
@@ -212,7 +209,7 @@ export function MessageItem({
|
|
|
212
209
|
<div ref={contentRef} className={`flex-1 min-w-0 ${isHighlighted ? 'invisible' : ''}`}>
|
|
213
210
|
{isFirstInGroup && (
|
|
214
211
|
<div className="flex items-baseline gap-2 mb-0.5">
|
|
215
|
-
<UserProfilePopover userId={authorId}
|
|
212
|
+
<UserProfilePopover userId={authorId}>
|
|
216
213
|
<span className="text-sm font-semibold text-foreground hover:underline cursor-pointer">
|
|
217
214
|
{author?.name ?? 'Unknown'}
|
|
218
215
|
</span>
|
|
@@ -289,6 +286,7 @@ export function MessageItem({
|
|
|
289
286
|
key={r.emoji}
|
|
290
287
|
data-testid={`reaction-${message.recordId}-${r.emoji}`}
|
|
291
288
|
onClick={() => onToggleReaction(r.emoji)}
|
|
289
|
+
disabled={!canWrite}
|
|
292
290
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs border transition-colors ${
|
|
293
291
|
r.currentUserReacted
|
|
294
292
|
? 'bg-primary/15 border-primary/30 text-primary'
|
|
@@ -315,7 +313,7 @@ export function MessageItem({
|
|
|
315
313
|
</div>
|
|
316
314
|
|
|
317
315
|
{/* Desktop hover toolbar */}
|
|
318
|
-
{!isEditing && (
|
|
316
|
+
{!isEditing && canWrite && (
|
|
319
317
|
<div
|
|
320
318
|
data-testid={`hover-toolbar-${message.recordId}`}
|
|
321
319
|
className="absolute -top-3 right-5 flex items-center gap-0.5 bg-card border border-border/60 rounded-lg shadow-sm p-0.5 transition-all duration-100 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
|
7
|
-
import { useMessages } from 'deepspace'
|
|
7
|
+
import { useMessages, isWriterRole } from 'deepspace'
|
|
8
8
|
import { useReactions } from 'deepspace'
|
|
9
9
|
import { useUser } from 'deepspace'
|
|
10
10
|
import type { Message } from 'deepspace'
|
|
@@ -17,7 +17,6 @@ import { ThreadPanel } from './ThreadPanel'
|
|
|
17
17
|
|
|
18
18
|
interface MessageListProps {
|
|
19
19
|
channelId: string
|
|
20
|
-
onStartDM?: (userId: string) => void
|
|
21
20
|
}
|
|
22
21
|
|
|
23
22
|
interface ActionSheetState {
|
|
@@ -39,7 +38,9 @@ function isFirstInGroup(msgs: RecordData<Message>[], i: number): boolean {
|
|
|
39
38
|
const prev = msgs[i - 1]
|
|
40
39
|
const curr = msgs[i]
|
|
41
40
|
if (getAuthorId(prev) !== getAuthorId(curr)) return true
|
|
42
|
-
return
|
|
41
|
+
return (
|
|
42
|
+
new Date(curr.createdAt).getTime() - new Date(prev.createdAt).getTime() >= GROUP_THRESHOLD_MS
|
|
43
|
+
)
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
function formatTime(date: Date): string {
|
|
@@ -53,14 +54,19 @@ function getSeparatorLabel(date: Date): string {
|
|
|
53
54
|
|
|
54
55
|
if (date.toDateString() === today.toDateString()) return `Today ${formatTime(date)}`
|
|
55
56
|
if (date.toDateString() === yesterday.toDateString()) return `Yesterday ${formatTime(date)}`
|
|
56
|
-
return
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
return (
|
|
58
|
+
date.toLocaleDateString(undefined, {
|
|
59
|
+
weekday: 'long',
|
|
60
|
+
month: 'long',
|
|
61
|
+
day: 'numeric',
|
|
62
|
+
}) + ` ${formatTime(date)}`
|
|
63
|
+
)
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
function shouldShowSeparator(
|
|
66
|
+
function shouldShowSeparator(
|
|
67
|
+
current: RecordData<Message>,
|
|
68
|
+
prev: RecordData<Message> | undefined,
|
|
69
|
+
): boolean {
|
|
64
70
|
if (!prev) return true
|
|
65
71
|
const curDate = new Date(current.createdAt)
|
|
66
72
|
const prevDate = new Date(prev.createdAt)
|
|
@@ -68,10 +74,11 @@ function shouldShowSeparator(current: RecordData<Message>, prev: RecordData<Mess
|
|
|
68
74
|
return curDate.getTime() - prevDate.getTime() >= TIME_SEPARATOR_GAP_MS
|
|
69
75
|
}
|
|
70
76
|
|
|
71
|
-
export function MessageList({ channelId
|
|
77
|
+
export function MessageList({ channelId }: MessageListProps) {
|
|
72
78
|
const { messages, status, send, edit, softDelete } = useMessages(channelId)
|
|
73
79
|
const { getReactionsForMessage, toggle: toggleReaction } = useReactions(channelId)
|
|
74
80
|
const { user: currentUser } = useUser()
|
|
81
|
+
const canWrite = isWriterRole(currentUser?.role)
|
|
75
82
|
const bottomRef = useRef<HTMLDivElement>(null)
|
|
76
83
|
const feedRef = useRef<HTMLDivElement>(null)
|
|
77
84
|
const isInitialMount = useRef(true)
|
|
@@ -85,17 +92,14 @@ export function MessageList({ channelId, onStartDM }: MessageListProps) {
|
|
|
85
92
|
messages
|
|
86
93
|
.filter((m: RecordData<Message>) => !m.data.parentMessageId)
|
|
87
94
|
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()),
|
|
88
|
-
[messages]
|
|
95
|
+
[messages],
|
|
89
96
|
)
|
|
90
97
|
|
|
91
98
|
const threadCounts = useMemo(() => {
|
|
92
99
|
const counts = new Map<string, number>()
|
|
93
100
|
for (const m of messages) {
|
|
94
101
|
if (m.data.parentMessageId) {
|
|
95
|
-
counts.set(
|
|
96
|
-
m.data.parentMessageId,
|
|
97
|
-
(counts.get(m.data.parentMessageId) ?? 0) + 1
|
|
98
|
-
)
|
|
102
|
+
counts.set(m.data.parentMessageId, (counts.get(m.data.parentMessageId) ?? 0) + 1)
|
|
99
103
|
}
|
|
100
104
|
}
|
|
101
105
|
return counts
|
|
@@ -127,6 +131,7 @@ export function MessageList({ channelId, onStartDM }: MessageListProps) {
|
|
|
127
131
|
|
|
128
132
|
const handleLongPress = useCallback(
|
|
129
133
|
(msg: RecordData<Message>, rect: MessageRect) => {
|
|
134
|
+
if (!canWrite) return
|
|
130
135
|
const authorId = msg.data.authorId || msg.createdBy
|
|
131
136
|
const isOwn = currentUser?.id === authorId || currentUser?.id === msg.createdBy
|
|
132
137
|
setActionSheet({
|
|
@@ -136,7 +141,7 @@ export function MessageList({ channelId, onStartDM }: MessageListProps) {
|
|
|
136
141
|
rect,
|
|
137
142
|
})
|
|
138
143
|
},
|
|
139
|
-
[currentUser]
|
|
144
|
+
[canWrite, currentUser],
|
|
140
145
|
)
|
|
141
146
|
|
|
142
147
|
if (status === 'loading') {
|
|
@@ -163,12 +168,18 @@ export function MessageList({ channelId, onStartDM }: MessageListProps) {
|
|
|
163
168
|
return (
|
|
164
169
|
<div className="flex flex-1 min-h-0">
|
|
165
170
|
<div className={`flex flex-col flex-1 min-w-0 ${threadOpen ? 'hidden md:flex' : 'flex'}`}>
|
|
166
|
-
<div
|
|
171
|
+
<div
|
|
172
|
+
ref={feedRef}
|
|
173
|
+
className="flex-1 overflow-y-auto overflow-x-hidden flex flex-col"
|
|
174
|
+
data-testid="messages-feed"
|
|
175
|
+
>
|
|
167
176
|
{topLevelMessages.length === 0 ? (
|
|
168
177
|
<div className="flex items-center justify-center flex-1">
|
|
169
178
|
<div className="text-center text-muted-foreground">
|
|
170
179
|
<p className="text-lg mb-1">No messages yet</p>
|
|
171
|
-
<p className="text-sm">
|
|
180
|
+
<p className="text-sm">
|
|
181
|
+
{canWrite ? 'Be the first to send a message!' : 'There are no messages yet.'}
|
|
182
|
+
</p>
|
|
172
183
|
</div>
|
|
173
184
|
</div>
|
|
174
185
|
) : (
|
|
@@ -181,7 +192,10 @@ export function MessageList({ channelId, onStartDM }: MessageListProps) {
|
|
|
181
192
|
return (
|
|
182
193
|
<div key={msg.recordId}>
|
|
183
194
|
{showSeparator && (
|
|
184
|
-
<div
|
|
195
|
+
<div
|
|
196
|
+
className="relative flex items-center justify-center my-2 mx-4"
|
|
197
|
+
data-testid="date-separator"
|
|
198
|
+
>
|
|
185
199
|
<div className="absolute inset-0 flex items-center">
|
|
186
200
|
<div className="w-full border-t border-border" />
|
|
187
201
|
</div>
|
|
@@ -203,7 +217,6 @@ export function MessageList({ channelId, onStartDM }: MessageListProps) {
|
|
|
203
217
|
forceEdit={editingMessageId === msg.recordId}
|
|
204
218
|
onEditDone={() => setEditingMessageId(null)}
|
|
205
219
|
isHighlighted={actionSheet?.messageId === msg.recordId}
|
|
206
|
-
onStartDM={onStartDM}
|
|
207
220
|
/>
|
|
208
221
|
</div>
|
|
209
222
|
)
|
|
@@ -213,7 +226,9 @@ export function MessageList({ channelId, onStartDM }: MessageListProps) {
|
|
|
213
226
|
)}
|
|
214
227
|
</div>
|
|
215
228
|
|
|
216
|
-
|
|
229
|
+
{canWrite && (
|
|
230
|
+
<MessageInput onSend={(content) => send(content)} placeholder="Type a message..." />
|
|
231
|
+
)}
|
|
217
232
|
</div>
|
|
218
233
|
|
|
219
234
|
{threadMessageId && (
|
|
@@ -224,25 +239,27 @@ export function MessageList({ channelId, onStartDM }: MessageListProps) {
|
|
|
224
239
|
/>
|
|
225
240
|
)}
|
|
226
241
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
242
|
+
{canWrite && (
|
|
243
|
+
<MessageActionSheet
|
|
244
|
+
visible={!!actionSheet}
|
|
245
|
+
onClose={() => setActionSheet(null)}
|
|
246
|
+
isOwn={actionSheet?.isOwn ?? false}
|
|
247
|
+
onReaction={(emoji) => {
|
|
248
|
+
if (actionSheet) toggleReaction(actionSheet.messageId, emoji)
|
|
249
|
+
}}
|
|
250
|
+
onEdit={() => {
|
|
251
|
+
if (actionSheet) setEditingMessageId(actionSheet.messageId)
|
|
252
|
+
}}
|
|
253
|
+
onDelete={() => {
|
|
254
|
+
if (actionSheet) softDelete(actionSheet.messageId)
|
|
255
|
+
}}
|
|
256
|
+
onReply={() => {
|
|
257
|
+
if (actionSheet) setThreadMessageId(actionSheet.messageId)
|
|
258
|
+
}}
|
|
259
|
+
messageContent={actionSheet?.content ?? ''}
|
|
260
|
+
messageRect={actionSheet?.rect ?? null}
|
|
261
|
+
/>
|
|
262
|
+
)}
|
|
246
263
|
</div>
|
|
247
264
|
)
|
|
248
265
|
}
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { useEffect, useRef } from 'react'
|
|
7
|
-
import { useMessages } from 'deepspace'
|
|
7
|
+
import { useMessages, isWriterRole } from 'deepspace'
|
|
8
8
|
import { useReactions } from 'deepspace'
|
|
9
9
|
import { useUserLookup } from 'deepspace'
|
|
10
|
+
import { useUser } from 'deepspace'
|
|
10
11
|
import type { Message } from 'deepspace'
|
|
11
12
|
import type { RecordData } from 'deepspace'
|
|
12
13
|
import { MessageItem } from './MessageItem'
|
|
@@ -20,16 +21,21 @@ interface ThreadPanelProps {
|
|
|
20
21
|
|
|
21
22
|
export function ThreadPanel({ channelId, parentMessageId, onClose }: ThreadPanelProps) {
|
|
22
23
|
const { messages: allMessages } = useMessages(channelId)
|
|
23
|
-
const {
|
|
24
|
+
const {
|
|
25
|
+
messages: replies,
|
|
26
|
+
send,
|
|
27
|
+
edit,
|
|
28
|
+
remove,
|
|
29
|
+
} = useMessages(channelId, {
|
|
24
30
|
parentMessageId,
|
|
25
31
|
})
|
|
26
32
|
const { getReactionsForMessage, toggle: toggleReaction } = useReactions(channelId)
|
|
27
33
|
const { getUser } = useUserLookup()
|
|
34
|
+
const { user } = useUser()
|
|
35
|
+
const canWrite = isWriterRole(user?.role)
|
|
28
36
|
const bottomRef = useRef<HTMLDivElement>(null)
|
|
29
37
|
|
|
30
|
-
const parentMessage = allMessages.find(
|
|
31
|
-
(m: RecordData<Message>) => m.recordId === parentMessageId
|
|
32
|
-
)
|
|
38
|
+
const parentMessage = allMessages.find((m: RecordData<Message>) => m.recordId === parentMessageId)
|
|
33
39
|
|
|
34
40
|
useEffect(() => {
|
|
35
41
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
@@ -58,7 +64,13 @@ export function ThreadPanel({ channelId, parentMessageId, onClose }: ThreadPanel
|
|
|
58
64
|
onClick={onClose}
|
|
59
65
|
className="p-1 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
60
66
|
>
|
|
61
|
-
<svg
|
|
67
|
+
<svg
|
|
68
|
+
className="w-4 h-4"
|
|
69
|
+
fill="none"
|
|
70
|
+
viewBox="0 0 24 24"
|
|
71
|
+
stroke="currentColor"
|
|
72
|
+
strokeWidth={2}
|
|
73
|
+
>
|
|
62
74
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
63
75
|
</svg>
|
|
64
76
|
</button>
|
|
@@ -72,7 +84,10 @@ export function ThreadPanel({ channelId, parentMessageId, onClose }: ThreadPanel
|
|
|
72
84
|
</span>
|
|
73
85
|
<span className="text-xs text-muted-foreground">{parentTime}</span>
|
|
74
86
|
</div>
|
|
75
|
-
<p
|
|
87
|
+
<p
|
|
88
|
+
className="text-sm text-foreground/90 whitespace-pre-wrap break-words"
|
|
89
|
+
data-testid="thread-parent-content"
|
|
90
|
+
>
|
|
76
91
|
{parentMessage.data.content}
|
|
77
92
|
</p>
|
|
78
93
|
<div className="mt-1.5 text-xs text-muted-foreground">
|
|
@@ -98,10 +113,9 @@ export function ThreadPanel({ channelId, parentMessageId, onClose }: ThreadPanel
|
|
|
98
113
|
<div ref={bottomRef} />
|
|
99
114
|
</div>
|
|
100
115
|
|
|
101
|
-
|
|
102
|
-
onSend={(content) => send(content, parentMessageId)}
|
|
103
|
-
|
|
104
|
-
/>
|
|
116
|
+
{canWrite && (
|
|
117
|
+
<MessageInput onSend={(content) => send(content, parentMessageId)} placeholder="Reply..." />
|
|
118
|
+
)}
|
|
105
119
|
</div>
|
|
106
120
|
)
|
|
107
121
|
}
|
|
@@ -5,13 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
import { useState, useRef, useEffect, useCallback, type ReactNode } from 'react'
|
|
7
7
|
import { useUserLookup } from 'deepspace'
|
|
8
|
-
import { useUser } from 'deepspace'
|
|
9
8
|
import { usePresence } from 'deepspace'
|
|
10
9
|
|
|
11
10
|
interface UserProfilePopoverProps {
|
|
12
11
|
userId: string
|
|
13
12
|
children: ReactNode
|
|
14
|
-
onStartDM?: (userId: string) => void
|
|
15
13
|
}
|
|
16
14
|
|
|
17
15
|
function formatLastSeen(lastSeenAt: string | undefined): string {
|
|
@@ -28,9 +26,8 @@ function formatLastSeen(lastSeenAt: string | undefined): string {
|
|
|
28
26
|
|
|
29
27
|
const POPOVER_HEIGHT_ESTIMATE = 180
|
|
30
28
|
|
|
31
|
-
export function UserProfilePopover({ userId, children
|
|
29
|
+
export function UserProfilePopover({ userId, children }: UserProfilePopoverProps) {
|
|
32
30
|
const { getUser } = useUserLookup()
|
|
33
|
-
const { user: currentUser } = useUser()
|
|
34
31
|
const { isOnline, users } = usePresence()
|
|
35
32
|
const [open, setOpen] = useState(false)
|
|
36
33
|
const [placement, setPlacement] = useState<'below' | 'above'>('below')
|
|
@@ -49,9 +46,7 @@ export function UserProfilePopover({ userId, children, onStartDM }: UserProfileP
|
|
|
49
46
|
const rect = triggerRef.current.getBoundingClientRect()
|
|
50
47
|
const spaceBelow = window.innerHeight - rect.bottom
|
|
51
48
|
const inBottomHalf = rect.top > window.innerHeight / 2
|
|
52
|
-
setPlacement(
|
|
53
|
-
spaceBelow < POPOVER_HEIGHT_ESTIMATE || inBottomHalf ? 'above' : 'below'
|
|
54
|
-
)
|
|
49
|
+
setPlacement(spaceBelow < POPOVER_HEIGHT_ESTIMATE || inBottomHalf ? 'above' : 'below')
|
|
55
50
|
}
|
|
56
51
|
return !prev
|
|
57
52
|
})
|
|
@@ -61,8 +56,10 @@ export function UserProfilePopover({ userId, children, onStartDM }: UserProfileP
|
|
|
61
56
|
if (!open) return
|
|
62
57
|
function handleClickOutside(e: MouseEvent) {
|
|
63
58
|
if (
|
|
64
|
-
popoverRef.current &&
|
|
65
|
-
|
|
59
|
+
popoverRef.current &&
|
|
60
|
+
!popoverRef.current.contains(e.target as Node) &&
|
|
61
|
+
triggerRef.current &&
|
|
62
|
+
!triggerRef.current.contains(e.target as Node)
|
|
66
63
|
) {
|
|
67
64
|
close()
|
|
68
65
|
}
|
|
@@ -81,9 +78,7 @@ export function UserProfilePopover({ userId, children, onStartDM }: UserProfileP
|
|
|
81
78
|
}
|
|
82
79
|
}, [open, close])
|
|
83
80
|
|
|
84
|
-
const positionClass = placement === 'above'
|
|
85
|
-
? 'bottom-full mb-2 left-0'
|
|
86
|
-
: 'top-full mt-2 left-0'
|
|
81
|
+
const positionClass = placement === 'above' ? 'bottom-full mb-2 left-0' : 'top-full mt-2 left-0'
|
|
87
82
|
|
|
88
83
|
return (
|
|
89
84
|
<span className="relative inline-flex">
|
|
@@ -118,7 +113,9 @@ export function UserProfilePopover({ userId, children, onStartDM }: UserProfileP
|
|
|
118
113
|
/>
|
|
119
114
|
</div>
|
|
120
115
|
<div className="flex-1 min-w-0">
|
|
121
|
-
<p className="text-sm font-semibold text-foreground truncate">
|
|
116
|
+
<p className="text-sm font-semibold text-foreground truncate">
|
|
117
|
+
{user.name ?? 'Unknown'}
|
|
118
|
+
</p>
|
|
122
119
|
{roomUser?.role && (
|
|
123
120
|
<span className="inline-block mt-0.5 px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground">
|
|
124
121
|
{roomUser.role}
|
|
@@ -135,22 +132,6 @@ export function UserProfilePopover({ userId, children, onStartDM }: UserProfileP
|
|
|
135
132
|
{online ? 'Online' : `Last seen ${formatLastSeen(roomUser?.lastSeenAt)}`}
|
|
136
133
|
</p>
|
|
137
134
|
</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
135
|
</div>
|
|
155
136
|
)}
|
|
156
137
|
</span>
|