MuteListProvider.tsx raw
1 import {
2 MuteList,
3 fromMuteListToHexSet,
4 Pubkey,
5 CannotMuteSelfError,
6 MuteVisibility
7 } from '@/domain'
8 import { MuteListRepositoryImpl } from '@/infrastructure/persistence'
9 import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
10 import { useTranslation } from 'react-i18next'
11 import { toast } from 'sonner'
12 import { useNostr } from './NostrProvider'
13
14 type TMuteListContext = {
15 mutePubkeySet: Set<string>
16 muteList: MuteList | null
17 isLoading: boolean
18 changing: boolean
19 getMutePubkeys: () => string[]
20 getMuteType: (pubkey: string) => MuteVisibility | null
21 mutePubkeyPublicly: (pubkey: string) => Promise<void>
22 mutePubkeyPrivately: (pubkey: string) => Promise<void>
23 unmutePubkey: (pubkey: string) => Promise<void>
24 switchToPublicMute: (pubkey: string) => Promise<void>
25 switchToPrivateMute: (pubkey: string) => Promise<void>
26 }
27
28 const MuteListContext = createContext<TMuteListContext | undefined>(undefined)
29
30 export const useMuteList = () => {
31 const context = useContext(MuteListContext)
32 if (!context) {
33 throw new Error('useMuteList must be used within a MuteListProvider')
34 }
35 return context
36 }
37
38 export function MuteListProvider({ children }: { children: React.ReactNode }) {
39 const { t } = useTranslation()
40 const { pubkey: accountPubkey, publish, nip04Decrypt, nip04Encrypt } = useNostr()
41
42 // State managed by this provider
43 const [muteList, setMuteList] = useState<MuteList | null>(null)
44 const [isLoading, setIsLoading] = useState(false)
45 const [changing, setChanging] = useState(false)
46
47 // Use refs for unstable NostrProvider functions to prevent constant repository recreation.
48 // publish, nip04Decrypt, nip04Encrypt are not memoized in NostrProvider, so they get new
49 // references on every render. Using refs keeps the repository stable across renders.
50 const publishRef = useRef(publish)
51 const nip04DecryptRef = useRef(nip04Decrypt)
52 const nip04EncryptRef = useRef(nip04Encrypt)
53 publishRef.current = publish
54 nip04DecryptRef.current = nip04Decrypt
55 nip04EncryptRef.current = nip04Encrypt
56
57 // Create repository instance - only recreated when accountPubkey changes
58 const repository = useMemo(() => {
59 if (!accountPubkey) return null
60 return new MuteListRepositoryImpl({
61 publish: (draftEvent) => publishRef.current(draftEvent),
62 currentUserPubkey: accountPubkey,
63 decrypt: async (ciphertext, pk) => nip04DecryptRef.current(pk, ciphertext),
64 encrypt: async (plaintext, pk) => nip04EncryptRef.current(pk, plaintext)
65 })
66 }, [accountPubkey])
67
68 // Legacy compatibility: expose as Set<string> for existing consumers
69 const mutePubkeySet = useMemo(
70 () => (muteList ? fromMuteListToHexSet(muteList) : new Set<string>()),
71 [muteList]
72 )
73
74 // Load mute list when account changes
75 useEffect(() => {
76 let cancelled = false
77
78 const loadMuteList = async () => {
79 if (!accountPubkey || !repository) {
80 if (!cancelled) setMuteList(null)
81 return
82 }
83
84 if (!cancelled) setIsLoading(true)
85 try {
86 const ownerPubkey = Pubkey.tryFromString(accountPubkey)
87 if (!ownerPubkey) {
88 if (!cancelled) setMuteList(null)
89 return
90 }
91
92 const list = await repository.findByOwner(ownerPubkey)
93 if (!cancelled) setMuteList(list)
94 } catch (error) {
95 console.error('Failed to load mute list:', error)
96 if (!cancelled) setMuteList(null)
97 } finally {
98 if (!cancelled) setIsLoading(false)
99 }
100 }
101
102 loadMuteList()
103 return () => { cancelled = true }
104 }, [accountPubkey, repository])
105
106 const getMutePubkeys = useCallback(() => {
107 return Array.from(mutePubkeySet)
108 }, [mutePubkeySet])
109
110 const getMuteType = useCallback(
111 (pubkey: string): MuteVisibility | null => {
112 if (!muteList) return null
113 const pk = Pubkey.tryFromString(pubkey)
114 return pk ? muteList.getMuteVisibility(pk) : null
115 },
116 [muteList]
117 )
118
119 const mutePubkeyPublicly = useCallback(
120 async (pubkey: string) => {
121 if (!accountPubkey || !repository || changing) return
122
123 setChanging(true)
124 try {
125 const ownerPubkey = Pubkey.fromHex(accountPubkey)
126 const targetPubkey = Pubkey.tryFromString(pubkey)
127 if (!targetPubkey) return
128
129 // Fetch latest to avoid conflicts
130 const currentMuteList = await repository.findByOwner(ownerPubkey)
131
132 if (!currentMuteList) {
133 const result = confirm(t('MuteListNotFoundConfirmation'))
134 if (!result) return
135 }
136
137 const list = currentMuteList ?? MuteList.empty(ownerPubkey)
138
139 try {
140 const change = list.mutePublicly(targetPubkey)
141 if (change.type === 'no_change') return
142
143 await repository.save(list)
144 setMuteList(list)
145 toast.success(t('Successfully updated mute list'))
146 } catch (error) {
147 if (error instanceof CannotMuteSelfError) return
148 throw error
149 }
150 } catch (error) {
151 toast.error(t('Failed to mute user publicly') + ': ' + (error as Error).message)
152 } finally {
153 setChanging(false)
154 }
155 },
156 [accountPubkey, repository, changing, t]
157 )
158
159 const mutePubkeyPrivately = useCallback(
160 async (pubkey: string) => {
161 if (!accountPubkey || !repository || changing) return
162
163 setChanging(true)
164 try {
165 const ownerPubkey = Pubkey.fromHex(accountPubkey)
166 const targetPubkey = Pubkey.tryFromString(pubkey)
167 if (!targetPubkey) return
168
169 const currentMuteList = await repository.findByOwner(ownerPubkey)
170
171 if (!currentMuteList) {
172 const result = confirm(t('MuteListNotFoundConfirmation'))
173 if (!result) return
174 }
175
176 const list = currentMuteList ?? MuteList.empty(ownerPubkey)
177
178 try {
179 const change = list.mutePrivately(targetPubkey)
180 if (change.type === 'no_change') return
181
182 await repository.save(list)
183 setMuteList(list)
184 toast.success(t('Successfully updated mute list'))
185 } catch (error) {
186 if (error instanceof CannotMuteSelfError) return
187 throw error
188 }
189 } catch (error) {
190 toast.error(t('Failed to mute user privately') + ': ' + (error as Error).message)
191 } finally {
192 setChanging(false)
193 }
194 },
195 [accountPubkey, repository, changing, t]
196 )
197
198 const unmutePubkey = useCallback(
199 async (pubkey: string) => {
200 if (!accountPubkey || !repository || changing) return
201
202 setChanging(true)
203 try {
204 const ownerPubkey = Pubkey.fromHex(accountPubkey)
205 const targetPubkey = Pubkey.tryFromString(pubkey)
206 if (!targetPubkey) return
207
208 const currentMuteList = await repository.findByOwner(ownerPubkey)
209 if (!currentMuteList) return
210
211 const change = currentMuteList.unmute(targetPubkey)
212 if (change.type === 'no_change') return
213
214 await repository.save(currentMuteList)
215 setMuteList(currentMuteList)
216 toast.success(t('Successfully updated mute list'))
217 } catch (error) {
218 toast.error(t('Failed to unmute user') + ': ' + (error as Error).message)
219 } finally {
220 setChanging(false)
221 }
222 },
223 [accountPubkey, repository, changing, t]
224 )
225
226 const switchToPublicMute = useCallback(
227 async (pubkey: string) => {
228 if (!accountPubkey || !repository || changing) return
229
230 setChanging(true)
231 try {
232 const ownerPubkey = Pubkey.fromHex(accountPubkey)
233 const targetPubkey = Pubkey.tryFromString(pubkey)
234 if (!targetPubkey) return
235
236 const currentMuteList = await repository.findByOwner(ownerPubkey)
237 if (!currentMuteList) return
238
239 const change = currentMuteList.switchToPublic(targetPubkey)
240 if (change.type === 'no_change') return
241
242 await repository.save(currentMuteList)
243 setMuteList(currentMuteList)
244 toast.success(t('Successfully updated mute list'))
245 } catch (error) {
246 toast.error(t('Failed to switch mute visibility') + ': ' + (error as Error).message)
247 } finally {
248 setChanging(false)
249 }
250 },
251 [accountPubkey, repository, changing, t]
252 )
253
254 const switchToPrivateMute = useCallback(
255 async (pubkey: string) => {
256 if (!accountPubkey || !repository || changing) return
257
258 setChanging(true)
259 try {
260 const ownerPubkey = Pubkey.fromHex(accountPubkey)
261 const targetPubkey = Pubkey.tryFromString(pubkey)
262 if (!targetPubkey) return
263
264 const currentMuteList = await repository.findByOwner(ownerPubkey)
265 if (!currentMuteList) return
266
267 const change = currentMuteList.switchToPrivate(targetPubkey)
268 if (change.type === 'no_change') return
269
270 await repository.save(currentMuteList)
271 setMuteList(currentMuteList)
272 toast.success(t('Successfully updated mute list'))
273 } catch (error) {
274 toast.error(t('Failed to switch mute visibility') + ': ' + (error as Error).message)
275 } finally {
276 setChanging(false)
277 }
278 },
279 [accountPubkey, repository, changing, t]
280 )
281
282 return (
283 <MuteListContext.Provider
284 value={{
285 mutePubkeySet,
286 muteList,
287 isLoading,
288 changing,
289 getMutePubkeys,
290 getMuteType,
291 mutePubkeyPublicly,
292 mutePubkeyPrivately,
293 unmutePubkey,
294 switchToPublicMute,
295 switchToPrivateMute
296 }}
297 >
298 {children}
299 </MuteListContext.Provider>
300 )
301 }
302