refactor: use domain objects for FollowList and MuteList providers

- Refactor FollowListProvider to use domain FollowList class
- Refactor MuteListProvider to use domain MuteList class
- Add hide untrusted interactions/notifications settings to GeneralSettingsPage
- Maintain backward compatibility with existing Set<string> interface

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
mleku
2025-12-27 04:13:12 +02:00
parent bb74308e28
commit ad6a3dbbab
3 changed files with 250 additions and 134 deletions

View File

@@ -30,7 +30,14 @@ const GeneralSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
mediaAutoLoadPolicy, mediaAutoLoadPolicy,
setMediaAutoLoadPolicy setMediaAutoLoadPolicy
} = useContentPolicy() } = useContentPolicy()
const { hideUntrustedNotes, updateHideUntrustedNotes } = useUserTrust() const {
hideUntrustedNotes,
updateHideUntrustedNotes,
hideUntrustedInteractions,
updateHideUntrustedInteractions,
hideUntrustedNotifications,
updateHideUntrustedNotifications
} = useUserTrust()
const { quickReaction, updateQuickReaction, quickReactionEmoji, updateQuickReactionEmoji } = const { quickReaction, updateQuickReaction, quickReactionEmoji, updateQuickReactionEmoji } =
useUserPreferences() useUserPreferences()
@@ -99,6 +106,26 @@ const GeneralSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
onCheckedChange={updateHideUntrustedNotes} onCheckedChange={updateHideUntrustedNotes}
/> />
</SettingItem> </SettingItem>
<SettingItem>
<Label htmlFor="hide-untrusted-interactions" className="text-base font-normal">
{t('Hide untrusted interactions')}
</Label>
<Switch
id="hide-untrusted-interactions"
checked={hideUntrustedInteractions}
onCheckedChange={updateHideUntrustedInteractions}
/>
</SettingItem>
<SettingItem>
<Label htmlFor="hide-untrusted-notifications" className="text-base font-normal">
{t('Hide untrusted notifications')}
</Label>
<Switch
id="hide-untrusted-notifications"
checked={hideUntrustedNotifications}
onCheckedChange={updateHideUntrustedNotifications}
/>
</SettingItem>
<SettingItem> <SettingItem>
<Label htmlFor="hide-content-mentioning-muted-users" className="text-base font-normal"> <Label htmlFor="hide-content-mentioning-muted-users" className="text-base font-normal">
{t('Hide content mentioning muted users')} {t('Hide content mentioning muted users')}

View File

