You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

324 lines
11 KiB

import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls'
import { createMuteListDraftEvent } from '@/lib/draft-event'
import {
dedupePTagsAppendPubkey,
fetchLatestReplaceableListEvent,
removePubkeyFromPTags
} from '@/lib/replaceable-list-latest'
import { getPubkeysFromPTags } from '@/lib/tag'
import { MuteListContext } from '@/contexts/mute-list-context'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { kinds } from 'nostr-tools'
import dayjs from 'dayjs'
import { Event } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { z } from 'zod'
import { useNostr } from './NostrProvider'
import { useFavoriteRelays } from './FavoriteRelaysProvider'
import logger from '@/lib/logger'
import { muteSetHas } from '@/lib/mute-set'
/**
* Decryption failures are common and usually benign (npub-only session, extension declined NIP-04,
* legacy/other-client ciphertext, corrupted relay copy). Log at most once per event id per load.
*/
const muteListPrivateSectionIssueLogged = new Set<string>()
function logMuteListPrivateIssueOnce(eventId: string, message: string, detail?: Record<string, unknown>) {
if (muteListPrivateSectionIssueLogged.has(eventId)) return
muteListPrivateSectionIssueLogged.add(eventId)
logger.warn(message, { eventId, ...detail })
}
export function MuteListProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation()
const {
pubkey: accountPubkey,
muteListEvent,
publish,
updateMuteListEvent,
nip04Decrypt,
nip04Encrypt
} = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [tags, setTags] = useState<string[][]>([])
const [privateTags, setPrivateTags] = useState<string[][]>([])
const publicMutePubkeySet = useMemo(
() => new Set(getPubkeysFromPTags(tags).map((p) => p.toLowerCase())),
[tags]
)
const privateMutePubkeySet = useMemo(
() => new Set(getPubkeysFromPTags(privateTags).map((p) => p.toLowerCase())),
[privateTags]
)
const mutePubkeySet = useMemo(() => {
return new Set([...Array.from(privateMutePubkeySet), ...Array.from(publicMutePubkeySet)])
}, [publicMutePubkeySet, privateMutePubkeySet])
const [changing, setChanging] = useState(false)
useEffect(() => {
muteListPrivateSectionIssueLogged.clear()
}, [accountPubkey])
const getPrivateTags = async (muteListEvent: Event) => {
if (!muteListEvent.content) return []
const storedDecryptedTags = await indexedDb.getMuteDecryptedTags(muteListEvent.id)
if (storedDecryptedTags) {
return storedDecryptedTags
}
let plainText: string
try {
plainText = await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content)
} catch (error) {
logMuteListPrivateIssueOnce(
muteListEvent.id,
'Mute list private section could not be decrypted (public mutes still apply). Use a signing-capable login for private mutes.',
{ cause: error instanceof Error ? error.message : String(error) }
)
return []
}
if (!plainText.trim()) {
logMuteListPrivateIssueOnce(
muteListEvent.id,
'Mute list has ciphertext but decryption returned empty (e.g. read-only / npub-only login). Public mutes still apply.',
undefined
)
return []
}
try {
const privateTags = z.array(z.array(z.string())).parse(JSON.parse(plainText))
await indexedDb.putMuteDecryptedTags(muteListEvent.id, privateTags)
return privateTags
} catch (error) {
logMuteListPrivateIssueOnce(
muteListEvent.id,
'Mute list decrypted but private payload was not valid JSON (public mutes still apply).',
{ cause: error instanceof Error ? error.message : String(error) }
)
return []
}
}
useEffect(() => {
const updateMuteTags = async () => {
if (!muteListEvent) {
setTags([])
setPrivateTags([])
return
}
const privateTags = await getPrivateTags(muteListEvent).catch(() => {
return []
})
setPrivateTags(privateTags)
setTags(muteListEvent.tags)
}
updateMuteTags()
}, [muteListEvent])
const getMutePubkeys = () => {
return Array.from(mutePubkeySet)
}
const getMuteType = useCallback(
(pubkey: string): 'public' | 'private' | null => {
if (muteSetHas(publicMutePubkeySet, pubkey)) return 'public'
if (muteSetHas(privateMutePubkeySet, pubkey)) return 'private'
return null
},
[publicMutePubkeySet, privateMutePubkeySet]
)
const loadLatestMuteListEvent = useCallback(async (): Promise<Event | null> => {
if (!accountPubkey) return null
const relays = await buildAccountListRelayUrlsForMerge({
accountPubkey,
favoriteRelays: favoriteRelays ?? [],
blockedRelays
})
const fromNetwork = await fetchLatestReplaceableListEvent(accountPubkey, kinds.Mutelist, relays)
if (fromNetwork) return fromNetwork
return (await client.fetchMuteListEvent(accountPubkey)) ?? null
}, [accountPubkey, favoriteRelays, blockedRelays])
const publishNewMuteListEvent = async (tags: string[][], content?: string) => {
if (dayjs().unix() === muteListEvent?.created_at) {
await new Promise((resolve) => setTimeout(resolve, 1000))
}
const newMuteListDraftEvent = createMuteListDraftEvent(tags, content)
const event = await publish(newMuteListDraftEvent)
toast.success(t('Successfully updated mute list'))
return event
}
const checkMuteListEvent = (muteListEvent: Event | null | undefined) => {
if (!muteListEvent) {
const result = confirm(t('MuteListNotFoundConfirmation'))
if (!result) {
throw new Error('Mute list not found')
}
}
}
const mutePubkeyPublicly = async (pubkey: string) => {
if (!accountPubkey || changing) return
setChanging(true)
try {
const muteListEvent = await loadLatestMuteListEvent()
checkMuteListEvent(muteListEvent)
if (
muteListEvent &&
muteListEvent.tags.some(
([tagName, tagValue]) => tagName === 'p' && tagValue?.toLowerCase() === pubkey.toLowerCase()
)
) {
return
}
const newTags = dedupePTagsAppendPubkey(muteListEvent?.tags ?? [], pubkey)
const newMuteListEvent = await publishNewMuteListEvent(newTags, muteListEvent?.content)
const privateTags = await getPrivateTags(newMuteListEvent)
await updateMuteListEvent(newMuteListEvent, privateTags)
} catch (error) {
toast.error(t('Failed to mute user publicly') + ': ' + (error as Error).message)
} finally {
setChanging(false)
}
}
const mutePubkeyPrivately = async (pubkey: string) => {
if (!accountPubkey || changing) return
setChanging(true)
try {
const muteListEvent = await loadLatestMuteListEvent()
checkMuteListEvent(muteListEvent)
const privateTags = muteListEvent ? await getPrivateTags(muteListEvent) : []
if (
privateTags.some(
([tagName, tagValue]) => tagName === 'p' && tagValue?.toLowerCase() === pubkey.toLowerCase()
)
) {
return
}
const newPrivateTags = dedupePTagsAppendPubkey(privateTags, pubkey)
const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags))
const newMuteListEvent = await publishNewMuteListEvent(muteListEvent?.tags ?? [], cipherText)
await updateMuteListEvent(newMuteListEvent, newPrivateTags)
} catch (error) {
toast.error(t('Failed to mute user privately') + ': ' + (error as Error).message)
} finally {
setChanging(false)
}
}
const unmutePubkey = async (pubkey: string) => {
if (!accountPubkey || changing) return
setChanging(true)
try {
const muteListEvent = await loadLatestMuteListEvent()
if (!muteListEvent) return
const privateTags = await getPrivateTags(muteListEvent)
const newPrivateTags = privateTags.filter(
(tag) => !(tag[0] === 'p' && tag[1]?.toLowerCase() === pubkey.toLowerCase())
)
let cipherText = muteListEvent.content
if (newPrivateTags.length !== privateTags.length) {
cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags))
}
const newMuteListEvent = await publishNewMuteListEvent(
removePubkeyFromPTags(muteListEvent.tags, pubkey),
cipherText
)
await updateMuteListEvent(newMuteListEvent, newPrivateTags)
} finally {
setChanging(false)
}
}
const switchToPublicMute = async (pubkey: string) => {
if (!accountPubkey || changing) return
setChanging(true)
try {
const muteListEvent = await loadLatestMuteListEvent()
if (!muteListEvent) return
const privateTags = await getPrivateTags(muteListEvent)
const newPrivateTags = privateTags.filter(
(tag) => !(tag[0] === 'p' && tag[1]?.toLowerCase() === pubkey.toLowerCase())
)
if (newPrivateTags.length === privateTags.length) {
return
}
const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags))
const newMuteListEvent = await publishNewMuteListEvent(
dedupePTagsAppendPubkey(removePubkeyFromPTags(muteListEvent.tags, pubkey), pubkey),
cipherText
)
await updateMuteListEvent(newMuteListEvent, newPrivateTags)
} finally {
setChanging(false)
}
}
const switchToPrivateMute = async (pubkey: string) => {
if (!accountPubkey || changing) return
setChanging(true)
try {
const muteListEvent = await loadLatestMuteListEvent()
if (!muteListEvent) return
const newTags = removePubkeyFromPTags(muteListEvent.tags, pubkey)
if (newTags.length === muteListEvent.tags.length) {
return
}
const privateTags = await getPrivateTags(muteListEvent)
const newPrivateTags = dedupePTagsAppendPubkey(
privateTags.filter(
(tag) => !(tag[0] === 'p' && tag[1]?.toLowerCase() === pubkey.toLowerCase())
),
pubkey
)
const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags))
const newMuteListEvent = await publishNewMuteListEvent(newTags, cipherText)
await updateMuteListEvent(newMuteListEvent, newPrivateTags)
} finally {
setChanging(false)
}
}
return (
<MuteListContext.Provider
value={{
mutePubkeySet,
changing,
getMutePubkeys,
getMuteType,
mutePubkeyPublicly,
mutePubkeyPrivately,
unmutePubkey,
switchToPublicMute,
switchToPrivateMute
}}
>
{children}
</MuteListContext.Provider>
)
}