feat: add pinned post functionality

This commit is contained in:
codytseng 2025-10-12 21:39:16 +08:00
parent 9c554da2da
commit d131026af9
31 changed files with 563 additions and 56 deletions

View file

@ -55,6 +55,7 @@ type TNostrContext = {
bookmarkListEvent: Event | null
favoriteRelaysEvent: Event | null
userEmojiListEvent: Event | null
pinListEvent: Event | null
notificationsSeenAt: number
account: TAccountPointer | null
accounts: TAccountPointer[]
@ -85,6 +86,7 @@ type TNostrContext = {
updateMuteListEvent: (muteListEvent: Event, privateTags: string[][]) => Promise<void>
updateBookmarkListEvent: (bookmarkListEvent: Event) => Promise<void>
updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void>
updatePinListEvent: (pinListEvent: Event) => Promise<void>
updateNotificationsSeenAt: (skipPublish?: boolean) => Promise<void>
}
@ -119,6 +121,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const [bookmarkListEvent, setBookmarkListEvent] = useState<Event | null>(null)
const [favoriteRelaysEvent, setFavoriteRelaysEvent] = useState<Event | null>(null)
const [userEmojiListEvent, setUserEmojiListEvent] = useState<Event | null>(null)
const [pinListEvent, setPinListEvent] = useState<Event | null>(null)
const [notificationsSeenAt, setNotificationsSeenAt] = useState(-1)
const [isInitialized, setIsInitialized] = useState(false)
@ -161,6 +164,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setFollowListEvent(null)
setMuteListEvent(null)
setBookmarkListEvent(null)
setPinListEvent(null)
setNotificationsSeenAt(-1)
if (!account) {
return
@ -189,7 +193,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
storedMuteListEvent,
storedBookmarkListEvent,
storedFavoriteRelaysEvent,
storedUserEmojiListEvent
storedUserEmojiListEvent,
storedPinListEvent
] = await Promise.all([
indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList),
indexedDb.getReplaceableEvent(account.pubkey, kinds.Metadata),
@ -197,7 +202,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
indexedDb.getReplaceableEvent(account.pubkey, kinds.Mutelist),
indexedDb.getReplaceableEvent(account.pubkey, kinds.BookmarkList),
indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.FAVORITE_RELAYS),
indexedDb.getReplaceableEvent(account.pubkey, kinds.UserEmojiList)
indexedDb.getReplaceableEvent(account.pubkey, kinds.UserEmojiList),
indexedDb.getReplaceableEvent(account.pubkey, kinds.Pinlist)
])
if (storedRelayListEvent) {
setRelayList(getRelayListFromEvent(storedRelayListEvent))
@ -221,6 +227,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
if (storedUserEmojiListEvent) {
setUserEmojiListEvent(storedUserEmojiListEvent)
}
if (storedPinListEvent) {
setPinListEvent(storedPinListEvent)
}
const relayListEvents = await client.fetchEvents(BIG_RELAY_URLS, {
kinds: [kinds.RelayList],
@ -243,7 +252,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
kinds.BookmarkList,
ExtendedKind.FAVORITE_RELAYS,
ExtendedKind.BLOSSOM_SERVER_LIST,
kinds.UserEmojiList
kinds.UserEmojiList,
kinds.Pinlist
],
authors: [account.pubkey]
},
@ -268,6 +278,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
e.kind === kinds.Application &&
getReplaceableEventIdentifier(e) === ApplicationDataKey.NOTIFICATIONS_SEEN_AT
)
const pinnedNotesEvent = sortedEvents.find((e) => e.kind === kinds.Pinlist)
if (profileEvent) {
const updatedProfileEvent = await indexedDb.putReplaceableEvent(profileEvent)
if (updatedProfileEvent.id === profileEvent.id) {
@ -314,6 +325,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setUserEmojiListEvent(updatedUserEmojiListEvent)
}
}
if (pinnedNotesEvent) {
const updatedPinnedNotesEvent = await indexedDb.putReplaceableEvent(pinnedNotesEvent)
if (updatedPinnedNotesEvent.id === pinnedNotesEvent.id) {
setPinListEvent(updatedPinnedNotesEvent)
}
}
const notificationsSeenAt = Math.max(
notificationsSeenAtEvent?.created_at ?? 0,
@ -726,6 +743,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setFavoriteRelaysEvent(newFavoriteRelaysEvent)
}
const updatePinListEvent = async (pinListEvent: Event) => {
const newPinListEvent = await indexedDb.putReplaceableEvent(pinListEvent)
if (newPinListEvent.id !== pinListEvent.id) return
setPinListEvent(newPinListEvent)
}
const updateNotificationsSeenAt = async (skipPublish = false) => {
if (!account) return
@ -761,6 +785,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
bookmarkListEvent,
favoriteRelaysEvent,
userEmojiListEvent,
pinListEvent,
notificationsSeenAt,
account,
accounts,
@ -788,6 +813,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
updateMuteListEvent,
updateBookmarkListEvent,
updateFavoriteRelaysEvent,
updatePinListEvent,
updateNotificationsSeenAt
}}
>

