feat: mute

This commit is contained in:
codytseng
2025-01-19 14:40:05 +08:00
parent 34ff0cd314
commit cbae26e492
26 changed files with 564 additions and 45 deletions

View File

@@ -1,5 +1,5 @@
import { createFollowListDraftEvent } from '@/lib/draft-event'
import { getFollowingsFromFollowListEvent } from '@/lib/event'
import { extractPubkeysFromEventTags } from '@/lib/tag'
import client from '@/services/client.service'
import storage from '@/services/storage.service'
import { Event } from 'nostr-tools'
@@ -30,7 +30,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
const [followListEvent, setFollowListEvent] = useState<Event | undefined>(undefined)
const [isFetching, setIsFetching] = useState(true)
const followings = useMemo(
() => (followListEvent ? getFollowingsFromFollowListEvent(followListEvent) : []),
() => (followListEvent ? extractPubkeysFromEventTags(followListEvent.tags) : []),
[followListEvent]
)
@@ -87,7 +87,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
const getFollowings = async (pubkey: string) => {
const followListEvent = storage.getAccountFollowListEvent(pubkey)
if (followListEvent) {
return getFollowingsFromFollowListEvent(followListEvent)
return extractPubkeysFromEventTags(followListEvent.tags)
}
return await client.fetchFollowings(pubkey)
}

View File

@@ -0,0 +1,111 @@
import { createMuteListDraftEvent } from '@/lib/draft-event'
import { getLatestEvent } from '@/lib/event'
import { extractPubkeysFromEventTags, isSameTag } from '@/lib/tag'
import client from '@/services/client.service'
import storage from '@/services/storage.service'
import { Event, kinds } from 'nostr-tools'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import { z } from 'zod'
import { useNostr } from './NostrProvider'
type TMuteListContext = {
mutePubkeys: string[]
mutePubkey: (pubkey: string) => Promise<void>
unmutePubkey: (pubkey: string) => Promise<void>
}
const MuteListContext = createContext<TMuteListContext | undefined>(undefined)
export const useMuteList = () => {
const context = useContext(MuteListContext)
if (!context) {
throw new Error('useMuteList must be used within a MuteListProvider')
}
return context
}
export function MuteListProvider({ children }: { children: React.ReactNode }) {
const { pubkey: accountPubkey, publish, relayList, nip04Decrypt, nip04Encrypt } = useNostr()
const [muteListEvent, setMuteListEvent] = useState<Event | undefined>(undefined)
const [tags, setTags] = useState<string[][]>([])
const mutePubkeys = useMemo(() => extractPubkeysFromEventTags(tags), [tags])
useEffect(() => {
if (!muteListEvent) {
setTags([])
return
}
const updateTags = async () => {
const tags = muteListEvent.tags
if (muteListEvent.content && accountPubkey) {
try {
const plainText = await nip04Decrypt(accountPubkey, muteListEvent.content)
const contentTags = z.array(z.array(z.string())).parse(JSON.parse(plainText))
tags.push(...contentTags.filter((tag) => tags.every((t) => !isSameTag(t, tag))))
} catch {
// ignore
}
}
setTags(tags)
}
updateTags()
}, [muteListEvent])
useEffect(() => {
if (!accountPubkey) return
const init = async () => {
setMuteListEvent(undefined)
const storedMuteListEvent = storage.getAccountMuteListEvent(accountPubkey)
if (storedMuteListEvent) {
setMuteListEvent(storedMuteListEvent)
}
const events = await client.fetchEvents(relayList?.write ?? client.getDefaultRelayUrls(), {
kinds: [kinds.Mutelist],
authors: [accountPubkey]
})
const muteEvent = getLatestEvent(events) as Event | undefined
if (muteEvent) {
setMuteListEvent(muteEvent)
}
}
init()
}, [accountPubkey])
const updateMuteListEvent = (event: Event) => {
const isNew = storage.setAccountMuteListEvent(event)
if (!isNew) return
setMuteListEvent(event)
}
const mutePubkey = async (pubkey: string) => {
if (!accountPubkey) return
const newTags = tags.concat([['p', pubkey]])
const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newTags))
const newMuteListDraftEvent = createMuteListDraftEvent(muteListEvent?.tags ?? [], cipherText)
const newMuteListEvent = await publish(newMuteListDraftEvent)
updateMuteListEvent(newMuteListEvent)
}
const unmutePubkey = async (pubkey: string) => {
if (!accountPubkey || !muteListEvent) return
const newTags = tags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey)
const cipherText = await nip04Encrypt(accountPubkey, JSON.stringify(newTags))
const newMuteListDraftEvent = createMuteListDraftEvent(
muteListEvent.tags.filter((tag) => tag[0] !== 'p' || tag[1] !== pubkey),
cipherText
)
const newMuteListEvent = await publish(newMuteListDraftEvent)
updateMuteListEvent(newMuteListEvent)
}
return (
<MuteListContext.Provider value={{ mutePubkeys, mutePubkey, unmutePubkey }}>
{children}
</MuteListContext.Provider>
)
}

