ChannelSettingsPanel.tsx raw
1 import { useChat } from '@/providers/ChatProvider'
2 import { useNostr } from '@/providers/NostrProvider'
3 import { useFetchProfile } from '@/hooks/useFetchProfile'
4 import { Pubkey } from '@/domain'
5 import { TAccessMode, EXPIRY_OPTIONS, DEFAULT_MESSAGE_EXPIRY } from '@/services/chat.service'
6 import {
7 Lock,
8 LockOpen,
9 Globe,
10 UserPlus,
11 UserMinus,
12 ShieldPlus,
13 ShieldMinus,
14 UserCheck,
15 UserX,
16 Mail,
17 MailX,
18 Undo2,
19 X,
20 Bell,
21 BellOff
22 } from 'lucide-react'
23 import { useState } from 'react'
24 import { Button } from '../ui/button'
25
26 type TSubmitKey = 'enter' | 'ctrl+enter'
27
28 function loadSubmitKey(): TSubmitKey {
29 const v = localStorage.getItem('nirc:submitKey')
30 return v === 'enter' ? 'enter' : 'ctrl+enter'
31 }
32
33 export default function ChannelSettingsPanel({ onClose }: { onClose: () => void }) {
34 const {
35 currentChannel,
36 channelMods,
37 channelMembers,
38 channelBlocked,
39 channelInvited,
40 channelRequested,
41 channelRejected,
42 channelAccessMode,
43 isOwnerOrMod,
44 addMod,
45 removeMod,
46 approveMember,
47 removeMember,
48 unblockUser,
49 updateAccessMode,
50 updateMessageExpiry,
51 sendInvite,
52 revokeInvite,
53 acceptRequest,
54 rejectRequest,
55 revokeRejection,
56 mutedChannels,
57 toggleMuteChannel
58 } = useChat()
59 const { pubkey } = useNostr()
60 const [addInput, setAddInput] = useState('')
61 const [addMode, setAddMode] = useState<'member' | 'mod' | 'invite'>('member')
62 const [submitKey, setSubmitKey] = useState<TSubmitKey>(loadSubmitKey)
63
64 if (!currentChannel) return null
65
66 const isOwner = currentChannel.creator === pubkey
67 const isMuted = mutedChannels.has(currentChannel.id)
68
69 const handleAdd = async () => {
70 const pk = addInput.trim()
71 if (!pk) return
72 let hexPk = pk
73 const parsed = Pubkey.tryFromString(pk)
74 if (parsed) hexPk = parsed.hex
75 if (addMode === 'mod') {
76 await addMod(hexPk)
77 } else if (addMode === 'invite') {
78 await sendInvite(hexPk)
79 } else {
80 await approveMember(hexPk)
81 }
82 setAddInput('')
83 }
84
85 const accessModes: { mode: TAccessMode; label: string; icon: React.ReactNode }[] = [
86 { mode: 'open', label: 'Open', icon: <Globe className="size-3" /> },
87 { mode: 'whitelist', label: 'Whitelist', icon: <Lock className="size-3" /> },
88 { mode: 'blacklist', label: 'Blacklist', icon: <LockOpen className="size-3" /> }
89 ]
90
91 return (
92 <div className="absolute inset-0 z-20 bg-background overflow-y-auto">
93 <div className="max-w-lg mx-auto p-4 space-y-5">
94 {/* Header */}
95 <div className="flex items-center justify-between">
96 <span className="font-semibold text-sm">Settings — #{currentChannel.name}</span>
97 <Button variant="ghost" size="icon" className="size-7" onClick={onClose}>
98 <X className="size-4" />
99 </Button>
100 </div>
101
102 {/* --- Chat Settings (all users) --- */}
103 <section className="space-y-2">
104 <h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Chat</h3>
105 <div className="space-y-2">
106 <div className="flex items-center justify-between">
107 <span className="text-xs text-muted-foreground">Send message with</span>
108 <div className="flex gap-1">
109 {(['enter', 'ctrl+enter'] as const).map((key) => (
110 <button
111 key={key}
112 className={`text-xs px-2 py-1 rounded border ${submitKey === key ? 'bg-primary text-primary-foreground border-primary' : 'border-border'}`}
113 onClick={() => {
114 setSubmitKey(key)
115 localStorage.setItem('nirc:submitKey', key)
116 }}
117 >
118 {key === 'enter' ? 'Enter' : 'Ctrl+Enter'}
119 </button>
120 ))}
121 </div>
122 </div>
123 <div className="text-[10px] text-muted-foreground">
124 {submitKey === 'enter' ? 'Shift+Enter for newline' : 'Enter for newline'}
125 </div>
126 <div className="flex items-center justify-between">
127 <span className="text-xs text-muted-foreground">Notifications</span>
128 <Button
129 variant="outline"
130 size="sm"
131 className="h-7 text-xs gap-1"
132 onClick={() => toggleMuteChannel(currentChannel.id)}
133 >
134 {isMuted ? <BellOff className="size-3" /> : <Bell className="size-3" />}
135 {isMuted ? 'Muted' : 'Active'}
136 </Button>
137 </div>
138 </div>
139 </section>
140
141 {/* --- Access Mode (owner only) --- */}
142 {isOwner && (
143 <section className="space-y-2">
144 <h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Access Mode</h3>
145 <div className="flex gap-1">
146 {accessModes.map(({ mode, label, icon }) => (
147 <button
148 key={mode}
149 className={`text-xs px-3 py-1.5 rounded border flex items-center gap-1.5 ${channelAccessMode === mode ? 'bg-primary text-primary-foreground border-primary' : 'border-border'}`}
150 onClick={() => updateAccessMode(mode)}
151 >
152 {icon} {label}
153 </button>
154 ))}
155 </div>
156 <div className="text-[10px] text-muted-foreground">
157 {channelAccessMode === 'open' && 'Anyone authenticated can read and write.'}
158 {channelAccessMode === 'whitelist' && 'Only listed members, mods, and invitees can access.'}
159 {channelAccessMode === 'blacklist' && 'Everyone except excluded users can access.'}
160 </div>
161 </section>
162 )}
163
164 {/* --- Message Expiry (owner only) --- */}
165 {isOwner && (
166 <section className="space-y-2">
167 <h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Message Expiry</h3>
168 <div className="flex flex-wrap gap-1">
169 {EXPIRY_OPTIONS.map(({ label, value }) => (
170 <button
171 key={value}
172 className={`text-xs px-3 py-1.5 rounded border ${
173 (currentChannel.messageExpiry ?? DEFAULT_MESSAGE_EXPIRY) === value
174 ? 'bg-primary text-primary-foreground border-primary'
175 : 'border-border'
176 }`}
177 onClick={() => updateMessageExpiry(value)}
178 >
179 {label}
180 </button>
181 ))}
182 </div>
183 <div className="text-[10px] text-muted-foreground">
184 Messages will include a NIP-40 expiration tag set to this duration from send time.
185 </div>
186 </section>
187 )}
188
189 {/* --- Add member/mod/invite (owner + mods) --- */}
190 {isOwnerOrMod && (
191 <section className="space-y-2">
192 <h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
193 Add User
194 </h3>
195 <div className="flex gap-1">
196 <button
197 className={`text-xs px-2 py-0.5 rounded ${addMode === 'member' ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}
198 onClick={() => setAddMode('member')}
199 >
200 Member
201 </button>
202 <button
203 className={`text-xs px-2 py-0.5 rounded ${addMode === 'invite' ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}
204 onClick={() => setAddMode('invite')}
205 >
206 Invite
207 </button>
208 {isOwner && (
209 <button
210 className={`text-xs px-2 py-0.5 rounded ${addMode === 'mod' ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}
211 onClick={() => setAddMode('mod')}
212 >
213 Mod
214 </button>
215 )}
216 </div>
217 <div className="flex gap-1">
218 <input
219 type="text"
220 placeholder={`Add ${addMode} (npub or hex)`}
221 value={addInput}
222 onChange={(e) => setAddInput(e.target.value)}
223 className="flex-1 px-2 py-1 text-xs border rounded bg-background"
224 onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
225 />
226 <Button variant="outline" size="sm" className="h-7" onClick={handleAdd} disabled={!addInput.trim()}>
227 {addMode === 'mod' ? <ShieldPlus className="size-3" /> : addMode === 'invite' ? <Mail className="size-3" /> : <UserPlus className="size-3" />}
228 </Button>
229 </div>
230 </section>
231 )}
232
233 {/* --- Moderators --- */}
234 {channelMods.length > 0 && (
235 <section className="space-y-1.5">
236 <h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Moderators</h3>
237 {channelMods.map((pk) => (
238 <div key={pk} className="flex items-center justify-between text-xs py-0.5">
239 <span className="font-mono">
240 <PubkeyName hex={pk} />
241 {pk === currentChannel.creator && (
242 <span className="text-muted-foreground ml-1">(owner)</span>
243 )}
244 </span>
245 {isOwner && pk !== currentChannel.creator && (
246 <button
247 onClick={() => removeMod(pk)}
248 className="text-muted-foreground hover:text-destructive"
249 title="Remove mod (cascades invites/blocks)"
250 >
251 <ShieldMinus className="size-3" />
252 </button>
253 )}
254 </div>
255 ))}
256 </section>
257 )}
258
259 {/* --- Members / Excluded (depends on mode) --- */}
260 {channelAccessMode === 'whitelist' && channelMembers.length > 0 && (
261 <section className="space-y-1.5">
262 <h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Allowed Members</h3>
263 {channelMembers.map((entry) => (
264 <div key={entry.pubkey} className="flex items-center justify-between text-xs py-0.5">
265 <span className="font-mono">
266 <PubkeyName hex={entry.pubkey} />
267 {entry.addedBy && (
268 <span className="text-muted-foreground ml-1">
269 via <PubkeyName hex={entry.addedBy} />
270 </span>
271 )}
272 </span>
273 {isOwnerOrMod && (
274 <button
275 onClick={() => removeMember(entry.pubkey)}
276 className="text-muted-foreground hover:text-destructive"
277 title="Remove member"
278 >
279 <UserMinus className="size-3" />
280 </button>
281 )}
282 </div>
283 ))}
284 </section>
285 )}
286
287 {channelAccessMode === 'blacklist' && channelBlocked.length > 0 && (
288 <section className="space-y-1.5">
289 <h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Excluded Users</h3>
290 {channelBlocked.map((entry) => (
291 <div key={entry.pubkey} className="flex items-center justify-between text-xs py-0.5">
292 <span className="font-mono text-destructive">
293 <PubkeyName hex={entry.pubkey} />
294 {entry.addedBy && (
295 <span className="text-muted-foreground ml-1">
296 by <PubkeyName hex={entry.addedBy} />
297 </span>
298 )}
299 </span>
300 {isOwnerOrMod && (
301 <button
302 onClick={() => unblockUser(entry.pubkey)}
303 className="text-muted-foreground hover:text-foreground"
304 title="Remove from excluded"
305 >
306 <UserCheck className="size-3" />
307 </button>
308 )}
309 </div>
310 ))}
311 </section>
312 )}
313
314 {/* --- Invites (owner + mods) --- */}
315 {isOwnerOrMod && channelInvited.length > 0 && (
316 <section className="space-y-1.5">
317 <h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Pending Invites</h3>
318 {channelInvited.map((entry) => (
319 <div key={entry.pubkey} className="flex items-center justify-between text-xs py-0.5">
320 <span className="font-mono">
321 <PubkeyName hex={entry.pubkey} />
322 {entry.addedBy && (
323 <span className="text-muted-foreground ml-1">
324 by <PubkeyName hex={entry.addedBy} />
325 </span>
326 )}
327 </span>
328 <button
329 onClick={() => revokeInvite(entry.pubkey)}
330 className="text-muted-foreground hover:text-destructive"
331 title="Revoke invite"
332 >
333 <MailX className="size-3" />
334 </button>
335 </div>
336 ))}
337 </section>
338 )}
339
340 {/* --- Requests (owner + mods) --- */}
341 {isOwnerOrMod && channelRequested.length > 0 && (
342 <section className="space-y-1.5">
343 <h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Join Requests</h3>
344 {channelRequested.map((pk) => (
345 <div key={pk} className="flex items-center justify-between text-xs py-0.5">
346 <span className="font-mono"><PubkeyName hex={pk} /></span>
347 <div className="flex gap-1">
348 <button
349 onClick={() => acceptRequest(pk)}
350 className="text-muted-foreground hover:text-foreground"
351 title="Accept"
352 >
353 <UserCheck className="size-3" />
354 </button>
355 <button
356 onClick={() => rejectRequest(pk)}
357 className="text-muted-foreground hover:text-destructive"
358 title="Reject"
359 >
360 <UserX className="size-3" />
361 </button>
362 </div>
363 </div>
364 ))}
365 </section>
366 )}
367
368 {/* --- Rejected (owner only can revoke) --- */}
369 {channelRejected.length > 0 && (
370 <section className="space-y-1.5">
371 <h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Rejected</h3>
372 {channelRejected.map((pk) => (
373 <div key={pk} className="flex items-center justify-between text-xs py-0.5">
374 <span className="font-mono text-destructive"><PubkeyName hex={pk} /></span>
375 {isOwner && (
376 <button
377 onClick={() => revokeRejection(pk)}
378 className="text-muted-foreground hover:text-foreground"
379 title="Revoke rejection"
380 >
381 <Undo2 className="size-3" />
382 </button>
383 )}
384 </div>
385 ))}
386 </section>
387 )}
388
389 {/* --- Blocked users (from kind 44 mod actions, always shown) --- */}
390 {channelAccessMode !== 'blacklist' && channelBlocked.length > 0 && (
391 <section className="space-y-1.5">
392 <h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Blocked</h3>
393 {channelBlocked.map((entry) => (
394 <div key={entry.pubkey} className="flex items-center justify-between text-xs py-0.5">
395 <span className="font-mono text-destructive"><PubkeyName hex={entry.pubkey} /></span>
396 {isOwnerOrMod && (
397 <button
398 onClick={() => unblockUser(entry.pubkey)}
399 className="text-muted-foreground hover:text-foreground"
400 title="Unblock"
401 >
402 <UserCheck className="size-3" />
403 </button>
404 )}
405 </div>
406 ))}
407 </section>
408 )}
409 </div>
410 </div>
411 )
412 }
413
414 function PubkeyName({ hex }: { hex: string }) {
415 const { profile } = useFetchProfile(hex)
416 const pk = Pubkey.tryFromString(hex)
417 return <>{profile?.username || pk?.formatNpub(8) || hex.slice(0, 12)}</>
418 }
419