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