index.tsx raw

   1  import MessageView from '@/components/Inbox/MessageView'
   2  import UserAvatar from '@/components/UserAvatar'
   3  import { Button } from '@/components/ui/button'
   4  import { Titlebar } from '@/components/Titlebar'
   5  import { useSecondaryPage } from '@/PageManager'
   6  import { useDM } from '@/providers/DMProvider'
   7  import { useFollowList } from '@/providers/FollowListProvider'
   8  import client from '@/services/client.service'
   9  import { TPageRef, TProfile } from '@/types'
  10  import { ChevronLeft, MoreVertical, RefreshCw, Settings, Trash2, Undo2, Users, X } from 'lucide-react'
  11  import { nip19 } from 'nostr-tools'
  12  import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
  13  import { useTranslation } from 'react-i18next'
  14  import { cn } from '@/lib/utils'
  15  import {
  16    DropdownMenu,
  17    DropdownMenuContent,
  18    DropdownMenuItem,
  19    DropdownMenuTrigger
  20  } from '@/components/ui/dropdown-menu'
  21  import ConversationSettingsModal from '@/components/Inbox/ConversationSettingsModal'
  22  import indexedDb from '@/services/indexed-db.service'
  23  import { useNostr } from '@/providers/NostrProvider'
  24  
  25  interface DMConversationPageProps {
  26    pubkey?: string
  27  }
  28  
  29  const DMConversationPage = forwardRef<TPageRef, DMConversationPageProps>(({ pubkey }, ref) => {
  30    const { t } = useTranslation()
  31    const layoutRef = useRef<TPageRef>(null)
  32    const { pubkey: userPubkey } = useNostr()
  33    const {
  34      selectConversation,
  35      currentConversation,
  36      isLoadingConversation,
  37      isNewConversation,
  38      clearNewConversationFlag,
  39      reloadConversation,
  40      deleteAllInConversation,
  41      undeleteAllInConversation
  42    } = useDM()
  43    const { pop } = useSecondaryPage()
  44    const { followingSet } = useFollowList()
  45    const [profile, setProfile] = useState<TProfile | null>(null)
  46    const [settingsOpen, setSettingsOpen] = useState(false)
  47    const [selectedRelays, setSelectedRelays] = useState<string[]>([])
  48    const [showPulse, setShowPulse] = useState(false)
  49  
  50    // Decode npub to hex if needed
  51    const hexPubkey = useMemo(() => {
  52      if (!pubkey) return null
  53      if (pubkey.startsWith('npub')) {
  54        try {
  55          const decoded = nip19.decode(pubkey)
  56          return decoded.type === 'npub' ? decoded.data : null
  57        } catch {
  58          return null
  59        }
  60      }
  61      return pubkey
  62    }, [pubkey])
  63  
  64    const isFollowing = hexPubkey ? followingSet.has(hexPubkey) : false
  65  
  66    useImperativeHandle(ref, () => layoutRef.current as TPageRef)
  67  
  68    // Select the conversation when this page mounts
  69    useEffect(() => {
  70      if (hexPubkey && hexPubkey !== currentConversation) {
  71        selectConversation(hexPubkey)
  72      }
  73    }, [hexPubkey, selectConversation, currentConversation])
  74  
  75    // Clear conversation when page unmounts
  76    useEffect(() => {
  77      return () => {
  78        selectConversation(null)
  79      }
  80    }, [])
  81  
  82    // Fetch profile
  83    useEffect(() => {
  84      if (!hexPubkey) return
  85  
  86      const fetchProfileData = async () => {
  87        try {
  88          const profileData = await client.fetchProfile(hexPubkey)
  89          if (profileData) {
  90            setProfile(profileData)
  91          }
  92        } catch (error) {
  93          console.error('Failed to fetch profile:', error)
  94        }
  95      }
  96      fetchProfileData()
  97    }, [hexPubkey])
  98  
  99    // Handle pulsing animation for new conversations
 100    useEffect(() => {
 101      if (isNewConversation) {
 102        setShowPulse(true)
 103        const timer = setTimeout(() => {
 104          setShowPulse(false)
 105          clearNewConversationFlag()
 106        }, 10000)
 107        return () => clearTimeout(timer)
 108      }
 109    }, [isNewConversation, clearNewConversationFlag])
 110  
 111    // Load saved relay settings when conversation changes
 112    useEffect(() => {
 113      if (!hexPubkey || !userPubkey) return
 114  
 115      const loadRelaySettings = async () => {
 116        const saved = await indexedDb.getConversationRelaySettings(userPubkey, hexPubkey)
 117        setSelectedRelays(saved || [])
 118      }
 119      loadRelaySettings()
 120    }, [hexPubkey, userPubkey])
 121  
 122    // Save relay settings when they change
 123    const handleRelaysChange = async (relays: string[]) => {
 124      setSelectedRelays(relays)
 125      if (userPubkey && hexPubkey) {
 126        await indexedDb.putConversationRelaySettings(userPubkey, hexPubkey, relays)
 127      }
 128    }
 129  
 130    const handleBack = () => {
 131      selectConversation(null)
 132      pop()
 133    }
 134  
 135    const displayName = profile?.username || (hexPubkey ? hexPubkey.slice(0, 8) + '...' : '')
 136  
 137    // Custom titlebar with user info
 138    const titlebar = (
 139      <div className="flex items-center gap-2 w-full px-1">
 140        <Button
 141          className="flex gap-1 items-center justify-start pl-2 pr-1"
 142          variant="ghost"
 143          size="titlebar-icon"
 144          title={t('back')}
 145          onClick={handleBack}
 146        >
 147          <ChevronLeft />
 148        </Button>
 149        {hexPubkey && (
 150          <>
 151            <UserAvatar userId={hexPubkey} className="size-7" />
 152            <div className="flex-1 min-w-0">
 153              <div className="flex items-center gap-1.5">
 154                <span className="font-semibold text-sm truncate">{displayName}</span>
 155                {isFollowing && (
 156                  <span title="Following">
 157                    <Users className="size-3 text-primary" />
 158                  </span>
 159                )}
 160              </div>
 161              {profile?.nip05 && (
 162                <span className="text-xs text-muted-foreground truncate block">{profile.nip05}</span>
 163              )}
 164            </div>
 165            <Button
 166              variant="ghost"
 167              size="icon"
 168              className="size-8"
 169              title={t('Reload messages')}
 170              onClick={reloadConversation}
 171              disabled={isLoadingConversation}
 172            >
 173              <RefreshCw className={cn('size-4', isLoadingConversation && 'animate-spin')} />
 174            </Button>
 175            <Button
 176              variant="ghost"
 177              size="icon"
 178              className={cn('size-8', showPulse && 'animate-pulse ring-2 ring-primary ring-offset-2')}
 179              title={t('Conversation settings')}
 180              onClick={() => {
 181                setShowPulse(false)
 182                clearNewConversationFlag()
 183                setSettingsOpen(true)
 184              }}
 185            >
 186              <Settings className="size-4" />
 187            </Button>
 188            <DropdownMenu>
 189              <DropdownMenuTrigger asChild>
 190                <Button variant="ghost" size="icon" className="size-8">
 191                  <MoreVertical className="size-4" />
 192                </Button>
 193              </DropdownMenuTrigger>
 194              <DropdownMenuContent align="end">
 195                <DropdownMenuItem onClick={deleteAllInConversation} className="text-destructive focus:text-destructive">
 196                  <Trash2 className="size-4 mr-2" />
 197                  {t('Delete All')}
 198                </DropdownMenuItem>
 199                <DropdownMenuItem onClick={undeleteAllInConversation}>
 200                  <Undo2 className="size-4 mr-2" />
 201                  {t('Undelete All')}
 202                </DropdownMenuItem>
 203              </DropdownMenuContent>
 204            </DropdownMenu>
 205            <Button
 206              variant="ghost"
 207              size="icon"
 208              className="size-8"
 209              title={t('Close conversation')}
 210              onClick={handleBack}
 211            >
 212              <X className="size-4" />
 213            </Button>
 214          </>
 215        )}
 216      </div>
 217    )
 218  
 219    return (
 220      <div className="flex flex-col h-[var(--vh)]">
 221        <Titlebar className="p-1 shrink-0" hideBottomBorder={false}>
 222          {titlebar}
 223        </Titlebar>
 224        <div className="flex-1 min-h-0">
 225          <MessageView hideHeader />
 226        </div>
 227        {hexPubkey && (
 228          <ConversationSettingsModal
 229            partnerPubkey={hexPubkey}
 230            open={settingsOpen}
 231            onOpenChange={setSettingsOpen}
 232            selectedRelays={selectedRelays}
 233            onSelectedRelaysChange={handleRelaysChange}
 234          />
 235        )}
 236      </div>
 237    )
 238  })
 239  
 240  DMConversationPage.displayName = 'DMConversationPage'
 241  export default DMConversationPage
 242