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
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ChannelListItem -- Single conversation row in the channel sidebar.
|
|
3
|
-
*
|
|
4
|
-
* Visual treatment varies by type:
|
|
5
|
-
* - Groups: colored icon badge with group/lock icon
|
|
6
|
-
* - DMs: user avatar with online presence ring
|
|
7
|
-
*
|
|
8
|
-
* Active state uses a left accent bar + tinted background.
|
|
9
|
-
* Unread state uses bold text + pulsing dot indicator.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import type { ReactNode } from 'react'
|
|
13
|
-
|
|
14
|
-
interface ChannelListItemProps {
|
|
15
|
-
name: string
|
|
16
|
-
isActive: boolean
|
|
17
|
-
hasUnread: boolean
|
|
18
|
-
isPrivate?: boolean
|
|
19
|
-
avatarUrl?: string
|
|
20
|
-
avatarFallback?: string
|
|
21
|
-
isOnline?: boolean
|
|
22
|
-
onClick: () => void
|
|
23
|
-
trailing?: ReactNode
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const GROUP_COLORS = [
|
|
27
|
-
'from-primary/20 to-primary/10 text-primary',
|
|
28
|
-
'from-primary/15 to-primary/25 text-primary',
|
|
29
|
-
'from-primary/10 to-primary/20 text-primary',
|
|
30
|
-
'from-primary/25 to-primary/15 text-primary',
|
|
31
|
-
'from-primary/20 to-primary/5 text-primary',
|
|
32
|
-
'from-primary/5 to-primary/20 text-primary',
|
|
33
|
-
]
|
|
34
|
-
|
|
35
|
-
function hashToIndex(str: string, max: number): number {
|
|
36
|
-
let hash = 0
|
|
37
|
-
for (let i = 0; i < str.length; i++) {
|
|
38
|
-
hash = str.charCodeAt(i) + ((hash << 5) - hash)
|
|
39
|
-
}
|
|
40
|
-
return Math.abs(hash) % max
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function ChannelListItem({
|
|
44
|
-
name,
|
|
45
|
-
isActive,
|
|
46
|
-
hasUnread,
|
|
47
|
-
isPrivate,
|
|
48
|
-
avatarUrl,
|
|
49
|
-
avatarFallback,
|
|
50
|
-
isOnline,
|
|
51
|
-
onClick,
|
|
52
|
-
trailing,
|
|
53
|
-
}: ChannelListItemProps) {
|
|
54
|
-
const showAvatar = avatarUrl !== undefined || avatarFallback !== undefined
|
|
55
|
-
const colorIdx = hashToIndex(name, GROUP_COLORS.length)
|
|
56
|
-
|
|
57
|
-
return (
|
|
58
|
-
<button
|
|
59
|
-
type="button"
|
|
60
|
-
onClick={onClick}
|
|
61
|
-
data-testid={`channel-link-${name}`}
|
|
62
|
-
className={`
|
|
63
|
-
group/item relative w-full flex items-center gap-3 px-3 py-2.5 rounded-xl
|
|
64
|
-
text-left transition-all duration-200 cursor-pointer
|
|
65
|
-
${isActive
|
|
66
|
-
? 'bg-primary/8 shadow-sm shadow-primary/5'
|
|
67
|
-
: 'hover:bg-muted/50 active:bg-muted/70'
|
|
68
|
-
}
|
|
69
|
-
`}
|
|
70
|
-
>
|
|
71
|
-
{/* Active indicator bar */}
|
|
72
|
-
{isActive && (
|
|
73
|
-
<span className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-r-full bg-primary" />
|
|
74
|
-
)}
|
|
75
|
-
|
|
76
|
-
{/* Avatar / icon */}
|
|
77
|
-
{showAvatar ? (
|
|
78
|
-
<div className="relative shrink-0">
|
|
79
|
-
{avatarUrl ? (
|
|
80
|
-
<img
|
|
81
|
-
src={avatarUrl}
|
|
82
|
-
alt=""
|
|
83
|
-
className={`w-9 h-9 rounded-full object-cover ring-2 transition-all duration-200 ${
|
|
84
|
-
isActive
|
|
85
|
-
? 'ring-primary/20'
|
|
86
|
-
: 'ring-transparent group-hover/item:ring-muted'
|
|
87
|
-
}`}
|
|
88
|
-
/>
|
|
89
|
-
) : (
|
|
90
|
-
<div
|
|
91
|
-
className={`w-9 h-9 rounded-full bg-gradient-to-br ${GROUP_COLORS[colorIdx]} flex items-center justify-center text-sm font-semibold ring-2 transition-all duration-200 ${
|
|
92
|
-
isActive
|
|
93
|
-
? 'ring-primary/20'
|
|
94
|
-
: 'ring-transparent group-hover/item:ring-muted'
|
|
95
|
-
}`}
|
|
96
|
-
>
|
|
97
|
-
{avatarFallback?.[0]?.toUpperCase() ?? '?'}
|
|
98
|
-
</div>
|
|
99
|
-
)}
|
|
100
|
-
{isOnline !== undefined && (
|
|
101
|
-
<span
|
|
102
|
-
className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-[2.5px] border-card transition-colors duration-200 ${
|
|
103
|
-
isOnline ? 'bg-success' : 'bg-muted-foreground/20'
|
|
104
|
-
}`}
|
|
105
|
-
/>
|
|
106
|
-
)}
|
|
107
|
-
</div>
|
|
108
|
-
) : (
|
|
109
|
-
<span
|
|
110
|
-
className={`shrink-0 w-9 h-9 rounded-xl bg-gradient-to-br ${GROUP_COLORS[colorIdx]} flex items-center justify-center transition-all duration-200`}
|
|
111
|
-
>
|
|
112
|
-
{isPrivate ? (
|
|
113
|
-
<svg className="w-4 h-4" 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-4 h-4" 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
|
-
)}
|
|
123
|
-
|
|
124
|
-
{/* Name + meta */}
|
|
125
|
-
<div className="flex-1 min-w-0">
|
|
126
|
-
<span
|
|
127
|
-
className={`block text-[13px] leading-tight truncate transition-colors duration-150 ${
|
|
128
|
-
isActive
|
|
129
|
-
? 'font-semibold text-foreground'
|
|
130
|
-
: hasUnread
|
|
131
|
-
? 'font-semibold text-foreground'
|
|
132
|
-
: 'font-medium text-muted-foreground group-hover/item:text-foreground'
|
|
133
|
-
}`}
|
|
134
|
-
>
|
|
135
|
-
{name}
|
|
136
|
-
</span>
|
|
137
|
-
</div>
|
|
138
|
-
|
|
139
|
-
{/* Unread dot */}
|
|
140
|
-
{hasUnread && !isActive && (
|
|
141
|
-
<span className="shrink-0 w-2.5 h-2.5 rounded-full bg-primary animate-pulse" />
|
|
142
|
-
)}
|
|
143
|
-
|
|
144
|
-
{trailing}
|
|
145
|
-
</button>
|
|
146
|
-
)
|
|
147
|
-
}
|
|
@@ -1,344 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ChannelSettingsPanel -- View/edit group info, manage members, leave.
|
|
3
|
-
* Renders as a slide-over panel on the right side of the chat.
|
|
4
|
-
*
|
|
5
|
-
* Permissions:
|
|
6
|
-
* - Public groups: no invite button (users join via Browse Groups)
|
|
7
|
-
* - Private groups: only the creator can invite others (invitation-based)
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { useState, useCallback, useMemo } from 'react'
|
|
11
|
-
import { useChannels } from 'deepspace'
|
|
12
|
-
import { useChannelMembers } from 'deepspace'
|
|
13
|
-
import { useUser } from 'deepspace'
|
|
14
|
-
import { useUserLookup } from 'deepspace'
|
|
15
|
-
import { useQuery } from 'deepspace'
|
|
16
|
-
import type { Channel, ChannelInvitation } from 'deepspace'
|
|
17
|
-
import type { RecordData } from 'deepspace'
|
|
18
|
-
import { AddMemberModal } from './AddMemberModal'
|
|
19
|
-
|
|
20
|
-
interface ChannelSettingsPanelProps {
|
|
21
|
-
channelId: string
|
|
22
|
-
onClose: () => void
|
|
23
|
-
onLeave: () => void
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function ChannelSettingsPanel({ channelId, onClose, onLeave }: ChannelSettingsPanelProps) {
|
|
27
|
-
const { channels, update } = useChannels()
|
|
28
|
-
const { members, leave } = useChannelMembers(channelId)
|
|
29
|
-
const { user } = useUser()
|
|
30
|
-
const { getUser } = useUserLookup()
|
|
31
|
-
const [showAddMember, setShowAddMember] = useState(false)
|
|
32
|
-
const [isEditing, setIsEditing] = useState(false)
|
|
33
|
-
const [editName, setEditName] = useState('')
|
|
34
|
-
const [editDescription, setEditDescription] = useState('')
|
|
35
|
-
const [isSaving, setIsSaving] = useState(false)
|
|
36
|
-
|
|
37
|
-
const channel = channels.find((c: RecordData<Channel>) => c.recordId === channelId)
|
|
38
|
-
const isCreator = channel?.createdBy === user?.id
|
|
39
|
-
const isPrivate = channel?.data.type === 'private'
|
|
40
|
-
const canInvite = isPrivate && isCreator
|
|
41
|
-
|
|
42
|
-
const { records: channelInvitations } = useQuery<ChannelInvitation>('channel-invitations', {
|
|
43
|
-
where: { channelId },
|
|
44
|
-
})
|
|
45
|
-
const pendingInvites = useMemo(
|
|
46
|
-
() => channelInvitations.filter(
|
|
47
|
-
(inv: RecordData<ChannelInvitation>) => inv.data.status === 'pending'
|
|
48
|
-
),
|
|
49
|
-
[channelInvitations]
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
const sortedMembers = useMemo(
|
|
53
|
-
() =>
|
|
54
|
-
[...members].sort((a, b) => {
|
|
55
|
-
const aUser = getUser(a.data.userId)
|
|
56
|
-
const bUser = getUser(b.data.userId)
|
|
57
|
-
return (aUser?.name ?? '').localeCompare(bUser?.name ?? '')
|
|
58
|
-
}),
|
|
59
|
-
[members, getUser]
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
const handleLeave = useCallback(async () => {
|
|
63
|
-
await leave()
|
|
64
|
-
onLeave()
|
|
65
|
-
}, [leave, onLeave])
|
|
66
|
-
|
|
67
|
-
const startEditing = useCallback(() => {
|
|
68
|
-
if (!channel) return
|
|
69
|
-
setEditName(channel.data.name)
|
|
70
|
-
setEditDescription(channel.data.description ?? '')
|
|
71
|
-
setIsEditing(true)
|
|
72
|
-
}, [channel])
|
|
73
|
-
|
|
74
|
-
const cancelEditing = useCallback(() => {
|
|
75
|
-
setIsEditing(false)
|
|
76
|
-
}, [])
|
|
77
|
-
|
|
78
|
-
const saveEditing = useCallback(async () => {
|
|
79
|
-
if (!channel || isSaving) return
|
|
80
|
-
const trimmedName = editName.trim().toLowerCase().replace(/\s+/g, '-')
|
|
81
|
-
if (!trimmedName) return
|
|
82
|
-
|
|
83
|
-
setIsSaving(true)
|
|
84
|
-
try {
|
|
85
|
-
await update(channelId, {
|
|
86
|
-
name: trimmedName,
|
|
87
|
-
description: editDescription.trim(),
|
|
88
|
-
})
|
|
89
|
-
setIsEditing(false)
|
|
90
|
-
} finally {
|
|
91
|
-
setIsSaving(false)
|
|
92
|
-
}
|
|
93
|
-
}, [channel, isSaving, editName, editDescription, update, channelId])
|
|
94
|
-
|
|
95
|
-
if (!channel) return null
|
|
96
|
-
|
|
97
|
-
return (
|
|
98
|
-
<>
|
|
99
|
-
<div
|
|
100
|
-
data-testid="channel-settings-panel"
|
|
101
|
-
className="w-full md:w-80 border-l border-border flex flex-col h-full bg-card shrink-0"
|
|
102
|
-
>
|
|
103
|
-
{/* Header */}
|
|
104
|
-
<div className="shrink-0 h-12 border-b border-border px-4 flex items-center justify-between">
|
|
105
|
-
<h3 className="font-semibold text-sm text-foreground">Group Info</h3>
|
|
106
|
-
<button
|
|
107
|
-
data-testid="close-settings-btn"
|
|
108
|
-
onClick={onClose}
|
|
109
|
-
className="p-1 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
110
|
-
>
|
|
111
|
-
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
112
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
113
|
-
</svg>
|
|
114
|
-
</button>
|
|
115
|
-
</div>
|
|
116
|
-
|
|
117
|
-
<div className="flex-1 overflow-y-auto">
|
|
118
|
-
{/* Channel info */}
|
|
119
|
-
<div className="px-4 py-5">
|
|
120
|
-
{isEditing ? (
|
|
121
|
-
<div className="space-y-3">
|
|
122
|
-
<div>
|
|
123
|
-
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
|
124
|
-
Group name
|
|
125
|
-
</label>
|
|
126
|
-
<input
|
|
127
|
-
data-testid="edit-channel-name"
|
|
128
|
-
type="text"
|
|
129
|
-
value={editName}
|
|
130
|
-
onChange={(e) => setEditName(e.target.value)}
|
|
131
|
-
onKeyDown={(e) => {
|
|
132
|
-
if (e.key === 'Enter') saveEditing()
|
|
133
|
-
if (e.key === 'Escape') cancelEditing()
|
|
134
|
-
}}
|
|
135
|
-
className="w-full bg-background border border-border rounded-lg px-3 py-2 text-sm text-foreground outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20 transition-shadow"
|
|
136
|
-
autoFocus
|
|
137
|
-
/>
|
|
138
|
-
</div>
|
|
139
|
-
<div>
|
|
140
|
-
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
|
141
|
-
Description
|
|
142
|
-
</label>
|
|
143
|
-
<textarea
|
|
144
|
-
data-testid="edit-channel-description"
|
|
145
|
-
value={editDescription}
|
|
146
|
-
onChange={(e) => setEditDescription(e.target.value)}
|
|
147
|
-
onKeyDown={(e) => {
|
|
148
|
-
if (e.key === 'Escape') cancelEditing()
|
|
149
|
-
}}
|
|
150
|
-
rows={2}
|
|
151
|
-
className="w-full bg-background border border-border rounded-lg px-3 py-2 text-sm text-foreground outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20 transition-shadow resize-none"
|
|
152
|
-
/>
|
|
153
|
-
</div>
|
|
154
|
-
<div className="flex gap-2">
|
|
155
|
-
<button
|
|
156
|
-
data-testid="save-channel-edit"
|
|
157
|
-
onClick={saveEditing}
|
|
158
|
-
disabled={isSaving || !editName.trim()}
|
|
159
|
-
className="flex-1 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"
|
|
160
|
-
>
|
|
161
|
-
{isSaving ? 'Saving...' : 'Save'}
|
|
162
|
-
</button>
|
|
163
|
-
<button
|
|
164
|
-
data-testid="cancel-channel-edit"
|
|
165
|
-
onClick={cancelEditing}
|
|
166
|
-
disabled={isSaving}
|
|
167
|
-
className="flex-1 px-3 py-1.5 text-xs font-medium text-muted-foreground border border-border rounded-lg hover:bg-muted transition-colors"
|
|
168
|
-
>
|
|
169
|
-
Cancel
|
|
170
|
-
</button>
|
|
171
|
-
</div>
|
|
172
|
-
</div>
|
|
173
|
-
) : (
|
|
174
|
-
<>
|
|
175
|
-
<div className="flex items-center gap-3">
|
|
176
|
-
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center shrink-0">
|
|
177
|
-
{isPrivate ? (
|
|
178
|
-
<svg className="w-5 h-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
179
|
-
<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" />
|
|
180
|
-
</svg>
|
|
181
|
-
) : (
|
|
182
|
-
<svg className="w-5 h-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
183
|
-
<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" />
|
|
184
|
-
</svg>
|
|
185
|
-
)}
|
|
186
|
-
</div>
|
|
187
|
-
<div className="flex-1 min-w-0">
|
|
188
|
-
<h4 className="text-base font-semibold text-foreground truncate">
|
|
189
|
-
{channel.data.name}
|
|
190
|
-
</h4>
|
|
191
|
-
<span className="text-xs text-muted-foreground capitalize">
|
|
192
|
-
{channel.data.type} group
|
|
193
|
-
</span>
|
|
194
|
-
</div>
|
|
195
|
-
{isCreator && (
|
|
196
|
-
<button
|
|
197
|
-
data-testid="edit-channel-btn"
|
|
198
|
-
onClick={startEditing}
|
|
199
|
-
className="shrink-0 p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
200
|
-
title="Edit group"
|
|
201
|
-
>
|
|
202
|
-
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
203
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
|
204
|
-
</svg>
|
|
205
|
-
</button>
|
|
206
|
-
)}
|
|
207
|
-
</div>
|
|
208
|
-
{channel.data.description && (
|
|
209
|
-
<p className="mt-3 text-sm text-muted-foreground leading-relaxed">
|
|
210
|
-
{channel.data.description}
|
|
211
|
-
</p>
|
|
212
|
-
)}
|
|
213
|
-
</>
|
|
214
|
-
)}
|
|
215
|
-
</div>
|
|
216
|
-
|
|
217
|
-
<div className="h-px bg-border mx-4" />
|
|
218
|
-
|
|
219
|
-
{/* Members section */}
|
|
220
|
-
<div className="px-4 py-4">
|
|
221
|
-
<div className="flex items-center justify-between mb-3">
|
|
222
|
-
<h4 className="text-sm font-semibold text-foreground">
|
|
223
|
-
Members
|
|
224
|
-
<span className="text-muted-foreground font-normal ml-1.5">
|
|
225
|
-
{members.length}
|
|
226
|
-
</span>
|
|
227
|
-
</h4>
|
|
228
|
-
{canInvite && (
|
|
229
|
-
<button
|
|
230
|
-
data-testid="add-member-btn"
|
|
231
|
-
onClick={() => setShowAddMember(true)}
|
|
232
|
-
className="text-xs font-medium text-primary hover:text-primary/80 transition-colors"
|
|
233
|
-
>
|
|
234
|
-
Invite member
|
|
235
|
-
</button>
|
|
236
|
-
)}
|
|
237
|
-
</div>
|
|
238
|
-
|
|
239
|
-
<div className="space-y-0.5">
|
|
240
|
-
{sortedMembers.map((m) => {
|
|
241
|
-
const memberUser = getUser(m.data.userId)
|
|
242
|
-
const isCurrentUser = m.data.userId === user?.id
|
|
243
|
-
const isMemberCreator = m.data.userId === channel.createdBy
|
|
244
|
-
|
|
245
|
-
return (
|
|
246
|
-
<div
|
|
247
|
-
key={m.recordId}
|
|
248
|
-
className="flex items-center gap-2.5 px-2 py-2 rounded-lg"
|
|
249
|
-
>
|
|
250
|
-
{memberUser?.imageUrl ? (
|
|
251
|
-
<img src={memberUser.imageUrl} alt="" className="w-8 h-8 rounded-full object-cover" />
|
|
252
|
-
) : (
|
|
253
|
-
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center text-xs font-semibold text-primary">
|
|
254
|
-
{(memberUser?.name ?? '?')[0]?.toUpperCase()}
|
|
255
|
-
</div>
|
|
256
|
-
)}
|
|
257
|
-
<div className="flex-1 min-w-0">
|
|
258
|
-
<span className="text-sm text-foreground truncate block">
|
|
259
|
-
{memberUser?.name ?? 'Unknown'}
|
|
260
|
-
{isCurrentUser && (
|
|
261
|
-
<span className="text-muted-foreground ml-1">(you)</span>
|
|
262
|
-
)}
|
|
263
|
-
</span>
|
|
264
|
-
</div>
|
|
265
|
-
{isMemberCreator && (
|
|
266
|
-
<span className="text-[10px] font-medium text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
|
267
|
-
Creator
|
|
268
|
-
</span>
|
|
269
|
-
)}
|
|
270
|
-
</div>
|
|
271
|
-
)
|
|
272
|
-
})}
|
|
273
|
-
</div>
|
|
274
|
-
</div>
|
|
275
|
-
|
|
276
|
-
{/* Pending invitations (private groups only) */}
|
|
277
|
-
{isPrivate && pendingInvites.length > 0 && (
|
|
278
|
-
<>
|
|
279
|
-
<div className="h-px bg-border mx-4" />
|
|
280
|
-
<div className="px-4 py-4">
|
|
281
|
-
<h4 className="text-sm font-semibold text-foreground mb-3">
|
|
282
|
-
Pending Invitations
|
|
283
|
-
<span className="text-muted-foreground font-normal ml-1.5">
|
|
284
|
-
{pendingInvites.length}
|
|
285
|
-
</span>
|
|
286
|
-
</h4>
|
|
287
|
-
<div className="space-y-0.5">
|
|
288
|
-
{pendingInvites.map((inv: RecordData<ChannelInvitation>) => {
|
|
289
|
-
const invitedUser = getUser(inv.data.invitedUserId)
|
|
290
|
-
return (
|
|
291
|
-
<div
|
|
292
|
-
key={inv.recordId}
|
|
293
|
-
className="flex items-center gap-2.5 px-2 py-2 rounded-lg"
|
|
294
|
-
>
|
|
295
|
-
{invitedUser?.imageUrl ? (
|
|
296
|
-
<img src={invitedUser.imageUrl} alt="" className="w-8 h-8 rounded-full object-cover opacity-60" />
|
|
297
|
-
) : (
|
|
298
|
-
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center text-xs font-semibold text-muted-foreground">
|
|
299
|
-
{(invitedUser?.name ?? '?')[0]?.toUpperCase()}
|
|
300
|
-
</div>
|
|
301
|
-
)}
|
|
302
|
-
<div className="flex-1 min-w-0">
|
|
303
|
-
<span className="text-sm text-muted-foreground truncate block">
|
|
304
|
-
{invitedUser?.name ?? 'Unknown'}
|
|
305
|
-
</span>
|
|
306
|
-
</div>
|
|
307
|
-
<span className="text-[10px] font-medium text-warning bg-warning/10 px-1.5 py-0.5 rounded">
|
|
308
|
-
Pending
|
|
309
|
-
</span>
|
|
310
|
-
</div>
|
|
311
|
-
)
|
|
312
|
-
})}
|
|
313
|
-
</div>
|
|
314
|
-
</div>
|
|
315
|
-
</>
|
|
316
|
-
)}
|
|
317
|
-
|
|
318
|
-
<div className="h-px bg-border mx-4" />
|
|
319
|
-
|
|
320
|
-
{/* Leave group */}
|
|
321
|
-
<div className="px-4 py-4">
|
|
322
|
-
<button
|
|
323
|
-
data-testid="leave-group-btn"
|
|
324
|
-
onClick={handleLeave}
|
|
325
|
-
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm text-destructive hover:bg-destructive/10 transition-colors"
|
|
326
|
-
>
|
|
327
|
-
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
328
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
|
329
|
-
</svg>
|
|
330
|
-
Leave group
|
|
331
|
-
</button>
|
|
332
|
-
</div>
|
|
333
|
-
</div>
|
|
334
|
-
</div>
|
|
335
|
-
|
|
336
|
-
{showAddMember && (
|
|
337
|
-
<AddMemberModal
|
|
338
|
-
channelId={channelId}
|
|
339
|
-
onClose={() => setShowAddMember(false)}
|
|
340
|
-
/>
|
|
341
|
-
)}
|
|
342
|
-
</>
|
|
343
|
-
)
|
|
344
|
-
}
|