View File

@@ -48,6 +48,20 @@ export class BunkerSigner implements ISigner {
})
}
async nip04Encrypt(pubkey: string, plainText: string) {
if (!this.signer) {
throw new Error('Not logged in')
}
return await this.signer.nip04Encrypt(pubkey, plainText)
}
async nip04Decrypt(pubkey: string, cipherText: string) {
if (!this.signer) {
throw new Error('Not logged in')
}
return await this.signer.nip04Decrypt(pubkey, cipherText)
}
getClientSecretKey() {
return bytesToHex(this.clientSecretKey)
}

View File

@@ -37,6 +37,8 @@ type TNostrContext = {
publish: (draftEvent: TDraftEvent, additionalRelayUrls?: string[]) => Promise<Event>
signHttpAuth: (url: string, method: string) => Promise<string>
signEvent: (draftEvent: TDraftEvent) => Promise<Event>
nip04Encrypt: (pubkey: string, plainText: string) => Promise<string>
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
checkLogin: <T>(cb?: () => T) => Promise<T | void>
getRelayList: (pubkey: string) => Promise<TRelayList>
updateRelayListEvent: (relayListEvent: Event) => void
@@ -299,6 +301,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return 'Nostr ' + btoa(JSON.stringify(event))
}
const nip04Encrypt = async (pubkey: string, plainText: string) => {
return signer?.nip04Encrypt(pubkey, plainText) ?? ''
}
const nip04Decrypt = async (pubkey: string, cipherText: string) => {
return signer?.nip04Decrypt(pubkey, cipherText) ?? ''
}
const checkLogin = async <T,>(cb?: () => T): Promise<T | void> => {
if (signer) {
return cb && cb()
@@ -349,6 +359,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
removeAccount,
publish,
signHttpAuth,
nip04Encrypt,
nip04Decrypt,
checkLogin,
signEvent,
getRelayList,

View File

@@ -23,4 +23,24 @@ export class Nip07Signer implements ISigner {
async signEvent(draftEvent: TDraftEvent) {
return await this.signer.signEvent(draftEvent)
}
async nip04Encrypt(pubkey: string, plainText: string) {
if (!this.signer) {
throw new Error('Not logged in')
}
if (!this.signer.nip04?.encrypt) {
throw new Error('The extension you are using does not support nip04 encryption')
}
return await this.signer.nip04.encrypt(pubkey, plainText)
}
async nip04Decrypt(pubkey: string, cipherText: string) {
if (!this.signer) {
throw new Error('Not logged in')
}
if (!this.signer.nip04?.decrypt) {
throw new Error('The extension you are using does not support nip04 decryption')
}
return await this.signer.nip04.decrypt(pubkey, cipherText)
}
}

View File

@@ -1,5 +1,5 @@
import { ISigner, TDraftEvent } from '@/types'
import { finalizeEvent, getPublicKey as nGetPublicKey, nip19 } from 'nostr-tools'
import { finalizeEvent, getPublicKey as nGetPublicKey, nip04, nip19 } from 'nostr-tools'
export class NsecSigner implements ISigner {
private privkey: Uint8Array | null = null
@@ -38,4 +38,18 @@ export class NsecSigner implements ISigner {
return null
}
}
async nip04Encrypt(pubkey: string, plainText: string) {
if (!this.privkey) {
throw new Error('Not logged in')
}
return nip04.encrypt(this.privkey, pubkey, plainText)
}
async nip04Decrypt(pubkey: string, cipherText: string) {
if (!this.privkey) {
throw new Error('Not logged in')
}
return nip04.decrypt(this.privkey, pubkey, cipherText)
}
}