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