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