@@ -1,5 +1,10 @@
import { createFollowListDraftEvent } from '@/lib/draft-event' import {
import { getPubkeysFromPTags } from '@/lib/tag' FollowList,
tryToFollowList,
fromFollowListToHexSet,
Pubkey,
CannotFollowSelfError
} from '@/domain'
import client from '@/services/client.service' import client from '@/services/client.service'
import { createContext, useContext, useMemo } from 'react' import { createContext, useContext, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -7,6 +12,7 @@ import { useNostr } from './NostrProvider'
type TFollowListContext = { type TFollowListContext = {
followingSet: Set<string> followingSet: Set<string>
followList: FollowList | null
follow: (pubkey: string) => Promise<void> follow: (pubkey: string) => Promise<void>
unfollow: (pubkey: string) => Promise<void> unfollow: (pubkey: string) => Promise<void>
} }
@@ -24,41 +30,73 @@ export const useFollowList = () => {
export function FollowListProvider({ children }: { children: React.ReactNode }) { export function FollowListProvider({ children }: { children: React.ReactNode }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey: accountPubkey, followListEvent, publish, updateFollowListEvent } = useNostr() const { pubkey: accountPubkey, followListEvent, publish, updateFollowListEvent } = useNostr()
const followingSet = useMemo(
() => new Set(followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []), // Create domain FollowList from event
const followList = useMemo(
() => tryToFollowList(followListEvent),
[followListEvent] [followListEvent]
) )
// Legacy compatibility: expose as Set<string> for existing consumers
const followingSet = useMemo(
() => (followList ? fromFollowListToHexSet(followList) : new Set<string>()),
[followList]
)
const follow = async (pubkey: string) => { const follow = async (pubkey: string) => {
if (!accountPubkey) return if (!accountPubkey) return
const followListEvent = await client.fetchFollowListEvent(accountPubkey) // Fetch latest follow list event
if (!followListEvent) { const latestEvent = await client.fetchFollowListEvent(accountPubkey)
if (!latestEvent) {
const result = confirm(t('FollowListNotFoundConfirmation')) const result = confirm(t('FollowListNotFoundConfirmation'))
if (!result) return
}
if (!result) { // Create or update FollowList using domain object
const ownerPubkey = Pubkey.fromHex(accountPubkey)
const currentFollowList = latestEvent
? FollowList.fromEvent(latestEvent)
: FollowList.empty(ownerPubkey)
// Use domain logic for following
const targetPubkey = Pubkey.tryFromString(pubkey)
if (!targetPubkey) return
try {
const change = currentFollowList.follow(targetPubkey)
if (change.type === 'no_change') return
// Publish the updated follow list
const draftEvent = currentFollowList.toDraftEvent()
const newFollowListEvent = await publish(draftEvent)
await updateFollowListEvent(newFollowListEvent)
} catch (error) {
if (error instanceof CannotFollowSelfError) {
// Silently ignore self-follow attempts
return return
} }
throw error
} }
const newFollowListDraftEvent = createFollowListDraftEvent(
(followListEvent?.tags ?? []).concat([['p', pubkey]]),
followListEvent?.content
)
const newFollowListEvent = await publish(newFollowListDraftEvent)
await updateFollowListEvent(newFollowListEvent)
} }
const unfollow = async (pubkey: string) => { const unfollow = async (pubkey: string) => {
if (!accountPubkey) return if (!accountPubkey) return
const followListEvent = await client.fetchFollowListEvent(accountPubkey) const latestEvent = await client.fetchFollowListEvent(accountPubkey)
if (!followListEvent) return if (!latestEvent) return
const newFollowListDraftEvent = createFollowListDraftEvent( // Use domain object for unfollowing
followListEvent.tags.filter(([tagName, tagValue]) => tagName !== 'p' || tagValue !== pubkey), const currentFollowList = FollowList.fromEvent(latestEvent)
followListEvent.content const targetPubkey = Pubkey.tryFromString(pubkey)
) if (!targetPubkey) return
const newFollowListEvent = await publish(newFollowListDraftEvent)
const change = currentFollowList.unfollow(targetPubkey)
if (change.type === 'no_change') return
// Publish the updated follow list
const draftEvent = currentFollowList.toDraftEvent()
const newFollowListEvent = await publish(draftEvent)
await updateFollowListEvent(newFollowListEvent) await updateFollowListEvent(newFollowListEvent)
} }
@@ -66,6 +104,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
<FollowListContext.Provider <FollowListContext.Provider
value={{ value={{
followingSet, followingSet,
followList,
follow, follow,
unfollow unfollow
}} }}

View File

@@ -1,5 +1,11 @@
import { createMuteListDraftEvent } from '@/lib/draft-event' import {
import { getPubkeysFromPTags } from '@/lib/tag' MuteList,
tryToMuteList,
fromMuteListToHexSet,
Pubkey,
CannotMuteSelfError,
MuteVisibility
} from '@/domain'
import client from '@/services/client.service' import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@@ -12,9 +18,10 @@ import { useNostr } from './NostrProvider'
type TMuteListContext = { type TMuteListContext = {
mutePubkeySet: Set<string> mutePubkeySet: Set<string>
muteList: MuteList | null
changing: boolean changing: boolean
getMutePubkeys: () => string[] getMutePubkeys: () => string[]
getMuteType: (pubkey: string) => 'public' | 'private' | null getMuteType: (pubkey: string) => MuteVisibility | null
mutePubkeyPublicly: (pubkey: string) => Promise<void> mutePubkeyPublicly: (pubkey: string) => Promise<void>
mutePubkeyPrivately: (pubkey: string) => Promise<void> mutePubkeyPrivately: (pubkey: string) => Promise<void>
unmutePubkey: (pubkey: string) => Promise<void> unmutePubkey: (pubkey: string) => Promise<void>
@@ -42,35 +49,27 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
nip04Decrypt, nip04Decrypt,
nip04Encrypt nip04Encrypt
} = useNostr() } = useNostr()
const [tags, setTags] = useState<string[][]>([])
const [privateTags, setPrivateTags] = useState<string[][]>([]) const [privateTags, setPrivateTags] = useState<string[][]>([])
const publicMutePubkeySet = useMemo(() => new Set(getPubkeysFromPTags(tags)), [tags])
const privateMutePubkeySet = useMemo(
() => new Set(getPubkeysFromPTags(privateTags)),
[privateTags]
)
const mutePubkeySet = useMemo(() => {
return new Set([...Array.from(privateMutePubkeySet), ...Array.from(publicMutePubkeySet)])
}, [publicMutePubkeySet, privateMutePubkeySet])
const [changing, setChanging] = useState(false) const [changing, setChanging] = useState(false)
// Decrypt private tags from mute list event
const getPrivateTags = useCallback( const getPrivateTags = useCallback(
async (muteListEvent: Event) => { async (event: Event) => {
if (!muteListEvent.content) return [] if (!event.content) return []
try { try {
const storedPlainText = await indexedDb.getDecryptedContent(muteListEvent.id) const storedPlainText = await indexedDb.getDecryptedContent(event.id)
let plainText: string let plainText: string
if (storedPlainText) { if (storedPlainText) {
plainText = storedPlainText plainText = storedPlainText
} else { } else {
plainText = await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content) plainText = await nip04Decrypt(event.pubkey, event.content)
await indexedDb.putDecryptedContent(muteListEvent.id, plainText) await indexedDb.putDecryptedContent(event.id, plainText)
} }
const privateTags = z.array(z.array(z.string())).parse(JSON.parse(plainText)) const tags = z.array(z.array(z.string())).parse(JSON.parse(plainText))
return privateTags return tags
} catch (error) { } catch (error) {
console.error('Failed to decrypt mute list content', error) console.error('Failed to decrypt mute list content', error)
return [] return []
@@ -79,73 +78,92 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
[nip04Decrypt] [nip04Decrypt]
) )
// Update private tags when mute list event changes
useEffect(() => { useEffect(() => {
const updateMuteTags = async () => { const updatePrivateTags = async () => {
if (!muteListEvent) { if (!muteListEvent) {
setTags([])
setPrivateTags([]) setPrivateTags([])
return return
} }
const privateTags = await getPrivateTags(muteListEvent).catch(() => { const tags = await getPrivateTags(muteListEvent).catch(() => [])
return [] setPrivateTags(tags)
})
setPrivateTags(privateTags)
setTags(muteListEvent.tags)
} }
updateMuteTags() updatePrivateTags()
}, [muteListEvent]) }, [muteListEvent, getPrivateTags])
const getMutePubkeys = () => { // Create domain MuteList from event and decrypted private tags
return Array.from(mutePubkeySet) const muteList = useMemo(
} () => tryToMuteList(muteListEvent, privateTags),
[muteListEvent, privateTags]
const getMuteType = useCallback(
(pubkey: string): 'public' | 'private' | null => {
if (publicMutePubkeySet.has(pubkey)) return 'public'
if (privateMutePubkeySet.has(pubkey)) return 'private'
return null
},
[publicMutePubkeySet, privateMutePubkeySet]
) )
const publishNewMuteListEvent = async (tags: string[][], content?: string) => { // Legacy compatibility: expose as Set<string> for existing consumers
const mutePubkeySet = useMemo(
() => (muteList ? fromMuteListToHexSet(muteList) : new Set<string>()),
[muteList]
)
const getMutePubkeys = useCallback(() => {
return Array.from(mutePubkeySet)
}, [mutePubkeySet])
const getMuteType = useCallback(
(pubkey: string): MuteVisibility | null => {
if (!muteList) return null
const pk = Pubkey.tryFromString(pubkey)
return pk ? muteList.getMuteVisibility(pk) : null
},
[muteList]
)
// Publish updated mute list with rate limiting
const publishMuteList = async (updatedMuteList: MuteList, encryptedContent: string) => {
if (dayjs().unix() === muteListEvent?.created_at) { if (dayjs().unix() === muteListEvent?.created_at) {
await new Promise((resolve) => setTimeout(resolve, 1000)) await new Promise((resolve) => setTimeout(resolve, 1000))
} }
const newMuteListDraftEvent = createMuteListDraftEvent(tags, content)
const event = await publish(newMuteListDraftEvent) const draftEvent = updatedMuteList.toDraftEvent(encryptedContent)
const event = await publish(draftEvent)
toast.success(t('Successfully updated mute list')) toast.success(t('Successfully updated mute list'))
return event return event
} }
const checkMuteListEvent = (muteListEvent: Event | null) => {
if (!muteListEvent) {
const result = confirm(t('MuteListNotFoundConfirmation'))
if (!result) {
throw new Error('Mute list not found')
}
}
}
const mutePubkeyPublicly = async (pubkey: string) => { const mutePubkeyPublicly = async (pubkey: string) => {
if (!accountPubkey || changing) return if (!accountPubkey || changing) return
setChanging(true) setChanging(true)
try { try {
const muteListEvent = await client.fetchMuteListEvent(accountPubkey) const latestEvent = await client.fetchMuteListEvent(accountPubkey)
checkMuteListEvent(muteListEvent) if (!latestEvent) {
if ( const result = confirm(t('MuteListNotFoundConfirmation'))
muteListEvent && if (!result) return
muteListEvent.tags.some(([tagName, tagValue]) => tagName === 'p' && tagValue === pubkey) }
) {
return const ownerPubkey = Pubkey.fromHex(accountPubkey)
const decryptedPrivateTags = latestEvent ? await getPrivateTags(latestEvent) : []
const currentMuteList = latestEvent
? MuteList.fromEvent(latestEvent, decryptedPrivateTags)
: MuteList.empty(ownerPubkey)
const targetPubkey = Pubkey.tryFromString(pubkey)
if (!targetPubkey) return
try {
const change = currentMuteList.mutePublicly(targetPubkey)
if (change.type === 'no_change') return
// Encrypt private tags if there are any
const encryptedContent = currentMuteList.hasPrivateMutes()
? await nip04Encrypt(accountPubkey, JSON.stringify(currentMuteList.toPrivateTags()))
: ''
const newEvent = await publishMuteList(currentMuteList, encryptedContent)
await updateMuteListEvent(newEvent, currentMuteList.toPrivateTags())
} catch (error) {
if (error instanceof CannotMuteSelfError) return
throw error
} }
const newTags = (muteListEvent?.tags ?? []).concat([['p', pubkey]])
const newMuteListEvent = await publishNewMuteListEvent(newTags, muteListEvent?.content)
const privateTags = await getPrivateTags(newMuteListEvent)
await updateMuteListEvent(newMuteListEvent, privateTags)
} catch (error) { } catch (error) {
toast.error(t('Failed to mute user publicly') + ': ' + (error as Error).message) toast.error(t('Failed to mute user publicly') + ': ' + (error as Error).message)
} finally { } finally {
@@ -158,17 +176,37 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
setChanging(true) setChanging(true)
try { try {
const muteListEvent = await client.fetchMuteListEvent(accountPubkey) const latestEvent = await client.fetchMuteListEvent(accountPubkey)
checkMuteListEvent(muteListEvent) if (!latestEvent) {
const privateTags = muteListEvent ? await getPrivateTags(muteListEvent) : [] const result = confirm(t('MuteListNotFoundConfirmation'))
if (privateTags.some(([tagName, tagValue]) => tagName === 'p' && tagValue === pubkey)) { if (!result) return
return
} }
const newPrivateTags = privateTags.concat([['p', pubkey]]) const ownerPubkey = Pubkey.fromHex(accountPubkey)
const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags)) const decryptedPrivateTags = latestEvent ? await getPrivateTags(latestEvent) : []
const newMuteListEvent = await publishNewMuteListEvent(muteListEvent?.tags ?? [], cipherText) const currentMuteList = latestEvent
await updateMuteListEvent(newMuteListEvent, newPrivateTags) ? MuteList.fromEvent(latestEvent, decryptedPrivateTags)
: MuteList.empty(ownerPubkey)
const targetPubkey = Pubkey.tryFromString(pubkey)
if (!targetPubkey) return
try {
const change = currentMuteList.mutePrivately(targetPubkey)
if (change.type === 'no_change') return
// Always encrypt when adding private mutes
const encryptedContent = await nip04Encrypt(
accountPubkey,
JSON.stringify(currentMuteList.toPrivateTags())
)
const newEvent = await publishMuteList(currentMuteList, encryptedContent)
await updateMuteListEvent(newEvent, currentMuteList.toPrivateTags())
} catch (error) {
if (error instanceof CannotMuteSelfError) return
throw error
}
} catch (error) { } catch (error) {
toast.error(t('Failed to mute user privately') + ': ' + (error as Error).message) toast.error(t('Failed to mute user privately') + ': ' + (error as Error).message)
} finally { } finally {
@@ -181,21 +219,25 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
setChanging(true) setChanging(true)
try { try {
const muteListEvent = await client.fetchMuteListEvent(accountPubkey) const latestEvent = await client.fetchMuteListEvent(accountPubkey)
if (!muteListEvent) return if (!latestEvent) return
const privateTags = await getPrivateTags(muteListEvent) const decryptedPrivateTags = await getPrivateTags(latestEvent)
const newPrivateTags = privateTags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey) const currentMuteList = MuteList.fromEvent(latestEvent, decryptedPrivateTags)
let cipherText = muteListEvent.content
if (newPrivateTags.length !== privateTags.length) {
cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags))
}
const newMuteListEvent = await publishNewMuteListEvent( const targetPubkey = Pubkey.tryFromString(pubkey)
muteListEvent.tags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey), if (!targetPubkey) return
cipherText
) const change = currentMuteList.unmute(targetPubkey)
await updateMuteListEvent(newMuteListEvent, newPrivateTags) if (change.type === 'no_change') return
// Re-encrypt if there are still private mutes
const encryptedContent = currentMuteList.hasPrivateMutes()
? await nip04Encrypt(accountPubkey, JSON.stringify(currentMuteList.toPrivateTags()))
: ''
const newEvent = await publishMuteList(currentMuteList, encryptedContent)
await updateMuteListEvent(newEvent, currentMuteList.toPrivateTags())
} finally { } finally {
setChanging(false) setChanging(false)
} }
@@ -206,23 +248,25 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
setChanging(true) setChanging(true)
try { try {
const muteListEvent = await client.fetchMuteListEvent(accountPubkey) const latestEvent = await client.fetchMuteListEvent(accountPubkey)
if (!muteListEvent) return if (!latestEvent) return
const privateTags = await getPrivateTags(muteListEvent) const decryptedPrivateTags = await getPrivateTags(latestEvent)
const newPrivateTags = privateTags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey) const currentMuteList = MuteList.fromEvent(latestEvent, decryptedPrivateTags)
if (newPrivateTags.length === privateTags.length) {
return
}
const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags)) const targetPubkey = Pubkey.tryFromString(pubkey)
const newMuteListEvent = await publishNewMuteListEvent( if (!targetPubkey) return
muteListEvent.tags
.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey) const change = currentMuteList.switchToPublic(targetPubkey)
.concat([['p', pubkey]]), if (change.type === 'no_change') return
cipherText
) // Re-encrypt private tags
await updateMuteListEvent(newMuteListEvent, newPrivateTags) const encryptedContent = currentMuteList.hasPrivateMutes()
? await nip04Encrypt(accountPubkey, JSON.stringify(currentMuteList.toPrivateTags()))
: ''
const newEvent = await publishMuteList(currentMuteList, encryptedContent)
await updateMuteListEvent(newEvent, currentMuteList.toPrivateTags())
} finally { } finally {
setChanging(false) setChanging(false)
} }
@@ -233,21 +277,26 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
setChanging(true) setChanging(true)
try { try {
const muteListEvent = await client.fetchMuteListEvent(accountPubkey) const latestEvent = await client.fetchMuteListEvent(accountPubkey)
if (!muteListEvent) return if (!latestEvent) return
const newTags = muteListEvent.tags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey) const decryptedPrivateTags = await getPrivateTags(latestEvent)
if (newTags.length === muteListEvent.tags.length) { const currentMuteList = MuteList.fromEvent(latestEvent, decryptedPrivateTags)
return
}
const privateTags = await getPrivateTags(muteListEvent) const targetPubkey = Pubkey.tryFromString(pubkey)
const newPrivateTags = privateTags if (!targetPubkey) return
.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey)
.concat([['p', pubkey]]) const change = currentMuteList.switchToPrivate(targetPubkey)
const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newPrivateTags)) if (change.type === 'no_change') return
const newMuteListEvent = await publishNewMuteListEvent(newTags, cipherText)
await updateMuteListEvent(newMuteListEvent, newPrivateTags) // Encrypt the updated private tags
const encryptedContent = await nip04Encrypt(
accountPubkey,
JSON.stringify(currentMuteList.toPrivateTags())
)
const newEvent = await publishMuteList(currentMuteList, encryptedContent)
await updateMuteListEvent(newEvent, currentMuteList.toPrivateTags())
} finally { } finally {
setChanging(false) setChanging(false)
} }
@@ -257,6 +306,7 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
<MuteListContext.Provider <MuteListContext.Provider
value={{ value={{
mutePubkeySet, mutePubkeySet,
muteList,
changing, changing,
getMutePubkeys, getMutePubkeys,
getMuteType, getMuteType,