View file

@ -0,0 +1,111 @@
import { MAX_PINNED_NOTES } from '@/constants'
import { buildETag, createPinListDraftEvent } from '@/lib/draft-event'
import { getPinnedEventHexIdSetFromPinListEvent } from '@/lib/event-metadata'
import client from '@/services/client.service'
import { Event, kinds } from 'nostr-tools'
import { createContext, useContext, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useNostr } from './NostrProvider'
type TPinListContext = {
pinnedEventHexIdSet: Set<string>
pin: (event: Event) => Promise<void>
unpin: (event: Event) => Promise<void>
}
const PinListContext = createContext<TPinListContext | undefined>(undefined)
export const usePinList = () => {
const context = useContext(PinListContext)
if (!context) {
throw new Error('usePinList must be used within a PinListProvider')
}
return context
}
export function PinListProvider({ children }: { children: React.ReactNode }) {
const { t } = useTranslation()
const { pubkey: accountPubkey, pinListEvent, publish, updatePinListEvent } = useNostr()
const pinnedEventHexIdSet = useMemo(
() => getPinnedEventHexIdSetFromPinListEvent(pinListEvent),
[pinListEvent]
)
const pin = async (event: Event) => {
if (!accountPubkey) return
if (event.kind !== kinds.ShortTextNote || event.pubkey !== accountPubkey) return
const _pin = async () => {
const pinListEvent = await client.fetchPinListEvent(accountPubkey)
const currentTags = pinListEvent?.tags || []
if (currentTags.some((tag) => tag[0] === 'e' && tag[1] === event.id)) {
return
}
let newTags = [...currentTags, buildETag(event.id, event.pubkey)]
const eTagCount = newTags.filter((tag) => tag[0] === 'e').length
if (eTagCount > MAX_PINNED_NOTES) {
let removed = 0
const needRemove = eTagCount - MAX_PINNED_NOTES
newTags = newTags.filter((tag) => {
if (tag[0] === 'e' && removed < needRemove) {
removed += 1
return false
}
return true
})
}
const newPinListDraftEvent = createPinListDraftEvent(newTags, pinListEvent?.content)
const newPinListEvent = await publish(newPinListDraftEvent)
await updatePinListEvent(newPinListEvent)
}
const { unwrap } = toast.promise(_pin, {
loading: t('Pinning...'),
success: t('Pinned!'),
error: (err) => t('Failed to pin: {{error}}', { error: err.message })
})
await unwrap()
}
const unpin = async (event: Event) => {
if (!accountPubkey) return
if (event.kind !== kinds.ShortTextNote || event.pubkey !== accountPubkey) return
const _unpin = async () => {
const pinListEvent = await client.fetchPinListEvent(accountPubkey)
if (!pinListEvent) return
const newTags = pinListEvent.tags.filter((tag) => tag[0] !== 'e' || tag[1] !== event.id)
if (newTags.length === pinListEvent.tags.length) return
const newPinListDraftEvent = createPinListDraftEvent(newTags, pinListEvent.content)
const newPinListEvent = await publish(newPinListDraftEvent)
await updatePinListEvent(newPinListEvent)
}
const { unwrap } = toast.promise(_unpin, {
loading: t('Unpinning...'),
success: t('Unpinned!'),
error: (err) => t('Failed to unpin: {{error}}', { error: err.message })
})
await unwrap()
}
return (
<PinListContext.Provider
value={{
pinnedEventHexIdSet,
pin,
unpin
}}
>
{children}
</PinListContext.Provider>
)
}